diff --git a/.gitignore b/.gitignore index fecd347eae..52ef5af2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ yarn-error.log* # Claude Code local settings .claude/settings.local.json .cue-migration-backup-*/ + +# Bundled-plugin signing artifact (release-time build output; dev signs locally) +examples/plugins/*/signature.json diff --git a/CLAUDE-PLUGINS.md b/CLAUDE-PLUGINS.md index 9f38d6912d..d5dd392879 100644 --- a/CLAUDE-PLUGINS.md +++ b/CLAUDE-PLUGINS.md @@ -11,7 +11,7 @@ A plugin is one folder under `/plugins/` containing a `plugin.json` ma - Entire system is gated on `encoreFeatures.plugins === true` (off by default), re-read per call. - Every `plugins:*` IPC channel throws the sentinel `'PluginsDisabled'` when the flag is off, so the renderer can distinguish "feature off" from "no plugins installed". The gate runs OUTSIDE `withIpcErrorLogging` so the sentinel is not logged as a real failure. - `PluginManager.getActiveRecords()`, `getContributions()`, and `getAgentRegistry()` all return empty when the flag is off, regardless of what is on disk. -- `HOST_API_VERSION = '1.14.0'` (`src/shared/plugins/host-api.ts`) is the single source of truth for the host surface version. +- `HOST_API_VERSION = '1.16.0'` (`src/shared/plugins/host-api.ts`) is the single source of truth for the host surface version. ## File map @@ -102,12 +102,14 @@ HostResponse { id, ok, result?, error? } <---postMessage--- - `settings.get`: denies secret-looking keys (`SECRET_KEY_PATTERN`), the `encoreFeatures` gate, and any `plugins..*` namespace that is not the caller's own. - `settings.set`: only `plugins..*` keys; same secret/proto/gate guards; value must be JSON-storable and `<= MAX_SETTINGS_VALUE_BYTES = 64 * 1024`. - `sessions.list` / `sessions.get`: projected through `toSessionMetadata` - metadata only, never transcript/prompt text. + - `sessions.focus`: navigation only, gated by the narrow `sessions:focus` capability (NOT `tabs:manage`, which also carries create/close). Closed `{ sessionId, tabId? }` schema, `assertBrokerAllowed`, unknown sessionId throws. Implemented main-side like `pluginTabsFocus`: both verbs share `pluginAiFocusFields()` (`index.ts`), the main-side mirror of the renderer's `aiTabFocusFields()`, so the jump lands on the AI tab and nulls `activeGroupId` (a focused tiled group would otherwise keep owning the panel). No `tabId` -> the session's current AI tab, else its first; an explicit `tabId` that is not one of THAT session's AI tabs is rejected. - `transcripts.read`: PROJECTED session content - the caller declares which fields it needs and only allowlisted fields are returned (projection, not redaction). Resolves the session's REAL `projectPath` and RE-authorizes against it (the caller-claimed path is only a broker hint), refuses an untrusted plugin that also holds `net:fetch`/`net:connect`/`process:spawn` (the exfiltration combination), runs under the `ActionGuard` (high-risk rate/concurrency cap), and writes a per-read audit line. The metadata-only event bus is untouched. - `storage.*`: per-plugin KV via `kvStore` (values are strings). - - `events.subscribe` / `unsubscribe`: filtered to the fixed `PLUGIN_EVENT_TOPICS` catalog. Includes `tool.executed`, a metadata-only tool-lifecycle event (tool name + timing, never arguments or results). + - `events.subscribe` / `unsubscribe`: filtered to the fixed `PLUGIN_EVENT_TOPICS` catalog. Includes `tool.executed`, a metadata-only tool-lifecycle event (tool name + timing, never arguments or results), and `session.activated` `{ sessionId, tabId? }`, emitted from the `sessions:setActiveSessionId` handler with its own 100ms trailing debounce (separate from that handler's 400ms disk-write debounce) and skipped when the focused session did not actually change. - `agents.dispatch` and `process.spawn`: LIVE but fully gated. Each registers only when `deps.dispatch` / `deps.spawn` are injected (both are wired in `index.ts`). Every call runs the gate stack: allowlist-scope grant (`assertBrokerAllowed`), trusted signature (`assertTrustedActVerb`), Pianola risk ceiling (`assertLowOrMediumRisk`), a closed input schema, and the `ActionGuard` rate/concurrency cap. `agents.dispatch` ADDITIONALLY requires the separate unattended consent (see below) because plugin-initiated dispatch is never user-present. - `net.connect` / `net.send` / `net.close`: LIVE, trusted-only persistent outbound WebSocket. Registers only when `deps.netConnect` is injected. `wss:` only; the connect is pinned through the same `EgressGuard` lookup as `net.fetch` (loopback / RFC1918 / link-local / metadata blocked); caps at `MAX_SOCKETS_PER_PLUGIN = 4` per plugin and `MAX_FRAME_BYTES = 64 KB` per frame in both directions; `send`/`close` re-authorize the still-held host grant on every call so a mid-stream revoke denies the next call. The host owns the real socket; the plugin gets a `socketId` handle and receives frames as `net.connect:` topic events (via `pushEvent`, not the `PLUGIN_EVENT_TOPICS` catalog). Sockets are force-closed on disable / crash / uninstall. - `ui.panelPost`: requires `ui:panel` and targets ONLY one of the plugin's own declared panels (own-panels-only); JSON-only payload capped at `MAX_PANEL_POST_BYTES = 64 KB`; delivered to the panel page as a `maestro:panelData` window message. One-way push - there is no reply channel back to the sandbox. + - `ui.openPanel` / `ui.closePanel` / `ui.togglePanel`: requires `ui:panel` (no new consent); built by one shared factory and resolved through the SAME `deps.getPanel` lookup `ui.panelPost` uses, so a plugin can only summon its OWN panels. Non-`modal` placements are REJECTED (docked panels are always mounted, so open/close would be an untellable no-op). Registered only when `deps.panelVisibility` is wired (fail closed). Main broadcasts `plugins:panel-visibility` `{ pluginId, panelId, action }`; the renderer's App-level `PluginModalPanelMount` drives the `uiStore.openPluginPanelId` field (transient, namespaced `/`), and `close` only closes when that exact panel is the open one. **Direct dispatch requires unattended consent.** The `agents.dispatch` handler additionally calls the injected `dispatchUnattendedAllowed(pluginId, agentId)` predicate (wired in `index.ts` to `isPermittedUnattended(grantsOf(pluginId), 'agents:dispatch', agentId)`) and denies the call unless the plugin holds the separate, revocable UNATTENDED grant on top of the interactive `agents:dispatch` allowlist grant. The time-based scheduler (`PluginSchedulerHost`) enforces the same unattended check independently and calls the dispatch SINK directly, so it is unaffected by this handler. @@ -127,6 +129,7 @@ HostResponse { id, ok, result?, error? } <---postMessage--- | `settings:read` | low | none | non-secret app settings; not the feature gate, not a peer plugin's namespace | | `settings:write` | low | none | ONLY `plugins..*` keys | | `sessions:read` | medium | none | METADATA only, never transcript text | +| `sessions:focus` | low | none | navigation only: switch to an EXISTING session and land on its AI tab; no tab create/close power (deliberately not `tabs:manage`) | | `transcripts:read` | high | path | PROJECTED session content; project-scoped, re-authorized on the resolved path; refused with egress unless trusted; ActionGuard-bounded; audited | | `storage:read` | low | none | own KV | | `storage:write` | low | none | own KV | @@ -147,7 +150,7 @@ HostResponse { id, ok, result?, error? } <---postMessage--- - Every contributed id is namespaced `/`. The manifest author writes the bare local `id`; the loader stores both `localId` and the namespaced `id`. - Invalid individual items are dropped with a recorded error rather than failing the whole plugin (a typo in one theme must not hide good prompts). - On a namespaced-id collision the first wins (defended even though ids are plugin-scoped). For runtime agents, built-in agents always win, so a plugin can never shadow a first-party agent. -- Contribution types: `themes`, `iconPacks`, `prompts`, `settings`, `commandMacros`, `cueTriggers` (tier 0); `commands`, `panels`, `agents`, `tools`, `keybindings` (tier 1). `cueTriggers` with `action: 'notify'` run on tier 0; `action: 'dispatch'` is risk-gated (the Pianola risk engine) and surfaced to the user, never auto-fired when high-risk. A `tools` contribution is invokable with a result via the brokered `plugins:invoke-tool` round-trip, and (when `plugins` is on) is exposed to a spawned agent's model over MCP via `maestro-cli mcp serve` (claude/codex auto-injected, others best-guess), each model call risk-gated. A `keybindings` contribution's `command` must be a plugin-local id. Registering `agents`/`keybindings` does NOT by itself wire spawning / chord-binding - each is a separate step. +- Contribution types: `themes`, `iconPacks`, `prompts`, `settings`, `commandMacros`, `cueTriggers` (tier 0); `commands`, `panels`, `agents`, `tools`, `keybindings` (tier 1). `cueTriggers` with `action: 'notify'` run on tier 0; `action: 'dispatch'` is risk-gated (the Pianola risk engine) and surfaced to the user, never auto-fired when high-risk. A `tools` contribution is invokable with a result via the brokered `plugins:invoke-tool` round-trip, and (when `plugins` is on) is exposed to a spawned agent's model over MCP via `maestro-cli mcp serve` (claude/codex auto-injected, others best-guess), each model call risk-gated. A `panels` contribution carries an optional `size?: 'default' | 'full'` (`modal` placement only, parsed leniently: absent -> `default` silently, invalid -> manifest error plus `default`, panel never dropped), where `full` renders edge-to-edge overlay chrome. A `keybindings` contribution's `command` must be a plugin-local id. Registering `agents`/`keybindings` does NOT by itself wire spawning / chord-binding - each is a separate step. - `iconPacks` is a tier-0 contribution: the host validates SVG path data and hex colors, namespaces pack entries, and renders paths only through host-owned SVG markup in the group appearance picker. - `hostViews` are data-only contributions available to tier-0 and tier-1 plugins: `{ id, surface: 'movement' | 'cadenza', title, description?, blocks? }`. `blocks` is an optional BlockView block array, serialized UTF-8 is capped at 1,000,000 bytes, and the host renderer - not plugin code - draws it. Tier-1 runtime update/remove RPCs require `ui:hostView`, resolve only an already-declared local id, retain its title/surface, and reject cadenza decision/options or agent-routing payloads. @@ -191,8 +194,13 @@ Integrity ("files match what was signed") and trust ("key is recognized") are la `HOST_API_VERSION` is a permanent public contract once plugins ship. PATCH = host bug fix; MINOR = additive (new contribution point / manifest field / capability, older plugins keep working); MAJOR = remove or change the meaning of an existing one. A plugin pins `maestro.minHostApi`; the host loads it only when same-major and `host >= min`. -The current host is `1.14.0`; it added the `tool.executed` event topic and the -`ui.panelPost` host-to-panel push method. Earlier: `1.13.0` added the +The current host is `1.16.0`; it added the metadata-only `session.activated` +event topic, the `sessions.focus` method plus its narrow `sessions:focus` +capability, and the `ui.openPanel` / `ui.closePanel` / `ui.togglePanel` methods +plus the optional panel manifest field `size?: 'default' | 'full'`. (`1.15.0` is +taken by the Board + Profiles work on this fork, so it is skipped here.) +Earlier: `1.14.0` added the `tool.executed` event topic and the +`ui.panelPost` host-to-panel push method; `1.13.0` added the host-mediated `PluginUiSurface` registry and trusted-chrome guard; `1.12.0` added the `net:connect` capability and the `net.connect` / `net.send` / `net.close` methods; `1.11.0` added `groupings` + `ui:grouping`; `1.10.0` added diff --git a/docs/agent-guides/PLUGIN-DEVELOPMENT.md b/docs/agent-guides/PLUGIN-DEVELOPMENT.md index d34092ff69..b99714528c 100644 --- a/docs/agent-guides/PLUGIN-DEVELOPMENT.md +++ b/docs/agent-guides/PLUGIN-DEVELOPMENT.md @@ -94,7 +94,7 @@ One folder per plugin. The folder name and the manifest `id` must agree on insta | `name` | string | yes | display name | | `version` | string | yes | semver (distinct from `minHostApi`) | | `tier` | `0 \| 1 \| 2` | yes | trust/capability tier | -| `maestro` | `{ minHostApi: string }` | yes | minimum host API (current host is `1.9.0`) | +| `maestro` | `{ minHostApi: string }` | yes | minimum host API (current host is `1.16.0`) | | `description` | string | no | | | `author` | string | no | | | `license` | string | no | | @@ -288,12 +288,18 @@ Only `action: 'notify'` runs on tier 0. `action: 'dispatch'` needs `agents:dispa ### panels (tier 1) -`{ id, title, entry, placement }` where `entry` is a plugin-relative `.html` file and `placement` is `'modal' | 'left' | 'right' | 'main' | 'settings'` (defaults to `modal`). The `settings` placement renders only in the neutral Display settings host, never in plugin management, consent, uninstall, or grant/revoke UI. +`{ id, title, entry, placement, size? }` where `entry` is a plugin-relative `.html` file and `placement` is `'modal' | 'left' | 'right' | 'main' | 'settings'` (defaults to `modal`). The `settings` placement renders only in the neutral Display settings host, never in plugin management, consent, uninstall, or grant/revoke UI. + +`size` is `'default' | 'full'` and applies to `modal` panels only (defaults to `default`). As an explicit exception to the general contribution policy above (where a bad item is dropped), an unknown `size` reports a manifest error but keeps the panel, falling back to `default` rather than dropping the contribution. `default` renders the fixed modal chrome; `full` renders an edge-to-edge overlay inset a few pixels from the window edge, for mission-control-style surfaces you summon rather than browse. Requires `minHostApi: '1.16.0'`. ```json { "id": "vet-panel", "title": "Vet Panel", "entry": "panel.html", "placement": "right" } ``` +```json +{ "id": "flow", "title": "Agent Flow", "entry": "panel.html", "placement": "modal", "size": "full" } +``` + ### hostViews (tier 0 static; tier 1 updates) `{ id, surface: 'movement' | 'cadenza', title, description?, blocks? }` declares a static, @@ -394,6 +400,7 @@ Request these in `permissions` as `{ capability, scope?, reason? }`. `scope` nar | `settings:read` | low | none | read non-secret app settings + own `plugins..*` | `{ "capability": "settings:read" }` | | `settings:write` | low | none | write ONLY own `plugins..*` keys | `{ "capability": "settings:write" }` | | `sessions:read` | medium | none | list session METADATA (never transcript) | `{ "capability": "sessions:read" }` | +| `sessions:focus` | low | none | switch Maestro to one of the user's existing sessions (navigation only) | `{ "capability": "sessions:focus" }` | | `transcripts:read` | high | path | read PROJECTED session content (you declare fields) | `{ "capability": "transcripts:read", "scope": "/abs/project" }` | | `storage:read` | low | none | read own private key-value store | `{ "capability": "storage:read" }` | | `storage:write` | low | none | write own private key-value store | `{ "capability": "storage:write" }` | @@ -454,38 +461,42 @@ module.exports = { activate, deactivate }; Every method below is broker-gated and needs the matching capability granted. Signatures are copied from `buildSdk` (`src/main/plugins/plugin-sandbox-entry.ts`). -| SDK method | Capability | -| --------------------------------------------------------------------------------- | ---------------------------- | -| `maestro.pluginId` (string) | - | -| `maestro.fs.read(path)` -> `Promise` | `fs:read` | -| `maestro.fs.write(path, contents)` -> `Promise` | `fs:write` | -| `maestro.net.fetch(url, init?)` -> `Promise` | `net:fetch` | -| `maestro.net.connect(url, opts?)` -> `Promise<{ socketId }>` (`wss://` only) | `net:connect` | -| `maestro.net.send(socketId, data)` -> `Promise<{ ok: true }>` | `net:connect` | -| `maestro.net.close(socketId, opts?)` -> `Promise<{ ok: true }>` | `net:connect` | -| `maestro.agents.list()` | `agents:read` | -| `maestro.agents.get(agentId)` | `agents:read` | -| `maestro.agents.dispatch(agentId, prompt, opts?)` (needs unattended consent) | `agents:dispatch` | -| `maestro.notifications.toast(message, opts?)` -> `Promise` | `notifications:toast` | -| `maestro.settings.get(key)` | `settings:read` | -| `maestro.settings.set(key, value)` (key must be `plugins..*`) | `settings:write` | -| `maestro.sessions.list()` (metadata only) | `sessions:read` | -| `maestro.sessions.get(sessionId)` (metadata only) | `sessions:read` | -| `maestro.transcripts.read({ sessionId, fields, projectPath?, limit?, since? })` | `transcripts:read` | -| `maestro.storage.get(key)` | `storage:read` | -| `maestro.storage.keys()` | `storage:read` | -| `maestro.storage.set(key, value)` (value is a string) | `storage:write` | -| `maestro.storage.delete(key)` | `storage:write` | -| `maestro.ui.runCommand(commandId, args?)` | `ui:command` | -| `maestro.ui.hostView.update(localId, blocks)` -> `Promise` | `ui:hostView` | -| `maestro.ui.hostView.remove(localId)` -> `Promise` | `ui:hostView` | -| `maestro.ui.panelPost(panelId, data)` -> `Promise` (own panels, 64 KB JSON) | `ui:panel` | -| `maestro.events.on(topic, handler(payload, meta))` | - (delivery needs subscribe) | -| `maestro.events.subscribe(topics[])` | `events:subscribe` | -| `maestro.events.unsubscribe(topics?)` | `events:subscribe` | -| `maestro.commands.register(commandId, handler(args))` | - (invoked by host) | -| `maestro.tools.register(toolId, handler(args))` (result returned to host) | - (invoked by host) | -| `maestro.process.spawn(command, opts?)` (trusted + gated) | `process:spawn` | +| SDK method | Capability | +| ----------------------------------------------------------------------------------- | ---------------------------- | +| `maestro.pluginId` (string) | - | +| `maestro.fs.read(path)` -> `Promise` | `fs:read` | +| `maestro.fs.write(path, contents)` -> `Promise` | `fs:write` | +| `maestro.net.fetch(url, init?)` -> `Promise` | `net:fetch` | +| `maestro.net.connect(url, opts?)` -> `Promise<{ socketId }>` (`wss://` only) | `net:connect` | +| `maestro.net.send(socketId, data)` -> `Promise<{ ok: true }>` | `net:connect` | +| `maestro.net.close(socketId, opts?)` -> `Promise<{ ok: true }>` | `net:connect` | +| `maestro.agents.list()` | `agents:read` | +| `maestro.agents.get(agentId)` | `agents:read` | +| `maestro.agents.dispatch(agentId, prompt, opts?)` (needs unattended consent) | `agents:dispatch` | +| `maestro.notifications.toast(message, opts?)` -> `Promise` | `notifications:toast` | +| `maestro.settings.get(key)` | `settings:read` | +| `maestro.settings.set(key, value)` (key must be `plugins..*`) | `settings:write` | +| `maestro.sessions.list()` (metadata only) | `sessions:read` | +| `maestro.sessions.get(sessionId)` (metadata only) | `sessions:read` | +| `maestro.sessions.focus(sessionId, tabId?)` -> `Promise` (lands on an AI tab) | `sessions:focus` | +| `maestro.transcripts.read({ sessionId, fields, projectPath?, limit?, since? })` | `transcripts:read` | +| `maestro.storage.get(key)` | `storage:read` | +| `maestro.storage.keys()` | `storage:read` | +| `maestro.storage.set(key, value)` (value is a string) | `storage:write` | +| `maestro.storage.delete(key)` | `storage:write` | +| `maestro.ui.runCommand(commandId, args?)` | `ui:command` | +| `maestro.ui.hostView.update(localId, blocks)` -> `Promise` | `ui:hostView` | +| `maestro.ui.hostView.remove(localId)` -> `Promise` | `ui:hostView` | +| `maestro.ui.panelPost(panelId, data)` -> `Promise` (own panels, 64 KB JSON) | `ui:panel` | +| `maestro.ui.openPanel(panelId)` -> `Promise` (own `modal` panels only) | `ui:panel` | +| `maestro.ui.closePanel(panelId)` -> `Promise` (own `modal` panels only) | `ui:panel` | +| `maestro.ui.togglePanel(panelId)` -> `Promise` (own `modal` panels only) | `ui:panel` | +| `maestro.events.on(topic, handler(payload, meta))` | - (delivery needs subscribe) | +| `maestro.events.subscribe(topics[])` | `events:subscribe` | +| `maestro.events.unsubscribe(topics?)` | `events:subscribe` | +| `maestro.commands.register(commandId, handler(args))` | - (invoked by host) | +| `maestro.tools.register(toolId, handler(args))` (result returned to host) | - (invoked by host) | +| `maestro.process.spawn(command, opts?)` (trusted + gated) | `process:spawn` | `net.fetch` returns `{ status, statusText, headers, body }` (body is text, capped at 5 MB). Requests are egress-guarded: loopback, link-local, RFC1918, cloud-metadata, and the app's own port are blocked, and redirects are not followed (`redirect: 'error'`), so a 3xx to a non-granted host fails. @@ -607,6 +618,14 @@ await maestro.ui.panelPost('my-panel', { nodes }); ``` +### Summoning your own panel + +A `modal` panel normally opens from Settings -> Encore -> Plugins. To open it yourself - e.g. bind a `keybindings` chord to a command that pops a full-window overlay - call `maestro.ui.openPanel(panelId)`, `maestro.ui.closePanel(panelId)`, or `maestro.ui.togglePanel(panelId)`. All three take the LOCAL panel id, require `ui:panel` (no extra consent), and act ONLY on your own `modal` panels: a docked (`left`/`right`/`main`/`settings`) panel is rejected, since it is always mounted and has its own hide control, and `closePanel` is a no-op unless that exact panel is the one currently open, so you can never dismiss another plugin's surface. Escape, the backdrop, and the close button dismiss the panel too. Requires `minHostApi: '1.16.0'`. + +```js +maestro.commands.register('overlay', () => maestro.ui.togglePanel('flow')); +``` + --- ## 8. Events @@ -618,6 +637,7 @@ A plugin with `events:subscribe` receives a FIXED catalog of host topics (`src/s | `session.created` | `{ sessionId, title?, agentId?, projectPath? }` | | `session.updated` | `{ sessionId, title?, status? }` | | `session.removed` | `{ sessionId }` | +| `session.activated` | `{ sessionId, tabId? }` | | `agent.awaiting` | `{ agentId, tabId?, kind?, risk? }` | | `agent.statusChanged` | `{ agentId, tabId?, status }` | | `cue.fired` | `{ cueType, projectPath? }` | @@ -625,6 +645,8 @@ A plugin with `events:subscribe` receives a FIXED catalog of host topics (`src/s `tool.executed` fires when a tool call transitions (best-effort `phase`, e.g. running / completed / failed, when the provider reports one). It is metadata only: tool NAME and timing, never the tool's arguments or results. Requires `minHostApi: '1.14.0'`. +`session.activated` fires when the focused agent changes (opaque ids only, debounced to at most one event per ~100ms, and never re-fired for the session that is already focused). Use it to highlight whichever agent the user is looking at. Requires `minHostApi: '1.16.0'`. + Register handlers with `maestro.events.on(topic, fn)` first, then start delivery with `maestro.events.subscribe([...])`. Stop with `maestro.events.unsubscribe([...])` (or no argument for all). The handler receives `(payload, meta)` where `meta` is `{ topic, at }`. Unknown topics are ignored. --- diff --git a/examples/plugins/agent-flow/README.md b/examples/plugins/agent-flow/README.md index 6e50774e6f..7752c8d72f 100644 --- a/examples/plugins/agent-flow/README.md +++ b/examples/plugins/agent-flow/README.md @@ -1,11 +1,12 @@ # Agent Flow -A tier-2 Maestro plugin that visualizes what your agents are doing, live, as an -execution graph. It listens to the host's metadata-only event stream (tool -calls, agent status changes, completions, errors, and usage updates) and builds -one lane per session. Each lane holds the recent tool-call nodes for that -session, with timing and lifecycle phase, and the plugin pushes coalesced -snapshots to its own panel for rendering. +A tier-2 Maestro plugin that visualizes what your agents are doing, live, as a +full-window mission-control overlay summoned with `Alt+Shift+F`. It listens to +the host's metadata-only event stream (tool calls, agent status changes, +completions, errors, and usage updates) and builds one lane per session. Each +lane holds the recent tool-call nodes for that session, with timing and +lifecycle phase, and the plugin pushes coalesced snapshots to its own panel, +where every running agent is drawn as one node on a shared canvas. Everything the plugin sees is metadata only: tool names, timing, and lifecycle phase. It never receives tool arguments, tool results, prompt text, or agent @@ -15,7 +16,8 @@ output - those never cross the plugin event boundary. - Subscribes to `tool.executed`, `agent.statusChanged`, `agent.awaiting`, `agent.completed`, `agent.error`, `agent.exited`, `run.completed`, - `usage.updated`, `session.created`, `session.updated`, and `session.removed`. + `usage.updated`, `session.created`, `session.updated`, `session.removed`, and + `session.activated` (which agent the user is looking at, ids only). - Maintains an in-memory model: a lane per session (`{ sessionId, title, agentId, status, nodes, usage }`) where each node is a tool call (`{ toolCallId, toolName, phase, startedAt, endedAt, durationMs }`). @@ -23,40 +25,51 @@ output - those never cross the plugin event boundary. phase closes the node the `running` phase opened. - Caps each lane at the 300 most recent nodes and drops lanes for removed sessions. -- Pushes a coalesced `{ v, at, lanes }` snapshot to the `flow` panel at most - once per 250 ms, guarding the host's 64 KB panel-post cap. +- Pushes a coalesced `{ v, at, lanes, summary, focusedSessionId }` snapshot to + the `flow` panel at most once per 250 ms, guarding the host's 64 KB panel-post + cap. +- Summons and dismisses the overlay itself: the `overlay` command (bound to + `Alt+Shift+F`) calls `maestro.ui.togglePanel('flow')`, and a `jump` message + posted back by the panel calls `maestro.sessions.focus(sessionId)` to move + Maestro to that agent's AI tab. ## Panel UI The `flow` panel (`panel.html`) is a single self-contained HTML file (vanilla -JS + inline SVG/CSS, no external references) that renders each snapshot it +JS + inline SVG/CSS, no external references) rendered as a full-window overlay +(`{ "placement": "modal", "size": "full" }`). It renders each snapshot it receives as a `maestro:panelData` window message: -- **Node graph** - one horizontal lane per session (lane label = title, agent - id, and a green/yellow/red status dot), and within each lane a left-to-right - sequence of tool-call nodes connected by edges in execution order. Node color - follows phase: pulsing yellow for `running`, green for `completed`, red for - `failed`, gray for unknown. +- **Shared canvas** - every agent is ONE node, laid out on a single grid rather + than getting a lane row of its own, so a whole fleet is legible at a glance. + The node's ring follows Maestro's status language: green ready/idle, yellow + working, pulsing orange connecting, red error, blue waiting for input. A halo + pulses around the node while it is working or connecting, and the core shows + the count of tools currently in flight. +- **Tool satellites** - the most recent 6 tool calls orbit each node on thin + edges, one card each showing the tool NAME, its phase, and its duration + (`Bash` / `completed · 1.5s`). Running cards pulse; finished cards do not. +- **Cost and tokens** - a cost pill (`$0.1234`) plus a token bar under each + node, filled with the accumulated tokens against the reported context window + (amber past 70%, red past 90%). With no context window reported, the count is + shown without a bar rather than implying a capacity that was never sent. +- **Click to jump** - clicking a node, or a FINISHED tool card, posts + `{ commandId: 'jump', args: { sessionId } }` back to the sandbox, which calls + `maestro.sessions.focus(...)`; Maestro switches to that agent and lands on its + AI tab. The agent the user is currently looking at + (`snapshot.focusedSessionId`) wears a dashed accent ring. - **Pan / zoom** - drag the canvas background to pan, wheel to zoom around the - cursor (0.25x to 3x), double-click to reset. The transform and the current - selection both survive re-renders. -- **Inspector** - click a node to inspect its metadata (tool name, phase, - toolCallId, start/end time, duration formatted `1.2s` style); click a lane - label for session-level info (session id, agent id, status, and latest usage - figures - tokens, context window, cost - when present); click empty canvas to - close it. -- **Timeline** - a compact bottom strip maps wall-clock time to x-position, one - thin row per lane, each node drawn as a duration bar (running nodes extend to - "now" and re-extend on every snapshot). Clicking a bar selects the same node - in the graph. -- **Session tabs** - the header strip offers "All" plus one tab per lane; - selecting a tab filters both the graph and the timeline to that session. A + cursor (0.2x to 3x), double-click (or **Reset view**) to re-fit. The graph + auto-fits the window until the first manual pan or zoom, then stays put. +- **Dismiss** - Escape inside the overlay invokes the plugin's own `overlay` + command (the guest is a separate renderer process, so its key events cannot + reach the host's modal layer stack), as does pressing `Alt+Shift+F` again. A **Clear** button posts the `clear` command back to the sandbox. ## Activity and health (issue #1231) On top of the graph, the panel answers the "what is my long-running agent -actually doing right now" question with an activity summary and per-lane health +actually doing right now" question with an activity summary and per-agent health badges. This addresses [issue #1231](https://github.com/RunMaestro/Maestro/issues/1231) ("Provide more insight to long running thinking tasks"): how many background tool calls and @@ -68,23 +81,24 @@ run has broken on an error. Each segment is hidden when its count is 0 and colored with Maestro's status language (yellow for working and running tools, blue for waiting on input, red for errors). This is the count of background shell commands and agents running. -- **Per-lane health badges** - each lane label carries a coarse status badge +- **Per-node health badges** - each node carries a coarse status badge ("Working", "Waiting for input", "Idle", or the terminal "Completed" / - "Failed" state), a running-tool count ("3 tools") when tools are in flight, - and, while the lane is working, a live elapsed timer ("12s") measuring the time - since its last activity. -- **Stall warning** - when a working lane sees no activity for more than 30 + "Failed" state) and, while the agent is working, a live elapsed timer + ("Working · 12s") measuring the time since its last activity. The count of + tools in flight sits inside the node core. +- **Stall warning** - when a working agent sees no activity for more than 30 seconds an amber "No activity for Ns" badge appears, flagging a run that may be broken or never resolving. -- **Error badge** - when the lane's last `agent.error` is set, a red badge shows +- **Error badge** - when the agent's last `agent.error` is set, a red badge shows the error type plus a recoverability hint ("retrying" when recoverable, "needs attention" when not), so an API or network fault is visible at a glance. - **Live clock** - a 1-second interval re-renders only the summary strip and the - health badges (never the SVG graph) against the wall clock, so the elapsed - timer and stall warning keep advancing even when a stalled or errored lane - produces no further events and therefore no new snapshot. + health badges (never the SVG graph, whose animations would restart) against + the wall clock, so the elapsed timer and stall warning keep advancing even + when a stalled or errored agent produces no further events and therefore no + new snapshot. -This overlay shows **metadata only**: aggregate counts, coarse per-lane status +This overlay shows **metadata only**: aggregate counts, coarse per-agent status (`idle` / `busy` / `waiting_input` / `connecting` / `error`), timing since last activity, and an error type with a recoverable flag. It never surfaces thinking prose, prompt text, tool arguments, or tool output - those never cross the @@ -95,8 +109,10 @@ once the plugin is installed and add `panel.png` here.)_ ## Requirements -- A Maestro host implementing host API `1.14.0` or newer (for the - `maestro.ui.panelPost` host-to-panel channel). +- A Maestro host implementing host API `1.16.0` or newer (for the + `maestro.ui.panelPost` host-to-panel channel, the `ui.togglePanel` summon + verb, the panel `size` field, `maestro.sessions.focus`, and the + `session.activated` event). - The `plugins` Encore flag enabled. ## Install @@ -108,19 +124,21 @@ Enable the `plugins` Encore flag first (Settings), then either: - **Settings:** open the Extensions view and install from a local folder, pointing at `examples/plugins/agent-flow`. -At install you will be asked to grant the three requested capabilities -(`events:subscribe`, `ui:panel`, `sessions:read`). The panel appears in the right -bar once `ui:panel` is granted. The graph starts empty and fills in as agents -run; the "Agent Flow: Clear Graph" command resets it, and "Agent Flow: Refresh -Panel" re-pulls the current snapshot (the panel also does this automatically on -open). +At install you will be asked to grant the four requested capabilities +(`events:subscribe`, `ui:panel`, `sessions:read`, `sessions:focus`). Once +`ui:panel` is granted, press `Alt+Shift+F` (or run "Agent Flow: Toggle Overlay") to +summon the overlay; Escape or the same chord dismisses it. It starts empty and +fills in as agents run; the "Agent Flow: Clear Graph" command resets it, and +"Agent Flow: Refresh Panel" re-pulls the current snapshot (the panel also does +this automatically on open). ## Files - `plugin.json` - manifest (tier 2, panel + command contributions, permissions). - `main.js` - the sandbox entry: event handling, graph model, snapshot pushing. -- `panel.html` - the panel UI: node graph, pan/zoom, inspector, timeline, and - session tabs (single self-contained file, no external references). +- `panel.html` - the overlay UI: shared-canvas agent nodes, tool satellites, + cost/token readouts, pan/zoom, and click-to-jump (single self-contained file, + no external references). ## Security notes @@ -163,8 +181,8 @@ Each item below was confirmed by reading the final host and plugin code ## Result Agent Flow ships as a tier-2, in-repo example plugin -(`examples/plugins/agent-flow/`) plus the two additive host-API surfaces it -needed, both landed at **host API `1.14.0`**: +(`examples/plugins/agent-flow/`) plus the additive host-API surfaces it needed. +Two landed at **host API `1.14.0`**: - **`tool.executed` plugin event topic** (`src/shared/plugins/events.ts`) - metadata-only tool-call lifecycle events (name + timing, never arguments or @@ -174,10 +192,25 @@ needed, both landed at **host API `1.14.0`**: panels only, JSON only, 64 KB cap, one-way, delivered to the panel page as a `maestro:panelData` window message. +Four more landed at **host API `1.16.0`** to turn the docked panel into a +summonable full-window overlay: + +- **`session.activated` event topic** (`src/shared/plugins/events.ts`) - ids + only (`{ sessionId, tabId? }`), debounced, so the overlay can highlight the + agent the user is looking at. +- **`maestro.sessions.focus(sessionId, tabId?)`** - gated by the new narrow + `sessions:focus` capability; jumps to a session and lands on its AI tab. +- **`maestro.ui.openPanel / closePanel / togglePanel(panelId)`** - own panels + only, under the existing `ui:panel` capability, so a plugin can summon its own + surface from a keybinding. +- **Panel `size: 'default' | 'full'`** (`src/shared/plugins/contributions.ts`) - + a `modal` panel can render edge-to-edge instead of in the fixed 720x560 chrome. + The plugin's `main.js` subscribes to those events (plus agent/session/usage topics), maintains a per-session tool-call graph, and pushes coalesced -snapshots to its `flow` panel; `panel.html` renders the live node graph, -timeline, inspector, session tabs, and the issue #1231 activity/health overlay. +snapshots to its `flow` panel; `panel.html` renders the shared-canvas overlay - +one node per agent with tool satellites, cost/token readouts, click-to-jump, and +the issue #1231 activity/health overlay. ### How to try it @@ -186,10 +219,11 @@ timeline, inspector, session tabs, and the issue #1231 activity/health overlay. (or install from a local folder in the Settings Extensions view). Validate first with `maestro plugin validate ./examples/plugins/agent-flow`. 3. Enable the plugin and grant its requested capabilities (`events:subscribe`, - `ui:panel`, `sessions:read`). -4. Open the Agent Flow panel from the right bar and run any agent. Tool nodes - appear live, running nodes pulse and then close, and the overlay tracks - working/waiting/stalled/errored lanes. + `ui:panel`, `sessions:read`, `sessions:focus`). +4. Press `Alt+Shift+F` and run any agent. Each agent appears as a node, tool + satellites appear live and then settle, the overlay tracks + working/waiting/stalled/errored agents, and clicking a node jumps to that + agent's AI tab. ### Known limitations diff --git a/examples/plugins/agent-flow/main.js b/examples/plugins/agent-flow/main.js index b418e6f45a..4f2ee23cd4 100644 --- a/examples/plugins/agent-flow/main.js +++ b/examples/plugins/agent-flow/main.js @@ -10,6 +10,11 @@ // lane), and push coalesced snapshots to the `flow` panel via // `maestro.ui.panelPost`. Everything observed here is metadata only - tool // names, timing, and lifecycle phase - never arguments, results, or output. +// +// The overlay path: the `overlay` command (bound to a keybinding in plugin.json) +// summons or dismisses the full-window panel, `session.activated` tracks which +// agent the user is looking at so the panel can highlight it, and the panel +// posts a `jump` message back to move Maestro to a clicked node's session. 'use strict'; @@ -29,6 +34,7 @@ var TOPICS = [ 'session.created', 'session.updated', 'session.removed', + 'session.activated', ]; // Most recent nodes retained per lane before oldest are dropped. @@ -47,6 +53,11 @@ var SNAPSHOT_MAX_BYTES = 60000; var lanes = new Map(); var lastEventAt = 0; var snapshotTimer = 0; +// Session id of the agent the user is currently looking at, from the +// metadata-only `session.activated` event. Sent along in every snapshot so the +// overlay can highlight that node; the "focus current agent only" filter itself +// is Phase 2. +var focusedSessionId = ''; /** @type {MaestroSdk | null} */ var sdk = null; @@ -342,6 +353,18 @@ var HANDLERS = { 'session.removed': function (payload) { if (!payload || typeof payload.sessionId !== 'string') return; lanes.delete(payload.sessionId); + // The highlighted node is gone; drop the highlight rather than pointing at + // a lane that no longer exists. + if (focusedSessionId === payload.sessionId) focusedSessionId = ''; + }, + // Ids only - no title, no path, nothing derived from session content. The host + // already debounces rapid focus changes, so this is just a field assignment; + // the lane may not exist yet (the user can focus an agent that has produced no + // events), which is fine: the panel simply has nothing to highlight until it + // does. + 'session.activated': function (payload) { + if (!payload || typeof payload.sessionId !== 'string') return; + focusedSessionId = payload.sessionId; }, }; @@ -349,6 +372,18 @@ function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; } +// Run a brokered host call, ignoring both a synchronous throw and a rejected +// promise. Every host call here is fire-and-forget UI navigation: a denial +// (capability not granted) or a torn-down bridge must not take the plugin down. +function swallow(call) { + try { + var p = call(); + if (p && typeof p.then === 'function') p.then(undefined, function () {}); + } catch { + /* denial or bridge gone */ + } +} + function onEvent(topic, payload, meta) { var handler = HANDLERS[topic]; if (!handler) return; @@ -442,7 +477,13 @@ function buildSnapshot(cap) { var ordered = sortedLanes(); var out = new Array(ordered.length); for (var i = 0; i < ordered.length; i++) out[i] = laneSnapshot(ordered[i], cap); - return { v: 1, at: lastEventAt, lanes: out, summary: buildSummary(ordered) }; + return { + v: 1, + at: lastEventAt, + lanes: out, + summary: buildSummary(ordered), + focusedSessionId: focusedSessionId, + }; } function pushSnapshot() { @@ -535,6 +576,40 @@ function activate(maestro) { /* subscription denial is tolerated; handlers simply never fire */ } + // The contributed "overlay" command is what the Alt+Shift+F keybinding fires + // (and what the command palette entry runs): it summons or dismisses the + // full-window overlay. Toggling lives here rather than in the host so "press + // again to dismiss" stays the plugin's own semantics. + maestro.commands.register('overlay', function () { + swallow(function () { + return maestro.ui.togglePanel('flow'); + }); + }); + + // Posted back by the panel when the user clicks a node or a finished tool + // card: { sessionId, tabId? }. Not a contributed command - it is meaningless + // without args, so it stays out of the command palette. sessionId is validated + // here so a malformed panel message is a no-op instead of a host rejection. + maestro.commands.register('jump', function (args) { + if (!args || typeof args.sessionId !== 'string' || !args.sessionId) return; + var tabId = typeof args.tabId === 'string' && args.tabId ? args.tabId : undefined; + swallow(function () { + var p = maestro.sessions.focus(args.sessionId, tabId); + // Dismiss the overlay once a jump lands: the graph is a launcher, so + // clicking a node should return the user to the workspace rather than + // leave the full-window overlay covering the agent they just navigated + // to. Close only on a successful focus so a rejected/false jump keeps + // the graph up. + if (p && typeof p.then === 'function') { + return p.then(function (ok) { + if (ok !== false) maestro.ui.closePanel('flow'); + return ok; + }); + } + return p; + }); + }); + // The contributed "clear" command resets the whole graph. maestro.commands.register('clear', function () { resetModel(); @@ -558,6 +633,7 @@ function deactivate() { snapshotTimer = 0; } resetModel(); + focusedSessionId = ''; sdk = null; } diff --git a/examples/plugins/agent-flow/panel.html b/examples/plugins/agent-flow/panel.html index 908dd2264a..b961b7db3b 100644 --- a/examples/plugins/agent-flow/panel.html +++ b/examples/plugins/agent-flow/panel.html @@ -5,6 +5,8 @@ Agent Flow @@ -281,22 +238,16 @@
Agent Flow
-
- +
+
Click an agent to jump · wheel to zoom · double-click to reset
+ +
- -
-
- - - -
Waiting for agent activity...
-
- -
-
-
Timeline
- +
+ + + +
Waiting for agent activity...
diff --git a/examples/plugins/agent-flow/plugin.json b/examples/plugins/agent-flow/plugin.json index 703f93eed5..05e63fdbbc 100644 --- a/examples/plugins/agent-flow/plugin.json +++ b/examples/plugins/agent-flow/plugin.json @@ -1,9 +1,9 @@ { "id": "agent-flow", "name": "Agent Flow", - "version": "0.1.0", + "version": "0.2.0", "tier": 2, - "maestro": { "minHostApi": "1.14.0" }, + "maestro": { "minHostApi": "1.16.0" }, "description": "Live per-session execution-graph visualization built from host tool + agent lifecycle events.", "category": "insights", "beta": true, @@ -17,15 +17,34 @@ { "capability": "sessions:read", "reason": "Seed lane titles and agent ids for open sessions at startup." + }, + { + "capability": "sessions:focus", + "reason": "Jump to an agent's session when you click its node in the overlay." } ], "contributes": { "panels": [ - { "id": "flow", "title": "Agent Flow", "entry": "panel.html", "placement": "right" } + { + "id": "flow", + "title": "Agent Flow", + "entry": "panel.html", + "placement": "modal", + "size": "full" + } ], "commands": [ + { "id": "overlay", "title": "Agent Flow: Toggle Overlay" }, { "id": "clear", "title": "Agent Flow: Clear Graph" }, { "id": "sync", "title": "Agent Flow: Refresh Panel" } + ], + "keybindings": [ + { + "id": "toggle-overlay", + "key": "Alt+Shift+F", + "command": "overlay", + "description": "Summon or dismiss the Agent Flow overlay" + } ] } } diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 9c28b56e56..d354681123 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,8 +1,8 @@ { "name": "@maestro/plugin-sdk", - "version": "0.9.0", + "version": "0.11.0", "description": "Typed authoring surface for Maestro plugins (manifest, contributions, permissions, events, and the sandbox runtime API).", - "//": "Self-contained, dependency-free. The vendored plugin contracts track the Maestro plugin HOST_API_VERSION (1.14.0) from src/shared/plugins/host-api.ts; a drift-guard test asserts parity with those sources. Bump in lockstep with that contract: MINOR when the host adds a backward-compatible capability/contribution/method, MAJOR when one changes meaning or is removed.", + "//": "Self-contained, dependency-free. The vendored plugin contracts track the Maestro plugin HOST_API_VERSION (1.16.0) from src/shared/plugins/host-api.ts; a drift-guard test asserts parity with those sources. Bump in lockstep with that contract: MINOR when the host adds a backward-compatible capability/contribution/method, MAJOR when one changes meaning or is removed.", "type": "module", "license": "AGPL-3.0-only", "main": "dist/index.js", diff --git a/packages/plugin-sdk/src/__tests__/drift.test.ts b/packages/plugin-sdk/src/__tests__/drift.test.ts index 3b123f82b4..f764c87d70 100644 --- a/packages/plugin-sdk/src/__tests__/drift.test.ts +++ b/packages/plugin-sdk/src/__tests__/drift.test.ts @@ -96,9 +96,9 @@ describe('@maestro/plugin-sdk vendored-contract drift guard', () => { expect(HOST_METHOD_CAPABILITY).toEqual(SRC_HOST_METHOD_CAPABILITY); }); - it('HOST_API_VERSION matches the source and is pinned to 1.14.0', () => { + it('HOST_API_VERSION matches the source and is pinned to 1.16.0', () => { expect(HOST_API_VERSION).toBe(SRC_HOST_API_VERSION); - expect(HOST_API_VERSION).toBe('1.14.0'); + expect(HOST_API_VERSION).toBe('1.16.0'); }); it('capability risk and descriptions match the source', () => { diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fd3dea8ccf..1591ee070d 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -80,6 +80,7 @@ export type PluginCapability = | 'sessions:read' // list sessions + read their metadata (NEVER raw transcript content) | 'sessions:create' // create a new Maestro session/tab shell (no implicit dispatch) | 'sessions:write' // update/remove session metadata/state + | 'sessions:focus' // move Maestro's focus to an existing session (never reads content, never mutates it) | 'history:read' // read metadata-only history entries (never raw transcript content) | 'transcripts:read' // read PROJECTED session content (consented, audited, egress-locked) | 'transcripts:write' // append/update brokered transcript entries for a session @@ -114,6 +115,7 @@ export const PLUGIN_CAPABILITIES: readonly PluginCapability[] = [ 'sessions:read', 'sessions:create', 'sessions:write', + 'sessions:focus', 'history:read', 'transcripts:read', 'transcripts:write', @@ -147,6 +149,10 @@ const CAPABILITY_RISK: Record = { 'storage:write': 'low', 'settings:write': 'low', 'ui:command': 'low', + // Navigation only: it moves the user's view to a session that already exists. + // It cannot read, create, or modify anything, so it is deliberately cheaper + // than tabs:manage (which also carries tab creation and destruction). + 'sessions:focus': 'low', 'fs:read': 'medium', 'fs:watch': 'medium', 'net:fetch': 'medium', @@ -205,6 +211,7 @@ const CAPABILITY_SCOPE_KIND: Record = { 'sessions:read': 'none', 'sessions:create': 'none', 'sessions:write': 'none', + 'sessions:focus': 'none', 'storage:read': 'none', 'storage:write': 'none', 'storage:sql': 'none', @@ -364,6 +371,8 @@ export function describeCapability(capability: PluginCapability): string { return 'Create Maestro sessions'; case 'sessions:write': return 'Modify Maestro sessions'; + case 'sessions:focus': + return 'Switch Maestro to one of your existing sessions'; case 'history:read': return 'Read metadata-only history entries'; case 'storage:read': @@ -410,7 +419,16 @@ export function describeCapability(capability: PluginCapability): string { // --- Host API version (from shared/plugins/host-api.ts) --------------------- /** - * The host API version this Maestro build implements. Bumped to 1.14.0 for the + * The host API version this Maestro build implements. Bumped to 1.16.0 for three + * backward-compatible additions: the metadata-only `session.activated` event + * topic (`{ sessionId, tabId? }`, opaque ids only, fired when the focused agent + * changes), the `sessions.focus` method plus its narrow `sessions:focus` + * capability (navigate to an existing session's AI tab; no tab create/close + * power), and the summonable-panel trio `ui.openPanel` / `ui.closePanel` / + * `ui.togglePanel` under the existing `ui:panel` capability alongside the + * optional panel manifest field `size?: 'default' | 'full'` (absent or invalid + * => `default`, so older manifests are untouched). 1.15.0 is taken by the Board + * + Profiles work, so this fork skips it. 1.14.0 added the * backward-compatible additive `tool.executed` event topic (metadata-only tool * lifecycle: name + timing, never arguments or results) plus the `ui.panelPost` * host-to-panel push method (own-panels-only, JSON-only, MAX_PANEL_POST_BYTES @@ -432,7 +450,7 @@ export function describeCapability(capability: PluginCapability): string { * `ui:contribute` / `ui:panel` / `ui:render-unsafe`; 1.3.0 added `tools` + * `keybindings`; 1.2.0 added `transcripts:read`. */ -export const HOST_API_VERSION = '1.14.0'; +export const HOST_API_VERSION = '1.16.0'; /** Result of checking a plugin's declared host-API requirement. */ export interface HostApiCompatibility { @@ -876,6 +894,11 @@ export interface CommandContribution { /** Where a contributed panel docks. `modal` (default) keeps today's behavior. */ export type PanelPlacement = 'modal' | 'left' | 'right' | 'main' | 'settings'; +/** Chrome size for a `modal` panel. `full` renders edge-to-edge (a summonable + * full-window overlay); absent/invalid parses to `default`. Presentation only - + * it never changes where a panel routes. Ignored by docked placements. */ +export type PanelSize = 'default' | 'full'; + /** A UI panel a (tier-1) plugin contributes, rendered in a locked-down sandboxed * iframe. `entry` is a plugin-relative HTML file (traversal-checked). */ export interface PanelContribution { @@ -885,6 +908,7 @@ export interface PanelContribution { title: string; entry: string; placement: PanelPlacement; + size: PanelSize; } /** A runtime agent a (tier-1) plugin registers - a Left Bar entry backed by a @@ -1160,6 +1184,7 @@ export const PLUGIN_EVENT_TOPICS = [ 'history.entryAdded', // a history entry was added (ids/classification only) 'agent.completed', // an agent reached a terminal state (metadata only, no output) 'tool.executed', // a tool call started or finished (name + timing only, no arguments or results) + 'session.activated', // the focused agent changed (ids only, no titles or content) ] as const; export type PluginEventTopic = (typeof PLUGIN_EVENT_TOPICS)[number]; @@ -1257,6 +1282,9 @@ export interface PluginEventPayloads { timestamp: number; durationMs?: number; }; + /** The focused agent changed. Opaque ids ONLY - no title, no project path, + * nothing derived from the session's content. */ + 'session.activated': { sessionId: string; tabId?: string }; } /** A typed host event. */ @@ -1290,6 +1318,7 @@ export const HOST_API = { 'sessions.create': { capability: 'sessions:create' }, 'sessions.update': { capability: 'sessions:write' }, 'sessions.delete': { capability: 'sessions:write' }, + 'sessions.focus': { capability: 'sessions:focus' }, 'history.list': { capability: 'history:read' }, 'history.get': { capability: 'history:read' }, 'transcripts.read': { capability: 'transcripts:read' }, @@ -1304,6 +1333,9 @@ export const HOST_API = { 'ui.hostViewUpdate': { capability: 'ui:hostView' }, 'ui.hostViewRemove': { capability: 'ui:hostView' }, 'ui.panelPost': { capability: 'ui:panel' }, + 'ui.openPanel': { capability: 'ui:panel' }, + 'ui.closePanel': { capability: 'ui:panel' }, + 'ui.togglePanel': { capability: 'ui:panel' }, 'tabs.list': { capability: 'tabs:manage' }, 'tabs.create': { capability: 'tabs:manage' }, 'tabs.focus': { capability: 'tabs:manage' }, @@ -1482,6 +1514,10 @@ export interface MaestroSessionsApi { } ): Promise; delete(sessionId: string): Promise; + /** Move the user's focus to an existing session (`sessions:focus`), landing on + * its AI tab. Omit `tabId` to keep whichever AI tab that session already had + * active. Navigation only - it neither reads nor modifies the session. */ + focus(sessionId: string, tabId?: string): Promise; } /** Read PROJECTED, consented, audited session content (`transcripts:read`) or @@ -1540,6 +1576,15 @@ export interface MaestroUiApi { * (`ui:panel`). Delivered to the panel page as a `maestro:panelData` window * message; JSON-only, capped at MAX_PANEL_POST_BYTES, no reply channel. */ panelPost(panelId: string, data: unknown): Promise; + /** Show one of this plugin's OWN `modal` panels as a host-drawn overlay + * (`ui:panel`). Own-panels-only: a foreign or namespaced id never resolves, + * and a docked panel is rejected. */ + openPanel(panelId: string): Promise; + /** Hide one of this plugin's own modal panels, if it is the open one. */ + closePanel(panelId: string): Promise; + /** Open the panel, or close it if it is already the open one - the + * press-again-to-dismiss half of a hotkey-summoned overlay. */ + togglePanel(panelId: string): Promise; } /** Manage Maestro tabs (`tabs:manage`). */ diff --git a/src/__tests__/main/ipc/handlers/persistence.test.ts b/src/__tests__/main/ipc/handlers/persistence.test.ts index 24ab11f4c2..ebffe77c38 100644 --- a/src/__tests__/main/ipc/handlers/persistence.test.ts +++ b/src/__tests__/main/ipc/handlers/persistence.test.ts @@ -230,6 +230,143 @@ describe('persistence IPC handlers', () => { expect(mockSessionsStore.set).toHaveBeenCalledWith('activeSessionId', 'quit-id'); }); + + describe('session.activated plugin event', () => { + let emitPluginEvent: ReturnType; + let setHandler: (event: unknown, id: string) => Promise; + + beforeEach(() => { + handlers.clear(); + emitPluginEvent = vi.fn(); + const deps: PersistenceHandlerDependencies = { + settingsStore: mockSettingsStore as unknown as Store, + sessionsStore: mockSessionsStore as unknown as Store, + groupsStore: mockGroupsStore as unknown as Store, + getWebServer: getWebServerFn, + safeSend: mockSafeSend, + emitPluginEvent, + }; + registerPersistenceHandlers(deps); + setHandler = handlers.get('sessions:setActiveSessionId') as typeof setHandler; + }); + + it('emits a metadata-only session.activated after its own short debounce', async () => { + await setHandler({}, 'sess-1'); + expect(emitPluginEvent).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(emitPluginEvent).toHaveBeenCalledTimes(1); + const event = emitPluginEvent.mock.calls[0][0]; + expect(event.topic).toBe('session.activated'); + expect(event.payload).toEqual({ sessionId: 'sess-1' }); + expect(typeof event.at).toBe('string'); + }); + + it('coalesces a burst of switches into one event for the session landed on', async () => { + await setHandler({}, 'a'); + await setHandler({}, 'b'); + await setHandler({}, 'c'); + + vi.advanceTimersByTime(100); + expect(emitPluginEvent).toHaveBeenCalledTimes(1); + expect(emitPluginEvent.mock.calls[0][0].payload).toEqual({ sessionId: 'c' }); + }); + + it('does not re-emit when the same session is re-focused', async () => { + await setHandler({}, 'same'); + vi.advanceTimersByTime(100); + await setHandler({}, 'same'); + vi.advanceTimersByTime(100); + + expect(emitPluginEvent).toHaveBeenCalledTimes(1); + }); + + it('ignores an empty session id', async () => { + await setHandler({}, ''); + vi.advanceTimersByTime(100); + + expect(emitPluginEvent).not.toHaveBeenCalled(); + }); + + // Regression: the plugin focus verbs (index.ts) emit session.activated + // directly, bypassing flushSessionActivated's dedupe. If the plugin path + // does not record its id via noteSessionActivated, the two paths desync: + // after the user is on A and a plugin focuses B, returning to A would be + // wrongly suppressed and subscribers stay stuck on B. + it('keeps the plugin path in sync so returning to a prior session still emits', async () => { + handlers.clear(); + const localEmit = vi.fn(); + const { noteSessionActivated } = registerPersistenceHandlers({ + settingsStore: mockSettingsStore as unknown as Store, + sessionsStore: mockSessionsStore as unknown as Store, + groupsStore: mockGroupsStore as unknown as Store, + getWebServer: getWebServerFn, + safeSend: mockSafeSend, + emitPluginEvent: localEmit, + }); + const localSetHandler = handlers.get('sessions:setActiveSessionId') as ( + event: unknown, + id: string + ) => Promise; + + // User navigates to A: the flush path emits A (lastEmitted = A). + await localSetHandler({}, 'A'); + vi.advanceTimersByTime(100); + expect(localEmit).toHaveBeenCalledTimes(1); + expect(localEmit.mock.calls[0][0].payload).toEqual({ sessionId: 'A' }); + + // Agent Flow calls sessions.focus(B): index.ts emits B directly, then + // records it through the shared dedupe. Simulate that record here. + noteSessionActivated('B'); + + // User returns to A: A differs from the last-emitted id (now B), so it + // must still be emitted rather than suppressed as a repeat. + await localSetHandler({}, 'A'); + vi.advanceTimersByTime(100); + expect(localEmit).toHaveBeenCalledTimes(2); + expect(localEmit.mock.calls[1][0].payload).toEqual({ sessionId: 'A' }); + }); + + // Regression (race): a direct plugin focus must supersede an already-queued + // debounced flush for a DIFFERENT session, or that stale timer fires after + // the plugin's emit and re-announces the wrong session. + it('cancels a pending debounced flush when the plugin directly focuses another session', async () => { + handlers.clear(); + const localEmit = vi.fn(); + const { noteSessionActivated } = registerPersistenceHandlers({ + settingsStore: mockSettingsStore as unknown as Store, + sessionsStore: mockSessionsStore as unknown as Store, + groupsStore: mockGroupsStore as unknown as Store, + getWebServer: getWebServerFn, + safeSend: mockSafeSend, + emitPluginEvent: localEmit, + }); + const localSetHandler = handlers.get('sessions:setActiveSessionId') as ( + event: unknown, + id: string + ) => Promise; + + // User navigates to A: its 100ms flush is now armed but has NOT fired. + await localSetHandler({}, 'A'); + expect(localEmit).not.toHaveBeenCalled(); + + // Agent Flow directly focuses B mid-window (index.ts emits B on the bus + // and records it here). This must cancel the pending A flush. + noteSessionActivated('B'); + + // Let the original A timer elapse: it must be dead, so A is never emitted + // after B. Subscribers stay on B (the real active session). + vi.advanceTimersByTime(100); + expect(localEmit).not.toHaveBeenCalled(); + + // And a later genuine navigation back to A still emits (B was the last + // recorded id, so A is not a duplicate). + await localSetHandler({}, 'A'); + vi.advanceTimersByTime(100); + expect(localEmit).toHaveBeenCalledTimes(1); + expect(localEmit.mock.calls[0][0].payload).toEqual({ sessionId: 'A' }); + }); + }); }); describe('settings:get', () => { diff --git a/src/__tests__/main/plugins/plugin-host-handlers.test.ts b/src/__tests__/main/plugins/plugin-host-handlers.test.ts index 56037c7ede..c41cbf35c2 100644 --- a/src/__tests__/main/plugins/plugin-host-handlers.test.ts +++ b/src/__tests__/main/plugins/plugin-host-handlers.test.ts @@ -395,6 +395,117 @@ describe('ui.panelPost', () => { }); }); +describe('ui.openPanel / ui.closePanel / ui.togglePanel', () => { + // The plugin 'p' declares a modal panel 'flow' (summonable) and a docked + // panel 'side' (always mounted, so not summonable). + const getPanel = (pluginId: string, localId: string) => { + if (pluginId !== 'p') return null; + if (localId === 'flow') { + return { + id: 'p/flow', + localId: 'flow', + pluginId: 'p', + title: 'Agent Flow', + entry: 'panel.html', + placement: 'modal' as const, + size: 'full' as const, + }; + } + if (localId === 'side') { + return { + id: 'p/side', + localId: 'side', + pluginId: 'p', + title: 'Side', + entry: 'side.html', + placement: 'right' as const, + size: 'default' as const, + }; + } + return null; + }; + + const granted = (panelVisibility: ReturnType) => + buildHostCallHandlers( + makeDeps({ panelVisibility, getPanel, broker: brokerFor(() => [grant('ui:panel')]) }) + ); + + it('is not registered at all when the sink dependency is absent (fail closed)', () => { + const h = buildHostCallHandlers(makeDeps()); + expect(h['ui.openPanel']).toBeUndefined(); + expect(h['ui.closePanel']).toBeUndefined(); + expect(h['ui.togglePanel']).toBeUndefined(); + }); + + it('denies when ui:panel is not granted', async () => { + const panelVisibility = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelVisibility, getPanel, broker: brokerFor(() => []) }) + ); + await expect(h['ui.togglePanel']!('p', { panelId: 'flow' })).rejects.toThrow( + /permission denied/ + ); + expect(panelVisibility).not.toHaveBeenCalled(); + }); + + it('forwards the namespaced id and the matching action for each verb', async () => { + const panelVisibility = vi.fn(); + const h = granted(panelVisibility); + await expect(h['ui.openPanel']!('p', { panelId: 'flow' })).resolves.toEqual({ ok: true }); + await expect(h['ui.closePanel']!('p', { panelId: 'flow' })).resolves.toEqual({ ok: true }); + await expect(h['ui.togglePanel']!('p', { panelId: 'flow' })).resolves.toEqual({ ok: true }); + expect(panelVisibility.mock.calls).toEqual([ + ['p', 'p/flow', 'open'], + ['p', 'p/flow', 'close'], + ['p', 'p/flow', 'toggle'], + ]); + }); + + it("denies an undeclared or another plugin's panel id (own panels only)", async () => { + const panelVisibility = vi.fn(); + const h = granted(panelVisibility); + await expect(h['ui.openPanel']!('p', { panelId: 'nope' })).rejects.toThrow(/not declared/); + // An already-namespaced or foreign id is treated as a local id and never + // resolves against this plugin's declarations. + await expect(h['ui.togglePanel']!('p', { panelId: 'other/flow' })).rejects.toThrow( + /not declared/ + ); + // A different calling plugin cannot reach 'p''s panel either. + await expect(h['ui.closePanel']!('other', { panelId: 'flow' })).rejects.toThrow(/not declared/); + expect(panelVisibility).not.toHaveBeenCalled(); + }); + + it('denies a docked panel (only modal panels have a summonable host)', async () => { + const panelVisibility = vi.fn(); + const h = granted(panelVisibility); + await expect(h['ui.openPanel']!('p', { panelId: 'side' })).rejects.toThrow(/not a modal panel/); + expect(panelVisibility).not.toHaveBeenCalled(); + }); + + it('rejects a missing, blank, or untrimmed panelId', async () => { + const panelVisibility = vi.fn(); + const h = granted(panelVisibility); + await expect(h['ui.openPanel']!('p', {})).rejects.toThrow(/panelId is required/); + await expect(h['ui.openPanel']!('p', { panelId: ' ' })).rejects.toThrow( + /panelId is required/ + ); + await expect(h['ui.openPanel']!('p', { panelId: ' flow' })).rejects.toThrow( + /panelId is required/ + ); + expect(panelVisibility).not.toHaveBeenCalled(); + }); + + it('rejects a caller-supplied extra field (closed schema)', async () => { + const panelVisibility = vi.fn(); + const h = granted(panelVisibility); + // No data may ride along on what is a pure show/hide signal. + await expect(h['ui.togglePanel']!('p', { panelId: 'flow', data: { n: 1 } })).rejects.toThrow( + /closed schema/ + ); + expect(panelVisibility).not.toHaveBeenCalled(); + }); +}); + describe('events.subscribe / events.unsubscribe', () => { it('delegate to the bus and filter to catalog topics', async () => { const bus = new PluginEventBusImpl({ isPermitted: () => true, push: () => true }); @@ -976,6 +1087,57 @@ describe('brokered non-act host API breadth', () => { await expect(disabled['sessions.create']!('p', {})).rejects.toThrow(/unavailable/); }); + it('focuses an existing session under sessions:focus and rejects stale or over-wide calls', async () => { + const focused: Array<{ sessionId: string; tabId?: string }> = []; + let grants: PermissionGrant[] = [grant('sessions:focus')]; + const h = buildHostCallHandlers( + makeDeps({ + broker: brokerFor(() => grants), + sessionsGet: (id) => (id === 's1' ? { id: 's1', title: 'One' } : null), + sessionsFocus: async (sessionId, tabId) => { + if (tabId && tabId !== 't1') return false; + focused.push({ sessionId, tabId }); + return true; + }, + }) + ); + + await expect(h['sessions.focus']!('p', { sessionId: 's1' })).resolves.toEqual({ ok: true }); + await expect(h['sessions.focus']!('p', { sessionId: 's1', tabId: 't1' })).resolves.toEqual({ + ok: true, + }); + expect(focused).toEqual([{ sessionId: 's1' }, { sessionId: 's1', tabId: 't1' }]); + + // Unknown session is rejected before the effect runs. + await expect(h['sessions.focus']!('p', { sessionId: 'nope' })).rejects.toThrow( + /unknown sessionId/ + ); + // A tab that is not the session's own resolves false main-side. + await expect(h['sessions.focus']!('p', { sessionId: 's1', tabId: 'other' })).rejects.toThrow( + /unknown focus target/ + ); + // Closed schema: focus is navigation, nothing else rides along. + await expect( + h['sessions.focus']!('p', { sessionId: 's1', patch: { title: 'x' } }) + ).rejects.toThrow(); + expect(focused).toHaveLength(2); + + grants = []; + await expect(h['sessions.focus']!('p', { sessionId: 's1' })).rejects.toThrow( + /permission denied/ + ); + + const disabled = buildHostCallHandlers( + makeDeps({ + broker: brokerFor(() => [grant('sessions:focus')]), + sessionsGet: () => ({ id: 's1', title: 'One' }), + }) + ); + await expect(disabled['sessions.focus']!('p', { sessionId: 's1' })).rejects.toThrow( + /unavailable/ + ); + }); + it('manages tabs through injected tab deps and denies stale tab ids cleanly', async () => { const tabs = new Map([ ['t1', { id: 't1', sessionId: 's1', type: 'ai' as const, title: 'One' }], diff --git a/src/__tests__/plugins/agent-flow-main.test.ts b/src/__tests__/plugins/agent-flow-main.test.ts new file mode 100644 index 0000000000..c2ddf063d6 --- /dev/null +++ b/src/__tests__/plugins/agent-flow-main.test.ts @@ -0,0 +1,170 @@ +/** + * Agent Flow plugin sandbox logic (`examples/plugins/agent-flow/main.js`). + * + * The file is plain CommonJS with no `require` calls (it runs through + * `new vm.Script` inside the plugin utilityProcess), so it can be loaded here + * directly with `createRequire` and driven through a stub `maestro` SDK. + * + * Covers the Phase 1 overlay wiring: the `overlay` command toggling the plugin's + * own panel, the panel-posted `jump` message reaching `sessions.focus`, and the + * metadata-only `session.activated` topic riding along in snapshots. + */ + +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; + +const MAIN_JS = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../examples/plugins/agent-flow/main.js' +); + +type EventHandler = (payload: unknown, meta?: unknown) => void; +type CommandHandler = (args?: unknown) => void; + +interface Stub { + sdk: Record; + emit: (topic: string, payload: unknown) => void; + run: (commandId: string, args?: unknown) => void; + panelPost: ReturnType; + togglePanel: ReturnType; + closePanel: ReturnType; + focus: ReturnType; + /** Latest snapshot pushed to the panel. */ + snapshot: () => Record | undefined; +} + +function makeStub(): Stub { + const events = new Map(); + const commands = new Map(); + const panelPost = vi.fn(() => Promise.resolve(undefined)); + const togglePanel = vi.fn(() => Promise.resolve(undefined)); + const closePanel = vi.fn(() => Promise.resolve(undefined)); + const focus = vi.fn(() => Promise.resolve(undefined)); + + return { + sdk: { + events: { + on: (topic: string, handler: EventHandler) => { + const list = events.get(topic) ?? []; + list.push(handler); + events.set(topic, list); + }, + subscribe: () => Promise.resolve(undefined), + }, + commands: { + register: (id: string, handler: CommandHandler) => commands.set(id, handler), + }, + ui: { panelPost, togglePanel, closePanel }, + sessions: { list: () => Promise.resolve([]), focus }, + }, + emit: (topic, payload) => (events.get(topic) ?? []).forEach((h) => h(payload, undefined)), + run: (commandId, args) => commands.get(commandId)?.(args), + panelPost, + togglePanel, + closePanel, + focus, + snapshot: () => { + const call = panelPost.mock.calls[panelPost.mock.calls.length - 1] as + | [string, Record] + | undefined; + return call?.[1]; + }, + }; +} + +describe('agent-flow plugin main.js', () => { + let plugin: { activate: (sdk: unknown) => void; deactivate: () => void }; + let stub: Stub; + + beforeEach(() => { + const require = createRequire(import.meta.url); + // Fresh module state per test: the file keeps its model in module scope. + delete require.cache[require.resolve(MAIN_JS)]; + plugin = require(MAIN_JS); + stub = makeStub(); + plugin.activate(stub.sdk); + }); + + afterEach(() => { + plugin.deactivate(); + }); + + it('toggles its own panel from the overlay command', () => { + stub.run('overlay'); + expect(stub.togglePanel).toHaveBeenCalledWith('flow'); + }); + + it('focuses a session when the panel posts a jump message', () => { + stub.run('jump', { sessionId: 's1' }); + expect(stub.focus).toHaveBeenCalledWith('s1', undefined); + + stub.run('jump', { sessionId: 's2', tabId: 't9' }); + expect(stub.focus).toHaveBeenLastCalledWith('s2', 't9'); + }); + + it('ignores a malformed jump message instead of calling the host', () => { + stub.run('jump', undefined); + stub.run('jump', {}); + stub.run('jump', { sessionId: '' }); + stub.run('jump', { sessionId: 42 }); + expect(stub.focus).not.toHaveBeenCalled(); + }); + + it('drops a non-string tabId rather than forwarding it', () => { + stub.run('jump', { sessionId: 's1', tabId: 7 }); + expect(stub.focus).toHaveBeenCalledWith('s1', undefined); + }); + + it('dismisses the overlay after a successful jump', async () => { + stub.run('jump', { sessionId: 's1' }); + // closePanel is chained off the focus promise, so let the microtask settle. + await Promise.resolve(); + await Promise.resolve(); + expect(stub.focus).toHaveBeenCalledWith('s1', undefined); + expect(stub.closePanel).toHaveBeenCalledWith('flow'); + }); + + it('leaves the overlay up when the jump is rejected', async () => { + stub.focus.mockResolvedValueOnce(false); + stub.run('jump', { sessionId: 'nope' }); + await Promise.resolve(); + await Promise.resolve(); + expect(stub.focus).toHaveBeenCalledWith('nope', undefined); + expect(stub.closePanel).not.toHaveBeenCalled(); + }); + + it('carries the activated session id in snapshots', () => { + stub.emit('session.created', { sessionId: 's1', title: 'One', agentId: 'a1' }); + stub.emit('session.activated', { sessionId: 's1' }); + stub.run('sync'); + expect(stub.snapshot()?.focusedSessionId).toBe('s1'); + + stub.emit('session.activated', { sessionId: 's2' }); + stub.run('sync'); + expect(stub.snapshot()?.focusedSessionId).toBe('s2'); + }); + + it('clears the highlight when the focused session is removed', () => { + stub.emit('session.activated', { sessionId: 's1' }); + stub.emit('session.removed', { sessionId: 's1' }); + stub.run('sync'); + expect(stub.snapshot()?.focusedSessionId).toBe(''); + }); + + it('keeps the highlight when a different session is removed', () => { + stub.emit('session.activated', { sessionId: 's1' }); + stub.emit('session.removed', { sessionId: 's2' }); + stub.run('sync'); + expect(stub.snapshot()?.focusedSessionId).toBe('s1'); + }); + + it('ignores a malformed session.activated payload', () => { + stub.emit('session.activated', { sessionId: 's1' }); + stub.emit('session.activated', { sessionId: 42 }); + stub.emit('session.activated', null); + stub.run('sync'); + expect(stub.snapshot()?.focusedSessionId).toBe('s1'); + }); +}); diff --git a/src/__tests__/plugins/agent-flow-panel.test.ts b/src/__tests__/plugins/agent-flow-panel.test.ts new file mode 100644 index 0000000000..c9a985f5a3 --- /dev/null +++ b/src/__tests__/plugins/agent-flow-panel.test.ts @@ -0,0 +1,275 @@ +/** + * Agent Flow overlay panel (`examples/plugins/agent-flow/panel.html`). + * + * The panel is a standalone document rendered in a locked-down guest, + * so there is nothing to import: the test loads the real file, mounts its body + * markup into jsdom and evaluates its inline script, then drives it exactly as + * the host does - inbound `maestro:panelData` messages in, outbound + * `maestro:invokeCommand` postMessages out. + * + * Covers the Phase 1 shared-canvas overlay: one node per agent, satellite tool + * cards, click-to-jump on a node and on a FINISHED card only, the focused-agent + * highlight from `snapshot.focusedSessionId`, and the metadata-only rule (tool + * name + phase + duration, nothing else). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PANEL_HTML = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../examples/plugins/agent-flow/panel.html' +); + +interface OutboundMessage { + type: string; + commandId: string; + args?: { sessionId?: string }; +} + +let posted: OutboundMessage[] = []; + +/** Mount the panel document and run its script, as the guest would. */ +function mountPanel(): void { + const html = fs.readFileSync(PANEL_HTML, 'utf8'); + const body = /([\s\S]*?)