From 73d210a8f090dd759a629e2d7ed41ff021f9ac55 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 22:30:46 +0200 Subject: [PATCH 01/24] MAESTRO: add tool.executed plugin event topic and emit site Agent Flow plugin, phase 1. Adds a metadata-only `tool.executed` topic to the host -> plugin event catalog and emits it from the process-listener that already forwards `process:tool-execution` to the renderer. The payload carries name and timing only: sessionId, toolName, timestamp, plus optional toolCallId and a best-effort `phase` lifecycle string lifted defensively out of the provider `state` blob. The `state` object itself (tool arguments and results) is never forwarded, per the metadata-only contract in src/shared/plugins/events.ts. The topic and payload are mirrored into the vendored plugin SDK so the drift guard in packages/plugin-sdk stays green; the HOST_API_VERSION bump belongs to a later phase. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugin-sdk/src/index.ts | 14 ++++ .../main/plugins/plugin-event-bus.test.ts | 30 +++++++++ .../__tests__/forwarding-listeners.test.ts | 64 +++++++++++++++++++ .../process-listeners/forwarding-listeners.ts | 38 ++++++++++- src/shared/plugins/events.ts | 14 ++++ 5 files changed, 158 insertions(+), 2 deletions(-) diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 7fe9cb6313..e53b4c468a 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1052,6 +1052,7 @@ export const PLUGIN_EVENT_TOPICS = [ 'cue.runFinished', // a Cue automation run reached a terminal state (status only) '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) ] as const; export type PluginEventTopic = (typeof PLUGIN_EVENT_TOPICS)[number]; @@ -1136,6 +1137,19 @@ export interface PluginEventPayloads { pipelineName?: string; lineageDepth?: number; }; + /** A tool call transitioned. Name + timing ONLY: the tool's `state` object, + * arguments and results are content-bearing and must never appear here. */ + 'tool.executed': { + sessionId: string; + tabId?: string; + toolName: string; + toolCallId?: string; + /** Best-effort lifecycle string (e.g. running / completed / failed) when + * the provider reports one; omitted otherwise. */ + phase?: string; + timestamp: number; + durationMs?: number; + }; } /** A typed host event. */ diff --git a/src/__tests__/main/plugins/plugin-event-bus.test.ts b/src/__tests__/main/plugins/plugin-event-bus.test.ts index 6306ab7664..f2101fa05d 100644 --- a/src/__tests__/main/plugins/plugin-event-bus.test.ts +++ b/src/__tests__/main/plugins/plugin-event-bus.test.ts @@ -111,6 +111,36 @@ describe('PluginEventBusImpl', () => { }); }); + it('delivers tool.executed metadata and strips a smuggled state object', () => { + let received: PluginEvent | undefined; + const push = vi.fn((_id: string, e: PluginEvent) => { + received = e; + return true; + }); + const bus = new PluginEventBusImpl({ isPermitted: () => true, push }); + bus.subscribe('a', ['tool.executed']); + bus.emit( + ev('tool.executed', { + sessionId: 's1', + toolName: 'Read', + toolCallId: 'call-1', + phase: 'completed', + timestamp: 1700000000000, + // A buggy emit site smuggling the tool's state blob: must not survive. + state: { input: { path: '/etc/passwd' }, output: 'file contents' }, + }) + ); + expect(push).toHaveBeenCalledTimes(1); + expect(received?.payload).toEqual({ + sessionId: 's1', + toolName: 'Read', + toolCallId: 'call-1', + phase: 'completed', + timestamp: 1700000000000, + }); + expect(received?.payload).not.toHaveProperty('state'); + }); + it('drops the entire payload when it exceeds the serialized size cap', () => { let received: PluginEvent | undefined; const push = vi.fn((_id: string, e: PluginEvent) => { diff --git a/src/main/process-listeners/__tests__/forwarding-listeners.test.ts b/src/main/process-listeners/__tests__/forwarding-listeners.test.ts index 098e2b53fa..2cdb59d7be 100644 --- a/src/main/process-listeners/__tests__/forwarding-listeners.test.ts +++ b/src/main/process-listeners/__tests__/forwarding-listeners.test.ts @@ -175,6 +175,70 @@ describe('Forwarding Listeners', () => { ); }); + it('should emit a metadata-only tool.executed plugin event alongside the renderer forward', () => { + const emitPluginEvent = vi.fn(); + setupForwardingListeners(mockProcessManager, { ...mockDeps, emitPluginEvent }); + + const handler = eventHandlers.get('tool-execution'); + const testSessionId = 'test-session-123'; + const toolEvent = { + toolName: 'read_file', + toolCallId: 'call-7', + timestamp: 1700000000000, + state: { status: 'completed', input: { path: '/secret' }, output: 'file contents' }, + }; + + handler?.(testSessionId, toolEvent); + + // (a) renderer forward is unchanged - still the full tool event. + expect(mockSafeSend).toHaveBeenCalledWith('process:tool-execution', testSessionId, toolEvent); + + // (b) plugin event carries name + timing only, never the state blob. + expect(emitPluginEvent).toHaveBeenCalledTimes(1); + const event = emitPluginEvent.mock.calls[0][0]; + expect(event.topic).toBe('tool.executed'); + expect(event.payload).toEqual({ + sessionId: testSessionId, + toolName: 'read_file', + toolCallId: 'call-7', + timestamp: 1700000000000, + phase: 'completed', + }); + expect(event.payload).not.toHaveProperty('state'); + expect(JSON.stringify(event.payload)).not.toContain('/secret'); + }); + + it('should omit phase when the tool state carries no status string', () => { + const emitPluginEvent = vi.fn(); + setupForwardingListeners(mockProcessManager, { ...mockDeps, emitPluginEvent }); + + eventHandlers.get('tool-execution')?.('s1', { + toolName: 'bash', + timestamp: 5, + state: 'raw-string-state', + }); + + expect(emitPluginEvent.mock.calls[0][0].payload).toEqual({ + sessionId: 's1', + toolName: 'bash', + timestamp: 5, + }); + }); + + it('should not throw when no plugin event emitter is injected', () => { + setupForwardingListeners(mockProcessManager, mockDeps); + + const handler = eventHandlers.get('tool-execution'); + expect(() => + handler?.('s1', { toolName: 'read_file', timestamp: 1, state: { status: 'running' } }) + ).not.toThrow(); + expect(mockSafeSend).toHaveBeenCalledWith( + 'process:tool-execution', + 's1', + expect.objectContaining({ toolName: 'read_file' }) + ); + }); + it('should forward stderr events to renderer', () => { setupForwardingListeners(mockProcessManager, mockDeps); diff --git a/src/main/process-listeners/forwarding-listeners.ts b/src/main/process-listeners/forwarding-listeners.ts index 5c9c4d9c9a..e5f9d3a8f5 100644 --- a/src/main/process-listeners/forwarding-listeners.ts +++ b/src/main/process-listeners/forwarding-listeners.ts @@ -11,6 +11,19 @@ const THINKING_CHUNK_FLUSH_INTERVAL_MS = 50; /** Hard cap on buffered thinking chunk size - flush early if exceeded. */ const THINKING_CHUNK_FLUSH_SIZE = 8 * 1024; +/** + * Best-effort lifecycle string for a tool execution (running / completed / + * failed / ...), lifted defensively out of the provider-specific `state` blob. + * Returns undefined unless `state` is a plain object carrying a string status, + * so nothing content-bearing can ever reach the plugin event payload. + */ +function extractToolPhase(state: unknown): string | undefined { + if (!state || typeof state !== 'object' || Array.isArray(state)) return undefined; + const record = state as Record; + const candidate = record.status ?? record.phase; + return typeof candidate === 'string' && candidate ? candidate : undefined; +} + /** * Sets up simple forwarding listeners that pass events directly to renderer. * These are lightweight handlers that don't require any processing logic. @@ -18,9 +31,12 @@ const THINKING_CHUNK_FLUSH_SIZE = 8 * 1024; */ export function setupForwardingListeners( processManager: ProcessManager, - deps: Pick + deps: Pick< + ProcessListenerDependencies, + 'safeSend' | 'getWebServer' | 'patterns' | 'emitPluginEvent' + > ): void { - const { safeSend, getWebServer, patterns } = deps; + const { safeSend, getWebServer, patterns, emitPluginEvent } = deps; const { REGEX_AI_SUFFIX, REGEX_AI_TAB_ID } = patterns; // Handle slash commands from Claude Code init message @@ -86,6 +102,24 @@ export function setupForwardingListeners( processManager.on('tool-execution', (sessionId: string, toolEvent: ToolExecution) => { safeSend('process:tool-execution', sessionId, toolEvent); + // Metadata-only mirror for subscribed plugins: tool NAME and timing only. + // `toolEvent.state` carries arguments/results and must never be forwarded; + // only a best-effort lifecycle string is lifted out of it. + if (emitPluginEvent) { + const phase = extractToolPhase(toolEvent.state); + emitPluginEvent({ + topic: 'tool.executed', + at: new Date().toISOString(), + payload: { + sessionId, + toolName: toolEvent.toolName, + timestamp: toolEvent.timestamp, + ...(toolEvent.toolCallId ? { toolCallId: toolEvent.toolCallId } : {}), + ...(phase ? { phase } : {}), + }, + }); + } + // Broadcast to web clients for UX parity with desktop thinking stream const webServer = getWebServer(); if (webServer) { diff --git a/src/shared/plugins/events.ts b/src/shared/plugins/events.ts index 2fa695ddac..9c5e123ce0 100644 --- a/src/shared/plugins/events.ts +++ b/src/shared/plugins/events.ts @@ -24,6 +24,7 @@ export const PLUGIN_EVENT_TOPICS = [ 'cue.runFinished', // a Cue automation run reached a terminal state (status only) '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) ] as const; export type PluginEventTopic = (typeof PLUGIN_EVENT_TOPICS)[number]; @@ -111,6 +112,19 @@ export interface PluginEventPayloads { pipelineName?: string; lineageDepth?: number; }; + /** A tool call transitioned. Name + timing ONLY: the tool's `state` object, + * arguments and results are content-bearing and must never appear here. */ + 'tool.executed': { + sessionId: string; + tabId?: string; + toolName: string; + toolCallId?: string; + /** Best-effort lifecycle string (e.g. running / completed / failed) when + * the provider reports one; omitted otherwise. */ + phase?: string; + timestamp: number; + durationMs?: number; + }; } /** A typed host event. */ From 7c0cc1b2e17f2f0238ef688ad2079bae6b050bfc Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 22:43:58 +0200 Subject: [PATCH 02/24] MAESTRO: add ui.panelPost host-to-panel data channel (Agent Flow Phase 2) Adds the missing host-to-panel push path: a plugin sandbox can now push JSON-only, size-capped data into its OWN declared panels via maestro.ui.panelPost(panelId, data). Flow: sandbox -> main (validate + cap) -> renderer broadcast -> panel frame -> webview guest -> page. - rpc-protocol: ui.panelPost gated behind ui:panel capability - contributions: MAX_PANEL_POST_BYTES = 64KB per-message cap - panel-host: PANEL_DATA_CHANNEL constant - sandbox SDK wrapper + host handler (own-panels-only, fail-closed sink) - main wiring via safeSend broadcast; preload + PluginPanelFrame delivery - guest preload relays only maestro:panelData into the page, no reply path - tests: rpc table, full handler suite, deps-wiring guard Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/plugin-host-deps-wiring.test.ts | 9 ++ .../main/plugins/plugin-host-handlers.test.ts | 96 +++++++++++++++++++ .../shared/plugins/rpc-protocol.test.ts | 2 + src/main/index.ts | 11 +++ src/main/plugins/plugin-host-handlers.ts | 42 ++++++++ src/main/plugins/plugin-sandbox-entry.ts | 1 + src/main/preload/plugin-panel.ts | 16 ++++ src/main/preload/plugins.ts | 19 ++++ .../components/plugins/PluginPanelFrame.tsx | 21 ++++ src/renderer/global.d.ts | 3 + src/shared/plugins/contributions.ts | 8 ++ src/shared/plugins/panel-host.ts | 11 +++ src/shared/plugins/rpc-protocol.ts | 1 + 13 files changed, 240 insertions(+) diff --git a/src/__tests__/main/plugins/plugin-host-deps-wiring.test.ts b/src/__tests__/main/plugins/plugin-host-deps-wiring.test.ts index 83180090bc..09aaf3721d 100644 --- a/src/__tests__/main/plugins/plugin-host-deps-wiring.test.ts +++ b/src/__tests__/main/plugins/plugin-host-deps-wiring.test.ts @@ -113,6 +113,15 @@ describe('production host-handler deps wiring (FC2 - wired and gated)', () => { expect(keys).toContain('forwardHostView'); }); + it('wires the panel-post sink together with the own-panel lookup', () => { + // The sink without `getPanel` would leave the own-panels-only check with no + // resolver, so every post would deny (or, if the check were dropped, let a + // plugin push into another plugin's panel). Pin them as a pair. + if (keys.includes('panelPost')) { + expect(keys).toContain('getPanel'); + } + }); + it('production blesses NO spawn binaries - the only register() site is the env-gated DEMO blessing', () => { // Exactly one register call may exist, and it must sit behind both the // DEMO_MODE flag and the harness env var. Adding a second call site (or diff --git a/src/__tests__/main/plugins/plugin-host-handlers.test.ts b/src/__tests__/main/plugins/plugin-host-handlers.test.ts index 5fe43aea01..f05b5efd3b 100644 --- a/src/__tests__/main/plugins/plugin-host-handlers.test.ts +++ b/src/__tests__/main/plugins/plugin-host-handlers.test.ts @@ -301,6 +301,102 @@ describe('ui.hostViewUpdate / ui.hostViewRemove', () => { }); }); +describe('ui.panelPost', () => { + // The plugin 'p' declares one panel with local id 'flow'. + const getPanel = (pluginId: string, localId: string) => + pluginId === 'p' && localId === 'flow' + ? { + id: 'p/flow', + localId: 'flow', + pluginId: 'p', + title: 'Agent Flow', + entry: 'panel.html', + placement: 'modal' as const, + } + : null; + + it('is not registered at all when the sink dependency is absent (fail closed)', () => { + // makeDeps() supplies no panelPost, mirroring how agents.dispatch is + // unregistered without deps.dispatch. + const h = buildHostCallHandlers(makeDeps()); + expect(h['ui.panelPost']).toBeUndefined(); + }); + + it('denies when ui:panel is not granted', async () => { + const panelPost = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelPost, getPanel, broker: brokerFor(() => []) }) + ); + await expect(h['ui.panelPost']!('p', { panelId: 'flow', data: { n: 1 } })).rejects.toThrow( + /permission denied/ + ); + expect(panelPost).not.toHaveBeenCalled(); + }); + + it('posts to an own declared panel and forwards the namespaced id', async () => { + const panelPost = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelPost, getPanel, broker: brokerFor(() => [grant('ui:panel')]) }) + ); + await expect( + h['ui.panelPost']!('p', { panelId: 'flow', data: { n: 1 } }) + ).resolves.toEqual({ ok: true }); + expect(panelPost).toHaveBeenCalledWith('p', 'p/flow', { n: 1 }); + }); + + it('denies posting to an undeclared or another plugin\'s panel id', async () => { + const panelPost = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelPost, getPanel, broker: brokerFor(() => [grant('ui:panel')]) }) + ); + // Undeclared local id. + await expect( + h['ui.panelPost']!('p', { panelId: 'nope', data: {} }) + ).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.panelPost']!('p', { panelId: 'other/flow', data: {} }) + ).rejects.toThrow(/not declared/); + expect(panelPost).not.toHaveBeenCalled(); + }); + + it('denies non-JSON-serializable data', async () => { + const panelPost = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelPost, getPanel, broker: brokerFor(() => [grant('ui:panel')]) }) + ); + const circular: Record = {}; + circular.self = circular; + await expect( + h['ui.panelPost']!('p', { panelId: 'flow', data: circular }) + ).rejects.toThrow(/JSON-serializable/); + expect(panelPost).not.toHaveBeenCalled(); + }); + + it('denies data over MAX_PANEL_POST_BYTES', async () => { + const panelPost = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelPost, getPanel, broker: brokerFor(() => [grant('ui:panel')]) }) + ); + await expect( + h['ui.panelPost']!('p', { panelId: 'flow', data: 'x'.repeat(64 * 1024 + 1) }) + ).rejects.toThrow(/size limit/); + expect(panelPost).not.toHaveBeenCalled(); + }); + + it('rejects a caller-supplied extra field (closed schema)', async () => { + const panelPost = vi.fn(); + const h = buildHostCallHandlers( + makeDeps({ panelPost, getPanel, broker: brokerFor(() => [grant('ui:panel')]) }) + ); + await expect( + h['ui.panelPost']!('p', { panelId: 'flow', data: {}, extra: 1 }) + ).rejects.toThrow(/closed schema/); + expect(panelPost).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 }); diff --git a/src/__tests__/shared/plugins/rpc-protocol.test.ts b/src/__tests__/shared/plugins/rpc-protocol.test.ts index 1eb9adaf1c..e8c4dd1a94 100644 --- a/src/__tests__/shared/plugins/rpc-protocol.test.ts +++ b/src/__tests__/shared/plugins/rpc-protocol.test.ts @@ -27,6 +27,7 @@ describe('P0 host RPC contract additions', () => { expect(HOST_METHOD_CAPABILITY['background.unregister']).toBe('background:service'); expect(HOST_METHOD_CAPABILITY['ui.hostViewUpdate']).toBe('ui:hostView'); expect(HOST_METHOD_CAPABILITY['ui.hostViewRemove']).toBe('ui:hostView'); + expect(HOST_METHOD_CAPABILITY['ui.panelPost']).toBe('ui:panel'); }); it('includes the P0 methods in the runtime method catalog', () => { @@ -43,6 +44,7 @@ describe('P0 host RPC contract additions', () => { 'background.register', 'ui.hostViewUpdate', 'ui.hostViewRemove', + 'ui.panelPost', ] as const) { expect(HOST_METHODS).toContain(method); } diff --git a/src/main/index.ts b/src/main/index.ts index 235bfb7ca8..5bcf294332 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2287,6 +2287,17 @@ app if (operation === 'remove') return pluginHostViews.remove(pluginId, localId); return blocks === undefined ? false : pluginHostViews.update(pluginId, localId, blocks); }, + // ui.panelPost: resolve the caller's LOCAL panel id against its own + // declarations (a foreign or already-namespaced id never matches), then + // broadcast the validated, size-capped JSON to every renderer. The + // renderer hands it to the matching panel webview; nothing evaluates it. + getPanel: (pluginId, localId) => + pluginManager + ?.getContributions() + .panels.find((p) => p.pluginId === pluginId && p.localId === localId) ?? null, + panelPost: (pluginId, panelId, data) => { + safeSend('plugins:panel-data', { pluginId, panelId, data }); + }, listAgents: () => { const sessions = sessionsStore.get('sessions', []) as Array<{ id?: string; diff --git a/src/main/plugins/plugin-host-handlers.ts b/src/main/plugins/plugin-host-handlers.ts index d0e520f5bb..bbb9242e63 100644 --- a/src/main/plugins/plugin-host-handlers.ts +++ b/src/main/plugins/plugin-host-handlers.ts @@ -37,9 +37,11 @@ import { evaluatePluginDispatch } from '../../shared/plugins/plugin-dispatch-gat import { isHostViewBlocks, MAX_HOST_VIEW_BLOCKS_BYTES, + MAX_PANEL_POST_BYTES, serializedJsonByteLength, type HostViewBlocks, type HostViewContribution, + type PanelContribution, } from '../../shared/plugins/contributions'; import type { HistoryEntry } from '../../shared/types'; @@ -219,6 +221,15 @@ export interface HostHandlerDeps { blocks?: HostViewBlocks ) => boolean; + /** Resolve a caller-owned local panel id against active panel declarations. + * A plugin may only push data into panels IT declared. */ + getPanel?: (pluginId: string, localId: string) => PanelContribution | null; + /** Host-to-panel push sink. Receives the already-namespaced panel id and + * validated, size-capped JSON data; broadcasts it to the renderer(s) so the + * owning panel webview can receive it. Absent means the method is not + * registered at all (fail closed). */ + panelPost?: (pluginId: string, namespacedPanelId: string, data: unknown) => void; + /** Read-only agent listing (no secrets): id/name/cwd/toolType only. */ listAgents: () => Array<{ id: string; name: string; cwd?: string; toolType?: string }>; /** Optional path for per-plugin private SQLite databases. */ @@ -1292,6 +1303,37 @@ export function buildHostCallHandlers(deps: HostHandlerDeps): HostCallHandlers { // directly, bypassing this handler). We deliberately do NOT try to infer // "user-initiated" presence here: it is racy and Relay needs the unattended // grant regardless. + // ui.panelPost: the ONLY host-to-panel push channel. It is data-in, never + // code: the payload is JSON-validated and size-capped here, structured-cloned + // across every hop, and the renderer/guest never evaluate it. A plugin can + // target only panels IT declared, so the channel creates no cross-plugin + // reach and no new egress. Registered only when the sink is wired (fail + // closed, mirroring agents.dispatch). + if (deps.panelPost) { + const panelPost = deps.panelPost; + handlers['ui.panelPost'] = async (pluginId, params) => { + const p = asObject(params); + assertClosedSchema('ui.panelPost', p, { panelId: true, data: true }); + const panelId = p.panelId; + if (typeof panelId !== 'string' || panelId.trim() === '' || panelId !== panelId.trim()) { + throw new Error('panelId is required'); + } + assertBrokerAllowed(deps, pluginId, 'ui.panelPost', p); + // Own-panels-only: `panelId` is the caller's LOCAL id, resolved against + // this plugin's declarations. A namespaced or foreign id never resolves. + if (!deps.getPanel?.(pluginId, panelId)) { + throw new Error(`panel "${panelId}" is not declared by this plugin`); + } + const byteLength = serializedJsonByteLength(p.data); + if (byteLength === null) throw new Error('panel data must be JSON-serializable'); + if (byteLength > MAX_PANEL_POST_BYTES) { + throw new Error(`panel data exceeds the ${MAX_PANEL_POST_BYTES}-byte size limit`); + } + panelPost(pluginId, `${pluginId}/${panelId}`, p.data); + return { ok: true }; + }; + } + if (deps.dispatch) { const dispatch = deps.dispatch; handlers['agents.dispatch'] = async (pluginId, params) => { diff --git a/src/main/plugins/plugin-sandbox-entry.ts b/src/main/plugins/plugin-sandbox-entry.ts index 4e12c11b83..95c2ddad34 100644 --- a/src/main/plugins/plugin-sandbox-entry.ts +++ b/src/main/plugins/plugin-sandbox-entry.ts @@ -294,6 +294,7 @@ const BOOTSTRAP_SOURCE = String.raw`(function bootstrap(bridge) { update: function (id, blocks) { return hostCall('ui.hostViewUpdate', { id: id, blocks: blocks }); }, remove: function (id) { return hostCall('ui.hostViewRemove', { id: id }); } }), + panelPost: function (panelId, data) { return hostCall('ui.panelPost', { panelId: panelId, data: data }); }, grouping: Object.freeze({ publish: function (params) { return hostCall('ui.groupingPublish', params); }, clear: function (id) { return hostCall('ui.groupingClear', { id: id }); } diff --git a/src/main/preload/plugin-panel.ts b/src/main/preload/plugin-panel.ts index 19408fce5b..9348e019b0 100644 --- a/src/main/preload/plugin-panel.ts +++ b/src/main/preload/plugin-panel.ts @@ -39,6 +39,7 @@ interface PanelMessageEvent { declare const window: { addEventListener(type: 'message', listener: (event: PanelMessageEvent) => void): void; + postMessage(message: unknown, targetOrigin: string): void; }; window.addEventListener('message', (event) => { @@ -54,3 +55,18 @@ window.addEventListener('message', (event) => { args: msg.args, }); }); + +/** + * The one channel pushed INTO the page: host-to-panel data (`ui.panelPost`). + * The embedder renderer sends it only after the main process verified the + * posting plugin OWNS this panel and that the payload is JSON and under the + * size cap. Here the relay is deliberately dumb and total: take the structured- + * cloned value and re-post it on the page's own window under one fixed shape. + * Nothing is evaluated, `ipcRenderer` is never exposed, no other channel is + * relayed, and there is no reply path - the page can only read. + * The channel/shape constants are duplicated from + * `src/shared/plugins/panel-host.ts` (PANEL_DATA_CHANNEL) - keep them in sync. + */ +ipcRenderer.on('maestro:panelData', (_event, data: unknown) => { + window.postMessage({ type: 'maestro:panelData', data }, '*'); +}); diff --git a/src/main/preload/plugins.ts b/src/main/preload/plugins.ts index 36173c171c..def3576377 100644 --- a/src/main/preload/plugins.ts +++ b/src/main/preload/plugins.ts @@ -126,6 +126,25 @@ export function createPluginsApi() { }; }, + /** + * Subscribe to host-to-panel data pushes (`ui.panelPost`). The main process + * broadcasts `plugins:panel-data` with the already-namespaced panel id and + * validated, size-capped JSON data; the panel frame forwards it into the + * matching webview guest. Read-only signal - there is no reply channel. + */ + onPanelData: ( + callback: (payload: { pluginId: string; panelId: string; data: unknown }) => void + ): (() => void) => { + const handler = ( + _event: unknown, + payload: { pluginId: string; panelId: string; data: unknown } + ): void => callback(payload); + ipcRenderer.on('plugins:panel-data', handler); + return () => { + ipcRenderer.removeListener('plugins:panel-data', handler); + }; + }, + onGroupingsChanged: (callback: () => void): (() => void) => { const handler = (): void => callback(); ipcRenderer.on('plugins:groupings-changed', handler); diff --git a/src/renderer/components/plugins/PluginPanelFrame.tsx b/src/renderer/components/plugins/PluginPanelFrame.tsx index b5e99f2200..bf9708f7e5 100644 --- a/src/renderer/components/plugins/PluginPanelFrame.tsx +++ b/src/renderer/components/plugins/PluginPanelFrame.tsx @@ -37,6 +37,7 @@ import { pluginPanelPartition, pluginPanelUrl, PANEL_BRIDGE_CHANNEL, + PANEL_DATA_CHANNEL, } from '../../../shared/plugins/panel-host'; import { notifyToast } from '../../stores/notificationStore'; @@ -52,6 +53,8 @@ interface PluginPanelFrameProps { interface PanelWebviewElement extends HTMLElement { addEventListener(type: string, listener: (event: Event) => void): void; removeEventListener(type: string, listener: (event: Event) => void): void; + /** Structured-clone push into the guest (host-to-panel data, one-way). */ + send(channel: string, ...args: unknown[]): void; } /** Shape of the `ipc-message` event the guest preload emits via sendToHost. */ @@ -102,6 +105,24 @@ export function PluginPanelFrame({ theme, panel, frameClassName }: PluginPanelFr }; }, [panel.pluginId, failed]); + // Host-to-panel push (`ui.panelPost`). Main validated ownership, JSON-ness and + // size before broadcasting; here we only route the event to the ONE frame + // whose panel it names and hand the value to the guest as structured-clone + // data. The renderer never inspects or evaluates the payload. + useEffect(() => { + return window.maestro.plugins.onPanelData(({ panelId, data }) => { + if (panelId !== panel.id) return; + const webview = webviewRef.current; + if (!webview) return; + try { + webview.send(PANEL_DATA_CHANNEL, data); + } catch { + // The guest may not be attached yet (or is tearing down); drop the + // message rather than surfacing a plugin-triggered error to the user. + } + }); + }, [panel.id, failed]); + return (
Promise; onChanged: (callback: () => void) => () => void; onGroupingsChanged: (callback: () => void) => () => void; + onPanelData: ( + callback: (payload: { pluginId: string; panelId: string; data: unknown }) => void + ) => () => void; onRunUiCommand: ( callback: (commandId: string, args: unknown) => boolean | Promise ) => () => void; diff --git a/src/shared/plugins/contributions.ts b/src/shared/plugins/contributions.ts index 6df0c76e8c..8cff200b34 100644 --- a/src/shared/plugins/contributions.ts +++ b/src/shared/plugins/contributions.ts @@ -22,6 +22,14 @@ import type { PluginCapability } from './permissions'; */ export const MAX_HOST_VIEW_BLOCKS_BYTES = 1_000_000; +/** + * Maximum UTF-8 size of ONE host-to-panel push (`ui.panelPost`). Panel data is + * a live update stream, not a bulk transfer, so the per-message cap is small + * and deliberate: it bounds what a plugin can force through the sandbox RPC, + * the main-to-renderer IPC, and the webview bridge in a single call. + */ +export const MAX_PANEL_POST_BYTES = 64 * 1024; + /** * Size of JSON data as it crosses a UTF-8 message boundary. Returns null when * the value is not serializable, rather than throwing from an input validator. diff --git a/src/shared/plugins/panel-host.ts b/src/shared/plugins/panel-host.ts index 6d9d529d68..9c151c4e31 100644 --- a/src/shared/plugins/panel-host.ts +++ b/src/shared/plugins/panel-host.ts @@ -57,6 +57,17 @@ export const PANEL_CSP_CONTENT = */ export const PANEL_BRIDGE_CHANNEL = 'maestro:invokeCommand'; +/** + * The one channel the host pushes data INTO a panel guest on (`ui.panelPost`). + * The embedder renderer calls `webview.send(PANEL_DATA_CHANNEL, data)`; the + * guest preload relays exactly that one channel into the page as + * `window.postMessage({ type: 'maestro:panelData', data }, '*')`, which panel + * HTML reads with a plain `message` listener. Data-only and one-way: there is + * no reply channel, the payload is structured-cloned (never evaluated), and it + * is size-capped in the main process (MAX_PANEL_POST_BYTES). + */ +export const PANEL_DATA_CHANNEL = 'maestro:panelData'; + /** Session partition for a plugin's panels: `plugin:`. */ export function pluginPanelPartition(pluginId: string): string { return `${PLUGIN_PANEL_PARTITION_PREFIX}${pluginId}`; diff --git a/src/shared/plugins/rpc-protocol.ts b/src/shared/plugins/rpc-protocol.ts index 0a542c481f..b665b8c6fd 100644 --- a/src/shared/plugins/rpc-protocol.ts +++ b/src/shared/plugins/rpc-protocol.ts @@ -54,6 +54,7 @@ export const HOST_API = { 'ui.runCommand': { capability: 'ui:command' }, 'ui.hostViewUpdate': { capability: 'ui:hostView' }, 'ui.hostViewRemove': { capability: 'ui:hostView' }, + 'ui.panelPost': { capability: 'ui:panel' }, 'tabs.list': { capability: 'tabs:manage' }, 'tabs.create': { capability: 'tabs:manage' }, 'tabs.focus': { capability: 'tabs:manage' }, From 61de4daa65de90d3e4a7ce534031575b0f086a2c Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 22:57:23 +0200 Subject: [PATCH 03/24] MAESTRO: bump host API to 1.13.0, sync plugin SDK + docs (Agent Flow Phase 3) Officialize the Phase 1-2 additive host-API surfaces (tool.executed event topic, ui.panelPost host-to-panel push): - HOST_API_VERSION 1.12.0 -> 1.13.0 (src/shared/plugins/host-api.ts). - Vendor ui.panelPost + MAX_PANEL_POST_BYTES into @maestro/plugin-sdk, bump its HOST_API_VERSION + version-history comment, add panelPost to MaestroUiApi, bump package.json 0.7.0 -> 0.8.0, refresh drift-guard pin + panel-cap check. - CLAUDE-PLUGINS.md: version bump, semver-history entry, panelPost handler bullet + tool.executed events note. - PLUGIN-DEVELOPMENT.md: tool.executed topic row, 'Pushing live data to your panel' subsection, panelPost host-method row, minHostApi 1.13.0 notes. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE-PLUGINS.md | 18 ++++++------ docs/agent-guides/PLUGIN-DEVELOPMENT.md | 24 ++++++++++++++++ packages/plugin-sdk/package.json | 4 +-- .../plugin-sdk/src/__tests__/drift.test.ts | 10 +++++-- packages/plugin-sdk/src/index.ts | 28 +++++++++++++++---- src/shared/plugins/host-api.ts | 15 ++++++---- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/CLAUDE-PLUGINS.md b/CLAUDE-PLUGINS.md index c0162b8305..4140ffd75e 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.12.0'` (`src/shared/plugins/host-api.ts`) is the single source of truth for the host surface version. +- `HOST_API_VERSION = '1.13.0'` (`src/shared/plugins/host-api.ts`) is the single source of truth for the host surface version. ## File map @@ -104,9 +104,10 @@ HostResponse { id, ok, result?, error? } <---postMessage--- - `sessions.list` / `sessions.get`: projected through `toSessionMetadata` - metadata only, never transcript/prompt text. - `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. + - `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). - `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. **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. @@ -183,12 +184,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.12.0`; it added the `net:connect` capability and the -`net.connect` / `net.send` / `net.close` methods. Earlier: `1.11.0` added -`groupings` + `ui:grouping`; `1.10.0` added `iconPacks`; `1.9.0` added -`hostViews`, `ui:hostView`, and the `ui.hostViewUpdate` / `ui.hostViewRemove` -methods. Plugins declare the `maestro.minHostApi` matching the lowest version -whose surface they use. +The current host is `1.13.0`; it added the `tool.executed` event topic and the +`ui.panelPost` host-to-panel push method. Earlier: `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 `iconPacks`; +`1.9.0` added `hostViews`, `ui:hostView`, and the `ui.hostViewUpdate` / +`ui.hostViewRemove` methods. Plugins declare the `maestro.minHostApi` matching +the lowest version whose surface they use. ## Key invariants and gotchas (read before editing) diff --git a/docs/agent-guides/PLUGIN-DEVELOPMENT.md b/docs/agent-guides/PLUGIN-DEVELOPMENT.md index 295feda433..42e2f8296e 100644 --- a/docs/agent-guides/PLUGIN-DEVELOPMENT.md +++ b/docs/agent-guides/PLUGIN-DEVELOPMENT.md @@ -449,6 +449,7 @@ Every method below is broker-gated and needs the matching capability granted. Si | `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` | @@ -556,6 +557,26 @@ The host's guest preload accepts the message only from the panel document's own Flow: panel button posts the command -> host forwards over the broker -> the plugin's `say-hello` handler runs in the sandbox -> it calls `maestro.notifications.toast(...)` (a brokered effect). +### Pushing live data to your panel + +The `invokeCommand` bridge above is panel -> host. To push data the OTHER way (host -> panel) - e.g. stream a live snapshot into an open panel as events arrive - use `maestro.ui.panelPost(panelId, data)` from the sandbox. It requires `ui:panel`, targets ONLY one of your own declared panels (`panelId` is the LOCAL id from your `panels` contribution), and the payload must be JSON-serializable and under 64 KB. It is a one-way push: there is no reply channel back to the sandbox. Declare `minHostApi: '1.13.0'`. + +The data is delivered to the panel page as a `maestro:panelData` window message: + +```js +// Sandbox side (in activate / a command handler): +await maestro.ui.panelPost('my-panel', { nodes }); +``` + +```html + + +``` + --- ## 8. Events @@ -570,6 +591,9 @@ A plugin with `events:subscribe` receives a FIXED catalog of host topics (`src/s | `agent.awaiting` | `{ agentId, tabId?, kind?, risk? }` | | `agent.statusChanged` | `{ agentId, tabId?, status }` | | `cue.fired` | `{ cueType, projectPath? }` | +| `tool.executed` | `{ sessionId, tabId?, toolName, toolCallId?, phase?, timestamp, durationMs? }` | + +`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.13.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/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 5c70adca40..8bb5c6b3e8 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,8 +1,8 @@ { "name": "@maestro/plugin-sdk", - "version": "0.7.0", + "version": "0.8.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.12.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.13.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 a94783a121..0c0be2e922 100644 --- a/packages/plugin-sdk/src/__tests__/drift.test.ts +++ b/packages/plugin-sdk/src/__tests__/drift.test.ts @@ -14,6 +14,7 @@ import { UI_SURFACES, HOST_VIEW_SURFACES, MAX_HOST_VIEW_BLOCKS_BYTES, + MAX_PANEL_POST_BYTES, serializedJsonByteLength, capabilityRisk, isHostViewBlocks, @@ -46,6 +47,7 @@ import { HOST_API_VERSION as SRC_HOST_API_VERSION } from '../../../../src/shared import { HOST_VIEW_SURFACES as SRC_HOST_VIEW_SURFACES, MAX_HOST_VIEW_BLOCKS_BYTES as SRC_MAX_HOST_VIEW_BLOCKS_BYTES, + MAX_PANEL_POST_BYTES as SRC_MAX_PANEL_POST_BYTES, serializedJsonByteLength as srcSerializedJsonByteLength, UI_SURFACES as SRC_UI_SURFACES, isHostViewBlocks as srcIsHostViewBlocks, @@ -86,9 +88,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.12.0', () => { + it('HOST_API_VERSION matches the source and is pinned to 1.13.0', () => { expect(HOST_API_VERSION).toBe(SRC_HOST_API_VERSION); - expect(HOST_API_VERSION).toBe('1.12.0'); + expect(HOST_API_VERSION).toBe('1.13.0'); }); it('capability risk and descriptions match the source', () => { @@ -107,6 +109,10 @@ describe('@maestro/plugin-sdk vendored-contract drift guard', () => { expect(MAX_HOST_VIEW_BLOCKS_BYTES).toBe(SRC_MAX_HOST_VIEW_BLOCKS_BYTES); }); + it('panel-post byte cap matches the source', () => { + expect(MAX_PANEL_POST_BYTES).toBe(SRC_MAX_PANEL_POST_BYTES); + }); + it('serialized JSON byte measurement matches the source contract', () => { for (const value of [[], { blocks: [{ kind: 'text', content: '🪄' }] }]) { expect(serializedJsonByteLength(value)).toBe(srcSerializedJsonByteLength(value)); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index e53b4c468a..6873bfe017 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -27,6 +27,14 @@ function isNonEmptyString(value: unknown): value is string { */ export const MAX_HOST_VIEW_BLOCKS_BYTES = 1_000_000; +/** + * Maximum UTF-8 size of ONE host-to-panel push (`ui.panelPost`). Panel data is + * a live update stream, not a bulk transfer, so the per-message cap is small + * and deliberate: it bounds what a plugin can force through the sandbox RPC, + * the main-to-renderer IPC, and the webview bridge in a single call. + */ +export const MAX_PANEL_POST_BYTES = 64 * 1024; + /** Size of JSON data as it crosses a UTF-8 message boundary. */ export function serializedJsonByteLength(value: unknown): number | null { let serialized: string | undefined; @@ -402,11 +410,14 @@ 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.12.0 for the - * backward-compatible additive `net:connect` capability plus its `net.connect` - * / `net.send` / `net.close` host methods (hold an outbound persistent - * websocket to a host scope, e.g. a Discord/Slack gateway; egress-classified). - * 1.11.0 added virtual `groupings` contributions and the presentation-only + * The host API version this Maestro build implements. Bumped to 1.13.0 for 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 + * cap). 1.12.0 added the backward-compatible additive `net:connect` capability + * plus its `net.connect` / `net.send` / `net.close` host methods (hold an + * outbound persistent websocket to a host scope, e.g. a Discord/Slack gateway; + * egress-classified). 1.11.0 added virtual `groupings` contributions and the presentation-only * `ui:grouping` publish/clear methods; 1.10.0 added the backward-compatible, * data-only `iconPacks` contribution; 1.9.0 added host-rendered `hostViews`, * their `ui:hostView` capability, and the `ui.hostViewUpdate` / @@ -420,7 +431,7 @@ export function describeCapability(capability: PluginCapability): string { * `ui:contribute` / `ui:panel` / `ui:render-unsafe` UI capabilities; 1.3.0 * added `tools` + `keybindings`; 1.2.0 added `transcripts:read`. */ -export const HOST_API_VERSION = '1.12.0'; +export const HOST_API_VERSION = '1.13.0'; /** Result of checking a plugin's declared host-API requirement. */ export interface HostApiCompatibility { @@ -1196,6 +1207,7 @@ export const HOST_API = { 'ui.runCommand': { capability: 'ui:command' }, 'ui.hostViewUpdate': { capability: 'ui:hostView' }, 'ui.hostViewRemove': { capability: 'ui:hostView' }, + 'ui.panelPost': { capability: 'ui:panel' }, 'tabs.list': { capability: 'tabs:manage' }, 'tabs.create': { capability: 'tabs:manage' }, 'tabs.focus': { capability: 'tabs:manage' }, @@ -1428,6 +1440,10 @@ export interface MaestroUiApi { runCommand(commandId: string, args?: unknown): Promise; readonly hostView: MaestroHostViewApi; readonly grouping: MaestroGroupingApi; + /** Push a live JSON snapshot to one of this plugin's own declared panels + * (`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; } /** Manage Maestro tabs (`tabs:manage`). */ diff --git a/src/shared/plugins/host-api.ts b/src/shared/plugins/host-api.ts index 842de2a80e..e818a1aa67 100644 --- a/src/shared/plugins/host-api.ts +++ b/src/shared/plugins/host-api.ts @@ -21,11 +21,14 @@ import semver from 'semver'; /** - * The host API version this Maestro build implements. Bumped to 1.12.0 for the - * backward-compatible additive `net:connect` capability plus its `net.connect` - * / `net.send` / `net.close` host methods (hold an outbound persistent - * websocket to a host scope, e.g. a Discord/Slack gateway; egress-classified). - * 1.11.0 added virtual `groupings` contributions and the presentation-only + * The host API version this Maestro build implements. Bumped to 1.13.0 for 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 + * cap). 1.12.0 added the backward-compatible additive `net:connect` capability + * plus its `net.connect` / `net.send` / `net.close` host methods (hold an + * outbound persistent websocket to a host scope, e.g. a Discord/Slack gateway; + * egress-classified). 1.11.0 added virtual `groupings` contributions and the presentation-only * `ui:grouping` publish/clear methods; 1.10.0 added the backward-compatible, * data-only `iconPacks` contribution; 1.9.0 added host-rendered `hostViews`, * their `ui:hostView` capability, and the `ui.hostViewUpdate` / @@ -39,7 +42,7 @@ import semver from 'semver'; * `ui:contribute` / `ui:panel` / `ui:render-unsafe` UI capabilities; 1.3.0 * added `tools` + `keybindings`; 1.2.0 added `transcripts:read`. */ -export const HOST_API_VERSION = '1.12.0'; +export const HOST_API_VERSION = '1.13.0'; /** Result of checking a plugin's declared host-API requirement. */ export interface HostApiCompatibility { From 0717d679dcce31703cab036441020b5b7c87989d Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 23:04:31 +0200 Subject: [PATCH 04/24] MAESTRO: scaffold Agent Flow tier-2 plugin (Agent Flow Phase 4) Adds examples/plugins/agent-flow/: a tier-2 plugin whose sandbox entry subscribes to the metadata-only host event stream, maintains a per-session execution-graph model (lane per session, tool-call nodes merged by toolCallId, 300-node cap), and pushes coalesced snapshots to its panel via maestro.ui.panelPost (250ms trailing-edge, 60KB size guard). panel.html is a Phase-5 placeholder. Manifest validates with zero errors against host API 1.13.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/plugins/agent-flow/README.md | 52 +++ examples/plugins/agent-flow/main.js | 423 ++++++++++++++++++++++++ examples/plugins/agent-flow/panel.html | 1 + examples/plugins/agent-flow/plugin.json | 29 ++ 4 files changed, 505 insertions(+) create mode 100644 examples/plugins/agent-flow/README.md create mode 100644 examples/plugins/agent-flow/main.js create mode 100644 examples/plugins/agent-flow/panel.html create mode 100644 examples/plugins/agent-flow/plugin.json diff --git a/examples/plugins/agent-flow/README.md b/examples/plugins/agent-flow/README.md new file mode 100644 index 0000000000..f185caa685 --- /dev/null +++ b/examples/plugins/agent-flow/README.md @@ -0,0 +1,52 @@ +# 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. + +Everything the plugin sees is metadata only: tool names, timing, and lifecycle +phase. It never receives tool arguments, tool results, prompt text, or agent +output - those never cross the plugin event boundary. + +## What it does + +- Subscribes to `tool.executed`, `agent.statusChanged`, `agent.completed`, + `agent.error`, `agent.exited`, `run.completed`, `usage.updated`, + `session.created`, `session.updated`, and `session.removed`. +- 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 }`). +- Merges `tool.executed` events by `toolCallId`: a later `completed`/`failed` + 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. + +## Requirements + +- A Maestro host implementing host API `1.13.0` or newer (for the + `maestro.ui.panelPost` host-to-panel channel). +- The `plugins` Encore flag enabled. + +## Install + +Enable the `plugins` Encore flag first (Settings), then either: + +- **CLI:** `maestro plugin install ./examples/plugins/agent-flow` + (validate first with `maestro plugin validate ./examples/plugins/agent-flow`). +- **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 five requested capabilities. 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. + +## 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 (placeholder here; implemented in Phase 5). diff --git a/examples/plugins/agent-flow/main.js b/examples/plugins/agent-flow/main.js new file mode 100644 index 0000000000..e6bca704b5 --- /dev/null +++ b/examples/plugins/agent-flow/main.js @@ -0,0 +1,423 @@ +// Agent Flow - tier-2 Maestro plugin sandbox entry. +// +// Plain CommonJS run through `new vm.Script` inside a utilityProcess: no +// imports, no `require`, no Node built-ins. The only host access is the frozen +// `maestro` SDK (passed to `activate` and also available as a global). Standard +// JS intrinsics (JSON, Date, Map, Math, setTimeout) are available. +// +// Behavior: subscribe to the metadata-only host event stream, maintain a +// per-session execution-graph model (one lane per session, tool-call nodes per +// 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. + +'use strict'; + +/** @typedef {import('@maestro/plugin-sdk').MaestroSdk} MaestroSdk */ + +// ---- constants ------------------------------------------------------------- + +var TOPICS = [ + 'tool.executed', + 'agent.statusChanged', + 'agent.completed', + 'agent.error', + 'agent.exited', + 'run.completed', + 'usage.updated', + 'session.created', + 'session.updated', + 'session.removed', +]; + +// Most recent nodes retained per lane before oldest are dropped. +var LANE_NODE_CAP = 300; +// Trailing-edge coalescing window for panel pushes. +var SNAPSHOT_COALESCE_MS = 250; +// Keep the JSON well under the host's 64 KB panelPost cap. +var SNAPSHOT_MAX_BYTES = 60000; + +// ---- model ----------------------------------------------------------------- + +// sessionId -> lane. A lane is +// { sessionId, title, agentId, status, usage, nodes: [], lastActivity, open } +// where `open` maps an in-flight toolCallId to the node it opened, and each +// node is { toolCallId, toolName, phase, startedAt, endedAt, durationMs }. +var lanes = new Map(); +var lastEventAt = 0; +var snapshotTimer = 0; +/** @type {MaestroSdk | null} */ +var sdk = null; + +function getLane(sessionId) { + var lane = lanes.get(sessionId); + if (!lane) { + lane = { + sessionId: sessionId, + title: '', + agentId: '', + status: '', + usage: null, + nodes: [], + lastActivity: 0, + open: Object.create(null), + }; + lanes.set(sessionId, lane); + } + return lane; +} + +function touch(lane, at) { + if (typeof at === 'number' && at > lane.lastActivity) lane.lastActivity = at; +} + +// Trim a lane to the most recent LANE_NODE_CAP nodes, forgetting any open +// entries whose node was dropped. +function trimLane(lane) { + var overflow = lane.nodes.length - LANE_NODE_CAP; + if (overflow <= 0) return; + var dropped = lane.nodes.splice(0, overflow); + for (var i = 0; i < dropped.length; i++) { + var d = dropped[i]; + if (d.toolCallId && lane.open[d.toolCallId] === d) delete lane.open[d.toolCallId]; + } +} + +// Is `phase` an explicit "starting" phase (vs a terminal one)? +function isOpenPhase(phase) { + if (typeof phase !== 'string') return false; + switch (phase.toLowerCase()) { + case 'running': + case 'started': + case 'start': + case 'in_progress': + case 'pending': + return true; + default: + return false; + } +} + +function pushNode(lane, node) { + lane.nodes.push(node); + trimLane(lane); +} + +// Merge a tool.executed event into a lane. +function applyTool(payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + var toolName = typeof payload.toolName === 'string' ? payload.toolName : 'tool'; + var phase = typeof payload.phase === 'string' ? payload.phase : undefined; + var toolCallId = typeof payload.toolCallId === 'string' ? payload.toolCallId : undefined; + var durMs = typeof payload.durationMs === 'number' ? payload.durationMs : undefined; + + if (toolCallId) { + var openNode = lane.open[toolCallId]; + if (openNode) { + // A later phase closes the node the "running" phase opened. + openNode.phase = phase !== undefined ? phase : openNode.phase; + openNode.endedAt = at; + openNode.toolName = toolName || openNode.toolName; + openNode.durationMs = durMs !== undefined ? durMs : Math.max(0, at - openNode.startedAt); + delete lane.open[toolCallId]; + } else if (isOpenPhase(phase)) { + // Open a new in-flight node. + var node = { + toolCallId: toolCallId, + toolName: toolName, + phase: phase, + startedAt: at, + endedAt: undefined, + durationMs: undefined, + }; + lane.open[toolCallId] = node; + pushNode(lane, node); + } else { + // Terminal (or phase-less) event with no prior open node: a single + // closed node. + pushNode(lane, { + toolCallId: toolCallId, + toolName: toolName, + phase: phase, + startedAt: at, + endedAt: at, + durationMs: durMs !== undefined ? durMs : 0, + }); + } + } else { + // No toolCallId: append a single closed node. + pushNode(lane, { + toolCallId: undefined, + toolName: toolName, + phase: phase, + startedAt: at, + endedAt: at, + durationMs: durMs !== undefined ? durMs : 0, + }); + } + touch(lane, at); +} + +function resetModel() { + lanes.clear(); +} + +// ---- event handlers -------------------------------------------------------- + +function eventTime(payload, meta) { + if (payload && typeof payload.timestamp === 'number') return payload.timestamp; + if (meta && typeof meta.at === 'string') { + var t = Date.parse(meta.at); + if (!isNaN(t)) return t; + } + return lastEventAt || Date.now(); +} + +var HANDLERS = { + 'tool.executed': function (payload, at) { + applyTool(payload, at); + }, + 'agent.statusChanged': function (payload, at) { + if (!payload || typeof payload.agentId !== 'string') return; + // agent.statusChanged carries an agentId, not a sessionId. Prefer an + // existing lane whose agentId matches; else key by the agentId itself. + var target = null; + lanes.forEach(function (lane) { + if (!target && lane.agentId === payload.agentId) target = lane; + }); + if (!target) target = getLane(payload.agentId); + if (typeof payload.status === 'string') target.status = payload.status; + touch(target, at); + }, + 'agent.completed': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + if (typeof payload.status === 'string') lane.status = payload.status; + if (typeof payload.agentId === 'string' && !lane.agentId) lane.agentId = payload.agentId; + touch(lane, at); + }, + 'agent.error': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + lane.status = 'error'; + touch(lane, at); + }, + 'agent.exited': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + lane.status = payload.exitCode === 0 ? 'exited' : 'error'; + touch(lane, at); + }, + 'run.completed': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + touch(getLane(payload.sessionId), at); + }, + 'usage.updated': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + lane.usage = { + inputTokens: num(payload.inputTokens), + outputTokens: num(payload.outputTokens), + cacheReadInputTokens: num(payload.cacheReadInputTokens), + cacheCreationInputTokens: num(payload.cacheCreationInputTokens), + totalCostUsd: num(payload.totalCostUsd), + contextWindow: num(payload.contextWindow), + reasoningTokens: num(payload.reasoningTokens), + }; + touch(lane, at); + }, + 'session.created': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + if (typeof payload.title === 'string') lane.title = payload.title; + if (typeof payload.agentId === 'string') lane.agentId = payload.agentId; + touch(lane, at); + }, + 'session.updated': function (payload, at) { + if (!payload || typeof payload.sessionId !== 'string') return; + var lane = getLane(payload.sessionId); + if (typeof payload.title === 'string') lane.title = payload.title; + if (typeof payload.status === 'string') lane.status = payload.status; + touch(lane, at); + }, + 'session.removed': function (payload) { + if (!payload || typeof payload.sessionId !== 'string') return; + lanes.delete(payload.sessionId); + }, +}; + +function num(v) { + return typeof v === 'number' && isFinite(v) ? v : 0; +} + +function onEvent(topic, payload, meta) { + var handler = HANDLERS[topic]; + if (!handler) return; + var at = eventTime(payload, meta); + lastEventAt = at; + handler(payload, at); + scheduleSnapshot(); +} + +// ---- snapshot pushing ------------------------------------------------------ + +// UTF-8 byte length without relying on TextEncoder/Buffer (absent in sandbox). +function utf8Len(s) { + var n = 0; + for (var i = 0; i < s.length; i++) { + var c = s.charCodeAt(i); + if (c < 0x80) n += 1; + else if (c < 0x800) n += 2; + else if (c >= 0xd800 && c <= 0xdbff) { + n += 4; + i++; + } else n += 3; + } + return n; +} + +function laneSnapshot(lane, cap) { + var nodes = lane.nodes; + if (nodes.length > cap) nodes = nodes.slice(nodes.length - cap); + var out = new Array(nodes.length); + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i]; + out[i] = { + toolCallId: n.toolCallId, + toolName: n.toolName, + phase: n.phase, + startedAt: n.startedAt, + endedAt: n.endedAt, + durationMs: n.durationMs, + }; + } + return { + sessionId: lane.sessionId, + title: lane.title, + agentId: lane.agentId, + status: lane.status, + usage: lane.usage, + nodes: out, + }; +} + +function sortedLanes() { + var arr = []; + lanes.forEach(function (lane) { + arr.push(lane); + }); + // Most recent activity first. + arr.sort(function (a, b) { + return b.lastActivity - a.lastActivity; + }); + return arr; +} + +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 }; +} + +function pushSnapshot() { + if (!sdk) return; + var cap = LANE_NODE_CAP; + var snap = buildSnapshot(cap); + var json = JSON.stringify(snap); + // Guard the 64 KB panel-post cap: halve the per-lane node cap until it fits, + // dropping oldest nodes first. + while (utf8Len(json) > SNAPSHOT_MAX_BYTES && cap > 1) { + cap = Math.floor(cap / 2); + snap = buildSnapshot(cap); + json = JSON.stringify(snap); + } + try { + var p = sdk.ui.panelPost('flow', snap); + // panelPost is a brokered async call; swallow denial (ui:panel not yet + // granted) so we simply retry on the next mutation. + if (p && typeof p.then === 'function') p.then(undefined, function () {}); + } catch (e) { + /* denial or bridge gone; retry next mutation */ + } +} + +function scheduleSnapshot() { + // At most one push per SNAPSHOT_COALESCE_MS (trailing edge). + if (snapshotTimer) return; + snapshotTimer = setTimeout(function () { + snapshotTimer = 0; + pushSnapshot(); + }, SNAPSHOT_COALESCE_MS); +} + +// ---- startup --------------------------------------------------------------- + +// Seed lane titles / agent ids from currently-open sessions. Tolerates denial +// if the sessions:read grant is missing. +function seedFromSessions() { + if (!sdk) return; + try { + var p = sdk.sessions.list(); + if (!p || typeof p.then !== 'function') return; + p.then( + function (list) { + if (!Array.isArray(list)) return; + for (var i = 0; i < list.length; i++) { + var s = list[i]; + if (!s || typeof s.id !== 'string') continue; + var lane = getLane(s.id); + if (typeof s.title === 'string') lane.title = s.title; + if (typeof s.agentId === 'string') lane.agentId = s.agentId; + if (typeof s.status === 'string') lane.status = s.status; + } + scheduleSnapshot(); + }, + function () { + /* grant missing; tolerate */ + } + ); + } catch (e) { + /* tolerate */ + } +} + +function activate(maestro) { + sdk = maestro; + console.log('[agent-flow] starting up'); + + // Register in-realm handlers, then ask the host to deliver these topics. + for (var i = 0; i < TOPICS.length; i++) { + (function (topic) { + maestro.events.on(topic, function (payload, meta) { + onEvent(topic, payload, meta); + }); + })(TOPICS[i]); + } + try { + var sub = maestro.events.subscribe(TOPICS); + if (sub && typeof sub.then === 'function') sub.then(undefined, function () {}); + } catch (e) { + /* subscription denial is tolerated; handlers simply never fire */ + } + + // The contributed "clear" command resets the whole graph. + maestro.commands.register('clear', function () { + resetModel(); + scheduleSnapshot(); + }); + + seedFromSessions(); +} + +function deactivate() { + if (snapshotTimer) { + clearTimeout(snapshotTimer); + snapshotTimer = 0; + } + resetModel(); + sdk = null; +} + +module.exports = { activate: activate, deactivate: deactivate }; diff --git a/examples/plugins/agent-flow/panel.html b/examples/plugins/agent-flow/panel.html new file mode 100644 index 0000000000..b652bad05a --- /dev/null +++ b/examples/plugins/agent-flow/panel.html @@ -0,0 +1 @@ +Agent Flow panel: implemented in Phase 5 diff --git a/examples/plugins/agent-flow/plugin.json b/examples/plugins/agent-flow/plugin.json new file mode 100644 index 0000000000..2f41fc874c --- /dev/null +++ b/examples/plugins/agent-flow/plugin.json @@ -0,0 +1,29 @@ +{ + "id": "agent-flow", + "name": "Agent Flow", + "version": "0.1.0", + "tier": 2, + "maestro": { "minHostApi": "1.13.0" }, + "description": "Live per-session execution-graph visualization built from host tool + agent lifecycle events.", + "category": "insights", + "entry": "main.js", + "permissions": [ + { + "capability": "events:subscribe", + "reason": "Follow tool and agent lifecycle events to build the flow graph." + }, + { "capability": "ui:panel", "reason": "Render the Agent Flow graph in its own panel." }, + { + "capability": "sessions:read", + "reason": "Seed lane titles and agent ids for open sessions at startup." + }, + { "capability": "storage:read", "reason": "Restore saved panel display preferences." }, + { "capability": "storage:write", "reason": "Persist panel display preferences." } + ], + "contributes": { + "panels": [ + { "id": "flow", "title": "Agent Flow", "entry": "panel.html", "placement": "right" } + ], + "commands": [{ "id": "clear", "title": "Agent Flow: Clear Graph" }] + } +} From 4c187e10d96d1e964af07ea888cd644d1424c297 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 23:09:38 +0200 Subject: [PATCH 05/24] MAESTRO: build Agent Flow panel UI (graph, pan/zoom, inspector, timeline) (Agent Flow Phase 5) Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/plugins/agent-flow/README.md | 33 +- examples/plugins/agent-flow/panel.html | 867 ++++++++++++++++++++++++- 2 files changed, 898 insertions(+), 2 deletions(-) diff --git a/examples/plugins/agent-flow/README.md b/examples/plugins/agent-flow/README.md index f185caa685..fdc1d0219c 100644 --- a/examples/plugins/agent-flow/README.md +++ b/examples/plugins/agent-flow/README.md @@ -26,6 +26,36 @@ output - those never cross the plugin event boundary. - 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. +## 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 +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. +- **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 + **Clear** button posts the `clear` command back to the sandbox. + +Screenshot: _(placeholder - capture the panel with a couple of active sessions +once the plugin is installed and add `panel.png` here.)_ + ## Requirements - A Maestro host implementing host API `1.13.0` or newer (for the @@ -49,4 +79,5 @@ fills in as agents run; the "Agent Flow: Clear Graph" command resets it. - `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 (placeholder here; implemented in Phase 5). +- `panel.html` - the panel UI: node graph, pan/zoom, inspector, timeline, and + session tabs (single self-contained file, no external references). diff --git a/examples/plugins/agent-flow/panel.html b/examples/plugins/agent-flow/panel.html index b652bad05a..f8973614bb 100644 --- a/examples/plugins/agent-flow/panel.html +++ b/examples/plugins/agent-flow/panel.html @@ -1 +1,866 @@ -Agent Flow panel: implemented in Phase 5 + + + + + + Agent Flow + + + +
+
+
Agent Flow
+
+ +
+
+
+ + + +
Waiting for agent activity...
+
+ +
+
+
Timeline
+ +
+
+ + + From 4e3409bfc83cead9bbfc301766ff0e1b730e5fdd Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 23:20:21 +0200 Subject: [PATCH 06/24] MAESTRO: add activity + health overlay to Agent Flow plugin (Agent Flow Phase 6) Folds issue #1231 ("more insight to long running thinking tasks") into the Agent Flow visualizer, using metadata only (no thinking prose or tool arguments/outputs). main.js: subscribe to agent.awaiting; track per-lane lastActivityAt, runningToolCount, awaiting, and lastError; emit a top-level summary { busyLanes, runningTools, awaitingLanes, erroredLanes } and the four new fields per lane. runningToolCount is maintained independently of the capped nodes array (decremented on close and on trim of a still-open node). panel.html: header activity strip from snapshot.summary; per-lane health badges (coarse status, tool count, live elapsed timer, amber stall warning past 30s, red error badge with recoverability hint); a 1s interval repaints only the summary + badges against the live clock so timers advance between snapshots without re-rendering the SVG graph. README: new "Activity and health (issue #1231)" section noting the metadata-only boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/plugins/agent-flow/README.md | 43 +++- examples/plugins/agent-flow/main.js | 102 ++++++++- examples/plugins/agent-flow/panel.html | 273 ++++++++++++++++++++++++- 3 files changed, 395 insertions(+), 23 deletions(-) diff --git a/examples/plugins/agent-flow/README.md b/examples/plugins/agent-flow/README.md index fdc1d0219c..afdbcbe327 100644 --- a/examples/plugins/agent-flow/README.md +++ b/examples/plugins/agent-flow/README.md @@ -13,9 +13,9 @@ output - those never cross the plugin event boundary. ## What it does -- Subscribes to `tool.executed`, `agent.statusChanged`, `agent.completed`, - `agent.error`, `agent.exited`, `run.completed`, `usage.updated`, - `session.created`, `session.updated`, and `session.removed`. +- 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`. - 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 }`). @@ -53,6 +53,43 @@ receives as a `maestro:panelData` window message: selecting a tab filters both the graph and the timeline to that session. 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 +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 +agents are running, whether a thread is working / waiting / stuck, and whether a +run has broken on an error. + +- **Activity summary strip** - a bar under the header reads `snapshot.summary` + and shows fleet-wide counts: "N working, N tools running, N waiting, N 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 + ("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 + 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 + 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. + +This overlay shows **metadata only**: aggregate counts, coarse per-lane 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 +plugin event boundary (`src/shared/plugins/events.ts`). + Screenshot: _(placeholder - capture the panel with a couple of active sessions once the plugin is installed and add `panel.png` here.)_ diff --git a/examples/plugins/agent-flow/main.js b/examples/plugins/agent-flow/main.js index e6bca704b5..0ae4211d8b 100644 --- a/examples/plugins/agent-flow/main.js +++ b/examples/plugins/agent-flow/main.js @@ -20,6 +20,7 @@ var TOPICS = [ 'tool.executed', 'agent.statusChanged', + 'agent.awaiting', 'agent.completed', 'agent.error', 'agent.exited', @@ -61,6 +62,16 @@ function getLane(sessionId) { nodes: [], lastActivity: 0, open: Object.create(null), + // Health metadata (issue #1231). `lastActivityAt` is a wall-clock ms + // epoch (Date.now) refreshed on real activity so the panel can compute + // an elapsed timer / stall warning against its own live clock; + // `runningToolCount` tracks in-flight tool nodes independently of the + // capped `nodes` array; `awaiting` marks a lane blocked on input; + // `lastError` holds the last agent.error metadata until cleared. + lastActivityAt: Date.now(), + runningToolCount: 0, + awaiting: false, + lastError: null, }; lanes.set(sessionId, lane); } @@ -79,7 +90,12 @@ function trimLane(lane) { var dropped = lane.nodes.splice(0, overflow); for (var i = 0; i < dropped.length; i++) { var d = dropped[i]; - if (d.toolCallId && lane.open[d.toolCallId] === d) delete lane.open[d.toolCallId]; + if (d.toolCallId && lane.open[d.toolCallId] === d) { + delete lane.open[d.toolCallId]; + // The node we can no longer track was still in flight; drop it from the + // running count so a later close (which will not match) cannot inflate it. + if (lane.runningToolCount > 0) lane.runningToolCount--; + } } } @@ -121,6 +137,7 @@ function applyTool(payload, at) { openNode.toolName = toolName || openNode.toolName; openNode.durationMs = durMs !== undefined ? durMs : Math.max(0, at - openNode.startedAt); delete lane.open[toolCallId]; + if (lane.runningToolCount > 0) lane.runningToolCount--; } else if (isOpenPhase(phase)) { // Open a new in-flight node. var node = { @@ -133,6 +150,9 @@ function applyTool(payload, at) { }; lane.open[toolCallId] = node; pushNode(lane, node); + lane.runningToolCount++; + // Fresh tool work opening means any prior error thread has moved on. + lane.lastError = null; } else { // Terminal (or phase-less) event with no prior open node: a single // closed node. @@ -156,6 +176,10 @@ function applyTool(payload, at) { durationMs: durMs !== undefined ? durMs : 0, }); } + // Any tool activity means the session is doing something now: it is no longer + // blocked on input, and this counts as fresh activity for the stall clock. + lane.awaiting = false; + lane.lastActivityAt = Date.now(); touch(lane, at); } @@ -174,20 +198,38 @@ function eventTime(payload, meta) { return lastEventAt || Date.now(); } +// agent.statusChanged / agent.awaiting carry an agentId, not a sessionId. Prefer +// an existing lane whose agentId matches; else key a lane by the agentId itself. +function resolveByAgentId(agentId) { + var target = null; + lanes.forEach(function (lane) { + if (!target && lane.agentId === agentId) target = lane; + }); + if (!target) target = getLane(agentId); + return target; +} + var HANDLERS = { 'tool.executed': function (payload, at) { applyTool(payload, at); }, 'agent.statusChanged': function (payload, at) { if (!payload || typeof payload.agentId !== 'string') return; - // agent.statusChanged carries an agentId, not a sessionId. Prefer an - // existing lane whose agentId matches; else key by the agentId itself. - var target = null; - lanes.forEach(function (lane) { - if (!target && lane.agentId === payload.agentId) target = lane; - }); - if (!target) target = getLane(payload.agentId); - if (typeof payload.status === 'string') target.status = payload.status; + var target = resolveByAgentId(payload.agentId); + if (typeof payload.status === 'string') { + target.status = payload.status; + // A coarse waiting_input status is the same signal as agent.awaiting. + if (payload.status === 'waiting_input') target.awaiting = true; + } + target.lastActivityAt = Date.now(); + touch(target, at); + }, + 'agent.awaiting': function (payload, at) { + if (!payload || typeof payload.agentId !== 'string') return; + var target = resolveByAgentId(payload.agentId); + // Blocked on input: not a stall, and not fresh tool activity, so leave + // lastActivityAt untouched (the panel renders "Waiting for input"). + target.awaiting = true; touch(target, at); }, 'agent.completed': function (payload, at) { @@ -195,12 +237,21 @@ var HANDLERS = { var lane = getLane(payload.sessionId); if (typeof payload.status === 'string') lane.status = payload.status; if (typeof payload.agentId === 'string' && !lane.agentId) lane.agentId = payload.agentId; + // The run reached a terminal state: it is no longer waiting, and a clean + // completion clears any lingering error thread. + lane.awaiting = false; + if (payload.status === 'completed') lane.lastError = null; touch(lane, at); }, 'agent.error': function (payload, at) { if (!payload || typeof payload.sessionId !== 'string') return; var lane = getLane(payload.sessionId); lane.status = 'error'; + lane.lastError = { + errorType: typeof payload.errorType === 'string' ? payload.errorType : 'error', + recoverable: !!payload.recoverable, + at: Date.now(), + }; touch(lane, at); }, 'agent.exited': function (payload, at) { @@ -225,6 +276,10 @@ var HANDLERS = { contextWindow: num(payload.contextWindow), reasoningTokens: num(payload.reasoningTokens), }; + // Token accounting means the model produced output: fresh activity, and + // proof it is no longer blocked on input. + lane.awaiting = false; + lane.lastActivityAt = Date.now(); touch(lane, at); }, 'session.created': function (payload, at) { @@ -239,6 +294,7 @@ var HANDLERS = { var lane = getLane(payload.sessionId); if (typeof payload.title === 'string') lane.title = payload.title; if (typeof payload.status === 'string') lane.status = payload.status; + lane.lastActivityAt = Date.now(); touch(lane, at); }, 'session.removed': function (payload) { @@ -299,6 +355,32 @@ function laneSnapshot(lane, cap) { status: lane.status, usage: lane.usage, nodes: out, + lastActivityAt: lane.lastActivityAt, + runningToolCount: lane.runningToolCount, + awaiting: lane.awaiting, + lastError: lane.lastError, + }; +} + +// Fleet-wide health rollup (issue #1231) for the panel's activity strip. +function buildSummary(ordered) { + var busyLanes = 0; + var runningTools = 0; + var awaitingLanes = 0; + var erroredLanes = 0; + for (var i = 0; i < ordered.length; i++) { + var lane = ordered[i]; + var s = String(lane.status || '').toLowerCase(); + if (s === 'busy' || s === 'connecting') busyLanes++; + runningTools += lane.runningToolCount || 0; + if (lane.awaiting) awaitingLanes++; + if (lane.lastError) erroredLanes++; + } + return { + busyLanes: busyLanes, + runningTools: runningTools, + awaitingLanes: awaitingLanes, + erroredLanes: erroredLanes, }; } @@ -318,7 +400,7 @@ 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 }; + return { v: 1, at: lastEventAt, lanes: out, summary: buildSummary(ordered) }; } function pushSnapshot() { diff --git a/examples/plugins/agent-flow/panel.html b/examples/plugins/agent-flow/panel.html index f8973614bb..896a576afd 100644 --- a/examples/plugins/agent-flow/panel.html +++ b/examples/plugins/agent-flow/panel.html @@ -18,6 +18,7 @@ --yellow: #d29922; --red: #f85149; --gray: #6e7681; + --amber: #f0883e; } * { box-sizing: border-box; @@ -28,7 +29,12 @@ height: 100%; background: var(--bg); color: var(--text); - font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font: + 13px/1.4 -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; overflow: hidden; } #app { @@ -88,6 +94,41 @@ border-color: var(--red); color: var(--red); } + /* Activity summary strip (issue #1231): fleet-wide health rollup. */ + #summaryBar { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + background: var(--panel2); + border-bottom: 1px solid var(--border); + flex: 0 0 auto; + font-size: 11px; + } + #summaryBar.hidden { + display: none; + } + #summaryBar .seg { + padding: 1px 8px; + border-radius: 10px; + font-weight: 600; + white-space: nowrap; + } + .seg-working { + background: rgba(210, 153, 34, 0.18); + color: var(--yellow); + border: 1px solid rgba(210, 153, 34, 0.45); + } + .seg-waiting { + background: rgba(88, 166, 255, 0.15); + color: var(--accent); + border: 1px solid rgba(88, 166, 255, 0.45); + } + .seg-error { + background: rgba(248, 81, 73, 0.15); + color: var(--red); + border: 1px solid rgba(248, 81, 73, 0.5); + } #body { display: flex; flex: 1 1 auto; @@ -243,6 +284,7 @@
+
@@ -268,19 +310,27 @@ var NODE_W = 116; var NODE_H = 38; var GAP_X = 22; - var ROW_H = 66; + // Taller than the node so the lane label can stack title, agent id, and + // two rows of health badges (issue #1231) beneath one another. + var ROW_H = 88; + var STALL_MS = 30000; // amber "no activity" threshold while working // ---- state ----------------------------------------------------------- - var latest = null; // most recent snapshot { v, at, lanes } + var latest = null; // most recent snapshot { v, at, lanes, summary } var view = { tx: 0, ty: 0, scale: 1 }; // pan/zoom, preserved across renders var selection = null; // { kind:'node', sessionId, nodeKey } | { kind:'lane', sessionId } var activeTab = 'all'; // 'all' | sessionId + // sessionId -> { g, x, y1, y2 }: the SVG badge sub-group per lane label and + // its anchor coords, so the 1s clock tick can repaint ONLY the badges + // (elapsed timer + stall warning) without re-rendering the graph. + var healthGroups = Object.create(null); // ---- element refs ---------------------------------------------------- var canvas = document.getElementById('canvas'); var viewport = document.getElementById('viewport'); var emptyEl = document.getElementById('empty'); var tabsEl = document.getElementById('tabs'); + var summaryEl = document.getElementById('summaryBar'); var inspectorEl = document.getElementById('inspector'); var timelineEl = document.getElementById('timeline'); var tlSvg = document.getElementById('tl'); @@ -374,6 +424,183 @@ return 'unknown'; } + // ---- health (issue #1231) ------------------------------------------- + // All derived from METADATA ONLY: coarse status, counts, and timing. + // Never thinking prose, tool arguments, or tool output. + + function isTerminalStatus(s) { + return ( + s === 'completed' || + s === 'failed' || + s === 'cancelled' || + s === 'interrupted' || + s === 'error' || + s === 'exited' + ); + } + + // A lane is "working" when the agent is actively running: a coarse + // busy/connecting status, or a tool still in flight (status can lag). + // Awaiting-input and terminal states are not working. + function isWorking(lane) { + if (lane.awaiting) return false; + var s = String(lane.status || '').toLowerCase(); + if (s === 'waiting_input') return false; + if (isTerminalStatus(s)) return false; + if (s === 'busy' || s === 'connecting') return true; + return (lane.runningToolCount || 0) > 0; + } + + // Human status label from the coarse status / awaiting flag. + function statusLabel(lane) { + if (lane.awaiting) return 'Waiting for input'; + var s = String(lane.status || '').toLowerCase(); + if (s === 'waiting_input') return 'Waiting for input'; + if (s === 'busy' || s === 'connecting') return 'Working'; + if (s === 'idle') return 'Idle'; + if (s === 'completed') return 'Completed'; + if (s === 'failed') return 'Failed'; + if (s === 'error') return 'Error'; + if (s === 'exited') return 'Exited'; + if (s === 'cancelled') return 'Cancelled'; + if (s === 'interrupted') return 'Interrupted'; + if (isWorking(lane)) return 'Working'; + return lane.status ? lane.status : 'Idle'; + } + + // Color bucket for the status badge. + function statusKind(lane) { + if (lane.awaiting) return 'waiting'; + var s = String(lane.status || '').toLowerCase(); + if (s === 'waiting_input') return 'waiting'; + if (s === 'error' || s === 'failed') return 'error'; + if (s === 'completed' || s === 'exited') return 'done'; + if (isWorking(lane)) return 'working'; + return 'idle'; + } + + function badgeColor(kind) { + switch (kind) { + case 'working': + case 'tools': + return 'var(--yellow)'; + case 'waiting': + return 'var(--accent)'; + case 'done': + return 'var(--green)'; + case 'error': + return 'var(--red)'; + case 'stall': + return 'var(--amber)'; + case 'elapsed': + case 'idle': + default: + return 'var(--gray)'; + } + } + + // Draw one rounded pill badge at (x, topY) and return its width. + function svgBadge(parent, x, topY, text, kind) { + var w = Math.max(18, Math.round(String(text).length * 5.6 + 12)); + parent.appendChild( + el('rect', { + x: x, + y: topY, + width: w, + height: 14, + rx: 7, + fill: badgeColor(kind), + opacity: 0.92, + }) + ); + parent.appendChild( + el( + 'text', + { + x: x + w / 2, + y: topY + 10, + 'text-anchor': 'middle', + 'font-size': 9.5, + 'font-weight': 600, + fill: '#0d1117', + }, + text + ) + ); + return w; + } + + // Repaint the badge sub-group for one lane against the live clock. Called + // both during a full graph render and on the 1s tick (badges only). + function paintLaneHealth(entry, lane) { + var g = entry.g; + clear(g); + var working = isWorking(lane); + var hasClock = typeof lane.lastActivityAt === 'number' && isFinite(lane.lastActivityAt); + var idleMs = hasClock ? Math.max(0, Date.now() - lane.lastActivityAt) : 0; + + // Row 1: status, running-tool count, and (while working) an elapsed timer. + var x = entry.x; + x += svgBadge(g, x, entry.y1, truncate(statusLabel(lane), 16), statusKind(lane)) + 5; + var rtc = lane.runningToolCount || 0; + if (rtc > 0) + x += svgBadge(g, x, entry.y1, rtc + (rtc === 1 ? ' tool' : ' tools'), 'tools') + 5; + if (working && hasClock) + svgBadge(g, x, entry.y1, Math.round(idleMs / 1000) + 's', 'elapsed'); + + // Row 2: stall warning and/or error badge. + var x2 = entry.x; + if (working && hasClock && idleMs > STALL_MS) { + x2 += + svgBadge( + g, + x2, + entry.y2, + 'No activity for ' + Math.round(idleMs / 1000) + 's', + 'stall' + ) + 5; + } + if (lane.lastError) { + var et = truncate(String(lane.lastError.errorType || 'error'), 14); + var hint = lane.lastError.recoverable ? 'retrying' : 'needs attention'; + svgBadge(g, x2, entry.y2, et + ' · ' + hint, 'error'); + } + } + + // The header activity strip: fleet-wide counts from snapshot.summary. + function renderSummary() { + clear(summaryEl); + var s = latest && latest.summary ? latest.summary : null; + if (!s) { + summaryEl.classList.add('hidden'); + return; + } + var segs = []; + if (s.busyLanes > 0) segs.push({ t: s.busyLanes + ' working', c: 'working' }); + if (s.runningTools > 0) + segs.push({ + t: s.runningTools + (s.runningTools === 1 ? ' tool running' : ' tools running'), + c: 'working', + }); + if (s.awaitingLanes > 0) segs.push({ t: s.awaitingLanes + ' waiting', c: 'waiting' }); + if (s.erroredLanes > 0) + segs.push({ + t: s.erroredLanes + (s.erroredLanes === 1 ? ' error' : ' errors'), + c: 'error', + }); + if (!segs.length) { + summaryEl.classList.add('hidden'); + return; + } + summaryEl.classList.remove('hidden'); + for (var i = 0; i < segs.length; i++) { + var span = document.createElement('span'); + span.className = 'seg seg-' + segs[i].c; + span.textContent = segs[i].t; + summaryEl.appendChild(span); + } + } + // Stable per-node key so selection survives re-renders. function nodeKey(node, index) { return node.toolCallId ? 't:' + node.toolCallId : '#' + index; @@ -428,6 +655,7 @@ // ---- graph ----------------------------------------------------------- function renderGraph() { clear(viewport); + healthGroups = Object.create(null); var lanes = visibleLanes(); emptyEl.classList.toggle('hidden', lanes.length > 0); @@ -435,6 +663,7 @@ var lane = lanes[li]; var y0 = TOP + li * ROW_H; var midY = y0 + ROW_H / 2; + var boxTop = y0 + 6; // Lane separator. if (li > 0) { @@ -459,7 +688,7 @@ labelG.appendChild( el('rect', { x: PAD_X - 8, - y: y0 + 6, + y: boxTop, width: LABEL_W - 12, height: ROW_H - 14, rx: 6, @@ -471,7 +700,7 @@ labelG.appendChild( el('circle', { cx: PAD_X + 4, - cy: midY - 6, + cy: boxTop + 12, r: 4.5, class: 's-' + statusPhase(lane.status), }) @@ -479,17 +708,24 @@ labelG.appendChild( el( 'text', - { x: PAD_X + 16, y: midY - 2, 'font-size': 12, 'font-weight': 600 }, + { x: PAD_X + 16, y: boxTop + 16, 'font-size': 12, 'font-weight': 600 }, truncate(lane.title || lane.sessionId, 18) ) ); labelG.appendChild( el( 'text', - { x: PAD_X + 16, y: midY + 13, 'font-size': 10.5, fill: 'var(--muted)' }, + { x: PAD_X + 16, y: boxTop + 31, 'font-size': 10.5, fill: 'var(--muted)' }, truncate((lane.agentId || '') + (lane.status ? ' · ' + lane.status : ''), 22) ) ); + // Health badge sub-group (issue #1231): repainted on the 1s tick. + var hg = document.createElementNS(SVGNS, 'g'); + hg.setAttribute('class', 'health'); + labelG.appendChild(hg); + var entry = { g: hg, x: PAD_X - 4, y1: boxTop + 40, y2: boxTop + 57 }; + healthGroups[lane.sessionId] = entry; + paintLaneHealth(entry, lane); (function (sid) { labelG.addEventListener('click', function () { selectLane(sid); @@ -676,7 +912,8 @@ } function findLane(sessionId) { var lanes = latest && latest.lanes ? latest.lanes : []; - for (var i = 0; i < lanes.length; i++) if (lanes[i].sessionId === sessionId) return lanes[i]; + for (var i = 0; i < lanes.length; i++) + if (lanes[i].sessionId === sessionId) return lanes[i]; return null; } function findNode(lane, key) { @@ -709,9 +946,9 @@ dot.className = 'dot'; dot.style.background = 'var(--' + - ({ running: 'yellow', completed: 'green', failed: 'red', unknown: 'gray' }[ + { running: 'yellow', completed: 'green', failed: 'red', unknown: 'gray' }[ nodePhase(node) - ]) + + ] + ')'; h.appendChild(dot); h.appendChild(document.createTextNode(node.toolName || 'tool')); @@ -771,11 +1008,27 @@ // ---- master render --------------------------------------------------- function render() { renderTabs(); + renderSummary(); renderGraph(); renderTimeline(); renderInspector(); } + // A genuinely stalled or errored lane emits NO further host events, so no + // new snapshot arrives. Tick the live clock every second and repaint ONLY + // the summary strip and per-lane health badges (never the SVG graph) so the + // elapsed timer and stall warning keep advancing between snapshots. + function refreshHealth() { + renderSummary(); + if (!latest) return; + var lanes = visibleLanes(); + for (var i = 0; i < lanes.length; i++) { + var entry = healthGroups[lanes[i].sessionId]; + if (entry) paintLaneHealth(entry, lanes[i]); + } + } + setInterval(refreshHealth, 1000); + // ---- pan / zoom ------------------------------------------------------ var dragging = false; var dragMoved = false; From 223f0964df9b32bf2e0cf2a7a5f6d86c65d746e8 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Mon, 20 Jul 2026 23:55:01 +0200 Subject: [PATCH 07/24] MAESTRO: Agent Flow plugin Phase 7 validation and wrap-up Final validation pass for the Agent Flow plugin effort. No code behavior changes: this closes out the playbook's verify/audit/wrap-up phase. - README: add "Security notes" (six audited invariants, each PASS) and a "Result" section (what shipped, the two host-API additions at 1.13.0, install steps, known limitations). - panel.html: replace six en-dash "no value" placeholders with a plain hyphen (repo rule: no em/en dashes anywhere). File still parses; single script/style block; no external references. Verified: tsc clean (all three configs), eslint src/ clean, scoped tests green (196 root + 29 plugin-sdk), main.js parses. Co-Authored-By: Claude Opus 4.8 --- examples/plugins/agent-flow/README.md | 82 ++++++++++++++++++++++++++ examples/plugins/agent-flow/panel.html | 12 ++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/examples/plugins/agent-flow/README.md b/examples/plugins/agent-flow/README.md index afdbcbe327..490d36b254 100644 --- a/examples/plugins/agent-flow/README.md +++ b/examples/plugins/agent-flow/README.md @@ -118,3 +118,85 @@ fills in as agents run; the "Agent Flow: Clear Graph" command resets it. - `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). + +## Security notes + +Each item below was confirmed by reading the final host and plugin code +(Phase 7 audit) against the invariants in `CLAUDE-PLUGINS.md`: + +- **PASS - `tool.executed` carries no content.** The emit site in + `src/main/process-listeners/forwarding-listeners.ts` builds the payload from + `sessionId`, `toolName`, `timestamp`, and optional `toolCallId` and `phase` + only. `phase` is lifted by `extractToolPhase`, which returns a plain string + (`status`/`phase` field) or `undefined`; the tool `state` object (arguments + and results) is never referenced in the payload. +- **PASS - `ui.panelPost` is gated and fails closed.** The handler in + `src/main/plugins/plugin-host-handlers.ts` is registered only when its + `panelPost` sink is wired (no sink means the method is absent and denied, + mirroring `agents.dispatch`). It requires the `ui:panel` grant + (`assertBrokerAllowed`), resolves `panelId` as the caller's own declared + local panel via `getPanel` (a foreign or already-namespaced id never + resolves), requires JSON-serializable `data`, and enforces + `MAX_PANEL_POST_BYTES` (64 KB). +- **PASS - the guest preload is a dumb one-way relay.** `src/main/preload/plugin-panel.ts` + exposes nothing on `window` (no `contextBridge`, no `ipcRenderer`), forwards + only the `maestro:invokeCommand` shape out (source-window gated) and re-posts + only the `maestro:panelData` shape in. No value is evaluated and there is no + reply channel. +- **PASS - the panel has no external references.** `panel.html` is one + self-contained file: a single `
From 5529958e41845e3973cfe001131741adb0633523 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 21 Jul 2026 00:34:28 +0200 Subject: [PATCH 10/24] MAESTRO: fix CI - mock ipcRenderer.on for the panel-data inbound relay Phase 2 added an inbound `ipcRenderer.on('maestro:panelData', ...)` relay to the panel guest preload, but plugin-panel.test.ts mocked `electron` with only `ipcRenderer.sendToHost`, so importing the module threw "ipcRenderer.on is not a function" (ubuntu shard 2). Added `on` to the mock and a test asserting the preload relays exactly the one inbound `maestro:panelData` channel into the page as a window message and never turns it into an outbound call. Verified: plugin-panel.test.ts green (5), all preload tests green (454), all plugin test dirs green (595). Co-Authored-By: Claude Opus 4.8 --- .../main/preload/plugin-panel.test.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/__tests__/main/preload/plugin-panel.test.ts b/src/__tests__/main/preload/plugin-panel.test.ts index 7ebc991a87..d8b5c60535 100644 --- a/src/__tests__/main/preload/plugin-panel.test.ts +++ b/src/__tests__/main/preload/plugin-panel.test.ts @@ -6,14 +6,16 @@ * legacy postMessage bridge shape ({ type: 'maestro:invokeCommand', * commandId, args }) to the embedder via ipcRenderer.sendToHost, and ignores * everything else: wrong source (not the panel's own window), wrong type, - * non-string commandId, and non-object data. Nothing is exposed on window. + * non-string commandId, and non-object data. It also relays EXACTLY the one + * inbound channel (maestro:panelData) into the page as a window message. + * Nothing is exposed on window. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { sendToHost } = vi.hoisted(() => ({ sendToHost: vi.fn() })); +const { sendToHost, ipcOn } = vi.hoisted(() => ({ sendToHost: vi.fn(), ipcOn: vi.fn() })); vi.mock('electron', () => ({ - ipcRenderer: { sendToHost }, + ipcRenderer: { sendToHost, on: ipcOn }, contextBridge: { exposeInMainWorld: vi.fn() }, })); @@ -60,4 +62,27 @@ describe('plugin-panel preload bridge', () => { dispatchMessage(null); expect(sendToHost).not.toHaveBeenCalled(); }); + + it('relays only the maestro:panelData channel into the page as a window message', async () => { + // Importing the module registered exactly one inbound ipcRenderer.on for the + // panel-data channel; nothing else is relayed and no reply channel exists. + const dataCalls = ipcOn.mock.calls.filter((c) => c[0] === 'maestro:panelData'); + expect(dataCalls).toHaveLength(1); + const handler = dataCalls[0][1] as (event: unknown, data: unknown) => void; + + const posted: unknown[] = []; + const onMessage = (e: MessageEvent) => posted.push(e.data); + window.addEventListener('message', onMessage); + try { + handler({}, { nodes: [1, 2, 3] }); + // jsdom dispatches window.postMessage asynchronously; wait a macrotask. + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + window.removeEventListener('message', onMessage); + } + + expect(posted).toContainEqual({ type: 'maestro:panelData', data: { nodes: [1, 2, 3] } }); + // The inbound relay must never turn into an outbound call. + expect(sendToHost).not.toHaveBeenCalled(); + }); }); From 1dc5d20af2536af90944d7dedc06c7ac8a6b61ff Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 10:16:31 +0200 Subject: [PATCH 11/24] MAESTRO: add metadata-only session.activated plugin event Adds the focused-agent signal the Agent Flow overlay needs (phase 1, host-API addition A): - new PLUGIN_EVENT_TOPICS entry 'session.activated' with payload { sessionId, tabId? } in src/shared/plugins/events.ts, mirrored by hand into the vendored packages/plugin-sdk (CI does not check that parity) - emitted from the sessions:setActiveSessionId handler with its own 100ms trailing debounce, separate from the existing 400ms disk-write debounce, and suppressed when the same session is re-focused - tabId is omitted: that IPC only reports the focused session and the stored tab state can lag the live one Payload is ids only, per the events.ts metadata-only contract. --- packages/plugin-sdk/src/index.ts | 4 ++ .../main/ipc/handlers/persistence.test.ts | 58 +++++++++++++++++++ src/main/ipc/handlers/persistence.ts | 37 ++++++++++++ src/shared/plugins/events.ts | 4 ++ 4 files changed, 103 insertions(+) diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fd3dea8ccf..47c2faf610 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1160,6 +1160,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 +1258,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. */ diff --git a/src/__tests__/main/ipc/handlers/persistence.test.ts b/src/__tests__/main/ipc/handlers/persistence.test.ts index 24ab11f4c2..6e1263bf49 100644 --- a/src/__tests__/main/ipc/handlers/persistence.test.ts +++ b/src/__tests__/main/ipc/handlers/persistence.test.ts @@ -230,6 +230,64 @@ 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(); + }); + }); }); describe('settings:get', () => { diff --git a/src/main/ipc/handlers/persistence.ts b/src/main/ipc/handlers/persistence.ts index 0ed2e76102..2a5ce207b0 100644 --- a/src/main/ipc/handlers/persistence.ts +++ b/src/main/ipc/handlers/persistence.ts @@ -128,6 +128,36 @@ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies // before windows close; the write is synchronous so it completes in-line. app.on('before-quit', flushActiveSessionId); + // Metadata-only `session.activated` for subscribed plugins (events:subscribe). + // Its own debounce, deliberately much shorter than the 400ms disk debounce + // above: that one exists to avoid re-serializing the sessions store and is too + // slow to feel live in a plugin surface, while emitting on every raw call would + // spray events as the user arrow-keys down the Left Bar. Trailing-edge, so a + // burst of navigation yields one event for the session actually landed on. + const SESSION_ACTIVATED_DEBOUNCE_MS = 100; + let pendingActivatedSessionId: string | null = null; + let lastEmittedActivatedSessionId: string | null = null; + let sessionActivatedTimer: NodeJS.Timeout | null = null; + + const flushSessionActivated = (): void => { + sessionActivatedTimer = null; + const id = pendingActivatedSessionId; + pendingActivatedSessionId = null; + if (!id || !emitPluginEvent) return; + // Re-focusing the session the plugins were last told about is a no-op. + if (id === lastEmittedActivatedSessionId) return; + lastEmittedActivatedSessionId = id; + emitPluginEvent({ + topic: 'session.activated', + at: new Date().toISOString(), + // `tabId` is intentionally omitted: the renderer only reports which + // SESSION is focused here, and the stored session record's tab state can + // lag the live one. The field stays optional for a future caller that + // does know the tab. + payload: { sessionId: id }, + }); + }; + // Settings management ipcMain.handle('settings:get', async (_, key: string) => { const value = settingsStore.get(key); @@ -258,6 +288,13 @@ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies pendingActiveSessionId = id; if (activeSessionIdTimer) clearTimeout(activeSessionIdTimer); activeSessionIdTimer = setTimeout(flushActiveSessionId, ACTIVE_SESSION_ID_DEBOUNCE_MS); + + // Separate, shorter debounce for the plugin event (see flushSessionActivated). + if (emitPluginEvent && typeof id === 'string' && id) { + pendingActivatedSessionId = id; + if (sessionActivatedTimer) clearTimeout(sessionActivatedTimer); + sessionActivatedTimer = setTimeout(flushSessionActivated, SESSION_ACTIVATED_DEBOUNCE_MS); + } }); /** diff --git a/src/shared/plugins/events.ts b/src/shared/plugins/events.ts index 9c5e123ce0..fc32336f58 100644 --- a/src/shared/plugins/events.ts +++ b/src/shared/plugins/events.ts @@ -25,6 +25,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]; @@ -125,6 +126,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. */ From 44db841b567126593e85fa35d7829e73b0b4928f Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 10:30:17 +0200 Subject: [PATCH 12/24] MAESTRO: add sessions.focus plugin host verb Adds a narrow, navigation-only host verb so a plugin can jump the user to an existing agent's session without holding tabs:manage (which also carries tab creation and destruction). - new sessions:focus capability (risk low, no scope) - sessions.focus verb: closed { sessionId, tabId? } schema, broker check, unknown-session rejection, main-side effect - pluginAiFocusFields(): main-side mirror of the renderer's aiTabFocusFields(), now shared with tabs.focus so the two cannot drift (it also adds the activeGroupId: null the old inline literal was missing) - sandbox shim + vendored plugin-sdk mirror (capability rows, HOST_API row, MaestroSessionsApi.focus) --- packages/plugin-sdk/src/index.ts | 14 +++++ .../main/plugins/plugin-host-handlers.test.ts | 51 +++++++++++++++++++ .../shared/plugins/rpc-protocol.test.ts | 7 +++ src/main/index.ts | 51 ++++++++++++++++--- src/main/plugins/plugin-host-handlers.ts | 18 +++++++ src/main/plugins/plugin-sandbox-entry.ts | 3 +- src/shared/plugins/permissions.ts | 9 ++++ src/shared/plugins/rpc-protocol.ts | 1 + 8 files changed, 145 insertions(+), 9 deletions(-) diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 47c2faf610..2b5b68f0e1 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': @@ -1294,6 +1303,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' }, @@ -1486,6 +1496,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 diff --git a/src/__tests__/main/plugins/plugin-host-handlers.test.ts b/src/__tests__/main/plugins/plugin-host-handlers.test.ts index 56037c7ede..58e46fa4fe 100644 --- a/src/__tests__/main/plugins/plugin-host-handlers.test.ts +++ b/src/__tests__/main/plugins/plugin-host-handlers.test.ts @@ -976,6 +976,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__/shared/plugins/rpc-protocol.test.ts b/src/__tests__/shared/plugins/rpc-protocol.test.ts index e8c4dd1a94..adda56a2e9 100644 --- a/src/__tests__/shared/plugins/rpc-protocol.test.ts +++ b/src/__tests__/shared/plugins/rpc-protocol.test.ts @@ -50,6 +50,13 @@ describe('P0 host RPC contract additions', () => { } }); + it('maps sessions.focus to its own narrow navigation capability', () => { + // Deliberately NOT tabs:manage: focusing must not require the power to + // create or destroy the user's tabs. + expect(HOST_METHOD_CAPABILITY['sessions.focus']).toBe('sessions:focus'); + expect(HOST_METHODS).toContain('sessions.focus'); + }); + it('maps the net:connect methods to the net:connect capability', () => { expect(HOST_METHOD_CAPABILITY['net.connect']).toBe('net:connect'); expect(HOST_METHOD_CAPABILITY['net.send']).toBe('net:connect'); diff --git a/src/main/index.ts b/src/main/index.ts index e4e93e16c0..33631dfb45 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1944,6 +1944,21 @@ app ...(typeof session.cwd === 'string' ? { projectPath: session.cwd } : {}), }; }; + /** + * Main-side mirror of the renderer's `aiTabFocusFields()` + * (`src/renderer/utils/tabHelpers.ts`): land a session on an AI tab by + * clearing every non-AI view that would otherwise outrank it in the render + * precedence. Shared by `tabs.focus` and `sessions.focus` so the two plugin + * verbs can never drift into different notions of "focused". + */ + const pluginAiFocusFields = (tabId?: string): Record => ({ + ...(tabId ? { activeTabId: tabId } : {}), + activeFileTabId: null, + activeBrowserTabId: null, + activeTerminalTabId: null, + inputMode: 'ai', + activeGroupId: null, + }); const pluginTabsFocus = async (tabId: string): Promise => { const sessions = pluginSessionsRaw(); let focused = false; @@ -1951,14 +1966,7 @@ app if ((Array.isArray(session.aiTabs) ? session.aiTabs : []).some((t) => t?.id === tabId)) { focused = true; sessionsStore.set('activeSessionId', session.id as string); - return { - ...session, - activeTabId: tabId, - activeFileTabId: null, - activeBrowserTabId: null, - activeTerminalTabId: null, - inputMode: 'ai', - }; + return { ...session, ...pluginAiFocusFields(tabId) }; } if ( (Array.isArray(session.terminalTabs) ? session.terminalTabs : []).some( @@ -1980,6 +1988,32 @@ app if (focused) setPluginSessionsRaw(next); return focused; }; + /** + * Jump the user to an existing session (the `sessions.focus` verb). Without + * a tabId it keeps whichever AI tab the session already had active, falling + * back to its first AI tab; with one, that tab must belong to the session or + * the call is rejected rather than silently landing somewhere else. + */ + const pluginSessionsFocus = async (sessionId: string, tabId?: string): Promise => { + const sessions = pluginSessionsRaw(); + const session = sessions.find((s) => s.id === sessionId); + if (!session) return false; + const aiTabs = (Array.isArray(session.aiTabs) ? session.aiTabs : []) as Array< + Record | undefined + >; + const hasAiTab = (id: unknown) => + typeof id === 'string' && aiTabs.some((t) => t?.id === id) ? id : undefined; + if (tabId !== undefined && !hasAiTab(tabId)) return false; + const target = + tabId ?? + hasAiTab(session.activeTabId) ?? + (typeof aiTabs[0]?.id === 'string' ? (aiTabs[0].id as string) : undefined); + sessionsStore.set('activeSessionId', sessionId); + setPluginSessionsRaw( + sessions.map((s) => (s.id === sessionId ? { ...s, ...pluginAiFocusFields(target) } : s)) + ); + return true; + }; const pluginTabsClose = async (tabId: string): Promise => { const sessions = pluginSessionsRaw(); let closed = false; @@ -2269,6 +2303,7 @@ app sessionsCreate: pluginSessionsCreate, sessionsUpdate: pluginSessionsUpdate, sessionsDelete: pluginSessionsDelete, + sessionsFocus: pluginSessionsFocus, tabsList: pluginTabsList, tabsCreate: pluginTabsCreate, tabsFocus: pluginTabsFocus, diff --git a/src/main/plugins/plugin-host-handlers.ts b/src/main/plugins/plugin-host-handlers.ts index bbb9242e63..6ac27bb4f4 100644 --- a/src/main/plugins/plugin-host-handlers.ts +++ b/src/main/plugins/plugin-host-handlers.ts @@ -152,6 +152,9 @@ export interface HostHandlerDeps { patch: Record ) => Promise; sessionsDelete?: (sessionId: string) => Promise; + /** Move the user's focus to an existing session, landing on its AI tab. + * Resolves false when the session (or the named AI tab) no longer exists. */ + sessionsFocus?: (sessionId: string, tabId?: string) => Promise; /** Tab metadata and mutators. When omitted the handlers fail closed. */ tabsList?: (sessionId?: string) => PluginTabMetadata[]; @@ -868,6 +871,21 @@ export function buildHostCallHandlers(deps: HostHandlerDeps): HostCallHandlers { }); }, + 'sessions.focus': async (pluginId, params) => { + const p = asObject(params); + if (typeof p.sessionId !== 'string') throw new Error('sessionId is required'); + if (p.tabId !== undefined && typeof p.tabId !== 'string') + throw new Error('tabId must be a string'); + // Closed schema: focus is navigation, so nothing else may ride along. + assertClosedSchema('sessions.focus', p, { sessionId: true, tabId: true }); + assertBrokerAllowed(deps, pluginId, 'sessions.focus', p); + requireSession(p.sessionId); + if (!deps.sessionsFocus) throw new Error('sessions.focus is unavailable'); + const ok = await deps.sessionsFocus(p.sessionId, p.tabId); + if (!ok) throw new Error(`unknown focus target: ${p.sessionId}`); + return { ok: true }; + }, + 'history.list': async (pluginId, params) => { const p = asObject(params); assertBrokerAllowed(deps, pluginId, 'history.list', p); diff --git a/src/main/plugins/plugin-sandbox-entry.ts b/src/main/plugins/plugin-sandbox-entry.ts index 95c2ddad34..f8966559db 100644 --- a/src/main/plugins/plugin-sandbox-entry.ts +++ b/src/main/plugins/plugin-sandbox-entry.ts @@ -275,7 +275,8 @@ const BOOTSTRAP_SOURCE = String.raw`(function bootstrap(bridge) { get: function (sessionId) { return hostCall('sessions.get', { sessionId: sessionId }); }, create: function (params) { return hostCall('sessions.create', params || {}); }, update: function (sessionId, patch) { return hostCall('sessions.update', { sessionId: sessionId, patch: patch }); }, - delete: function (sessionId) { return hostCall('sessions.delete', { sessionId: sessionId }); } + delete: function (sessionId) { return hostCall('sessions.delete', { sessionId: sessionId }); }, + focus: function (sessionId, tabId) { return hostCall('sessions.focus', { sessionId: sessionId, tabId: tabId }); } }), transcripts: Object.freeze({ read: function (params) { return hostCall('transcripts.read', params); }, diff --git a/src/shared/plugins/permissions.ts b/src/shared/plugins/permissions.ts index 7bf76a1eae..b481e4126f 100644 --- a/src/shared/plugins/permissions.ts +++ b/src/shared/plugins/permissions.ts @@ -40,6 +40,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 @@ -74,6 +75,7 @@ export const PLUGIN_CAPABILITIES: readonly PluginCapability[] = [ 'sessions:read', 'sessions:create', 'sessions:write', + 'sessions:focus', 'history:read', 'transcripts:read', 'transcripts:write', @@ -107,6 +109,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', @@ -166,6 +172,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', @@ -526,6 +533,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': diff --git a/src/shared/plugins/rpc-protocol.ts b/src/shared/plugins/rpc-protocol.ts index b665b8c6fd..1f5cd81d0c 100644 --- a/src/shared/plugins/rpc-protocol.ts +++ b/src/shared/plugins/rpc-protocol.ts @@ -41,6 +41,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' }, From 67da840e01844494138f1675191da21f609db1d7 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 10:46:30 +0200 Subject: [PATCH 13/24] MAESTRO: add full-size modal plugin panels + global summon path Third host-API addition for the agent-flow overlay (Phase 1). - contributions: optional panel `size?: 'default' | 'full'`, parsed leniently (absent is not an error, invalid errors and defaults) so existing manifests are byte-identical in behaviour. - rpc-protocol: `ui.openPanel` / `ui.closePanel` / `ui.togglePanel` under the existing `ui:panel` capability, so no new consent prompt. - plugin-host-handlers: one shared factory for the three verbs - closed {panelId} schema, broker check, own-panel resolution via the same getPanel dep `ui.panelPost` uses, non-modal placements rejected. Registered only when the new panelVisibility sink is wired (fail closed). - main/preload: `plugins:panel-visibility` broadcast + `onPanelVisibility` bridge. Pure show/hide signal, no payload, no reply channel. - renderer: `uiStore.openPluginPanelId` and the single App-level `PluginModalPanelMount`. Settings' launch button now sets the same store field, so the Settings path and a plugin's own summon share one mount and one webview guest. - PluginPanelHost: `full` renders edge-to-edge (inset-4); the dead local Escape handler (onKeyDown on a non-focusable backdrop) is replaced with a layer-stack registration in the reserved plugin band. - Vendored plugin-sdk mirrored by hand (CI does not check that parity). --- packages/plugin-sdk/src/index.ts | 18 ++++++ src/main/index.ts | 7 +++ src/main/plugins/plugin-host-handlers.ts | 51 ++++++++++++++++- src/main/plugins/plugin-sandbox-entry.ts | 3 + src/main/preload/plugins.ts | 25 +++++++++ src/renderer/App.tsx | 4 ++ .../components/Settings/PluginPanelHost.tsx | 31 ++++++---- .../components/Settings/PluginsPanel.tsx | 18 +++--- .../plugins/PluginModalPanelMount.tsx | 56 +++++++++++++++++++ .../__tests__/PluginPanelSlot.test.tsx | 1 + src/renderer/global.d.ts | 7 +++ src/renderer/stores/uiStore.ts | 22 ++++++++ src/shared/plugins/contributions.ts | 33 +++++++++++ src/shared/plugins/rpc-protocol.ts | 3 + 14 files changed, 256 insertions(+), 23 deletions(-) create mode 100644 src/renderer/components/plugins/PluginModalPanelMount.tsx diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 2b5b68f0e1..e121121067 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -885,6 +885,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 { @@ -894,6 +899,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 @@ -1318,6 +1324,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' }, @@ -1558,6 +1567,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/main/index.ts b/src/main/index.ts index 33631dfb45..89c76dcde0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2381,6 +2381,13 @@ app panelPost: (pluginId, panelId, data) => { safeSend('plugins:panel-data', { pluginId, panelId, data }); }, + // ui.openPanel/closePanel/togglePanel: a pure show/hide signal for the + // caller's own modal panel, already resolved and namespaced by the + // handler. The renderer owns the single modal-panel mount, so all main + // does is broadcast the requested action. + panelVisibility: (pluginId, panelId, action) => { + safeSend('plugins:panel-visibility', { pluginId, panelId, action }); + }, listAgents: () => { const sessions = sessionsStore.get('sessions', []) as Array<{ id?: string; diff --git a/src/main/plugins/plugin-host-handlers.ts b/src/main/plugins/plugin-host-handlers.ts index 6ac27bb4f4..544d118ba1 100644 --- a/src/main/plugins/plugin-host-handlers.ts +++ b/src/main/plugins/plugin-host-handlers.ts @@ -17,7 +17,7 @@ import * as fs from 'fs'; import * as path from 'path'; import Database from 'better-sqlite3'; import { logger } from '../utils/logger'; -import type { HostCallHandlers } from './plugin-sandbox-host'; +import type { HostCallHandler, HostCallHandlers } from './plugin-sandbox-host'; import type { PermissionBroker } from './permission-broker'; import type { HostMethod } from '../../shared/plugins/rpc-protocol'; import type { ActionGuard } from './action-guard'; @@ -232,6 +232,15 @@ export interface HostHandlerDeps { * owning panel webview can receive it. Absent means the method is not * registered at all (fail closed). */ panelPost?: (pluginId: string, namespacedPanelId: string, data: unknown) => void; + /** Show/hide sink for a plugin's OWN `modal`-placement panel. Receives the + * already-namespaced panel id and the requested action; broadcasts it to the + * renderer(s), which own the single modal-panel mount. Absent means the + * open/close/toggle methods are not registered at all (fail closed). */ + panelVisibility?: ( + pluginId: string, + namespacedPanelId: string, + action: 'open' | 'close' | 'toggle' + ) => void; /** Read-only agent listing (no secrets): id/name/cwd/toolType only. */ listAgents: () => Array<{ id: string; name: string; cwd?: string; toolType?: string }>; @@ -1352,6 +1361,46 @@ export function buildHostCallHandlers(deps: HostHandlerDeps): HostCallHandlers { }; } + // ui.openPanel / ui.closePanel / ui.togglePanel: let a plugin summon or dismiss + // its OWN modal panel (the hotkey-summoned overlay path). Same own-panels-only + // resolution as ui.panelPost, so a plugin can never open, close, or flicker + // another plugin's surface, and no new consent is needed: anything that can + // contribute a panel at all already holds `ui:panel`. The verbs carry no data - + // they are pure show/hide signals - and are registered only when the sink is + // wired (fail closed). + if (deps.panelVisibility) { + const panelVisibility = deps.panelVisibility; + const makeVisibilityHandler = ( + method: 'ui.openPanel' | 'ui.closePanel' | 'ui.togglePanel', + action: 'open' | 'close' | 'toggle' + ): HostCallHandler => { + return async (pluginId, params) => { + const p = asObject(params); + assertClosedSchema(method, p, { panelId: true }); + const panelId = p.panelId; + if (typeof panelId !== 'string' || panelId.trim() === '' || panelId !== panelId.trim()) { + throw new Error('panelId is required'); + } + assertBrokerAllowed(deps, pluginId, method, p); + const panel = deps.getPanel?.(pluginId, panelId); + if (!panel) { + throw new Error(`panel "${panelId}" is not declared by this plugin`); + } + // Only `modal` panels have a summonable host; docked ones are always + // mounted and have their own hide control, so this would be a no-op the + // plugin could not distinguish from success. + if (panel.placement !== 'modal') { + throw new Error(`panel "${panelId}" is not a modal panel`); + } + panelVisibility(pluginId, `${pluginId}/${panelId}`, action); + return { ok: true }; + }; + }; + handlers['ui.openPanel'] = makeVisibilityHandler('ui.openPanel', 'open'); + handlers['ui.closePanel'] = makeVisibilityHandler('ui.closePanel', 'close'); + handlers['ui.togglePanel'] = makeVisibilityHandler('ui.togglePanel', 'toggle'); + } + if (deps.dispatch) { const dispatch = deps.dispatch; handlers['agents.dispatch'] = async (pluginId, params) => { diff --git a/src/main/plugins/plugin-sandbox-entry.ts b/src/main/plugins/plugin-sandbox-entry.ts index f8966559db..3cc6616c0f 100644 --- a/src/main/plugins/plugin-sandbox-entry.ts +++ b/src/main/plugins/plugin-sandbox-entry.ts @@ -296,6 +296,9 @@ const BOOTSTRAP_SOURCE = String.raw`(function bootstrap(bridge) { remove: function (id) { return hostCall('ui.hostViewRemove', { id: id }); } }), panelPost: function (panelId, data) { return hostCall('ui.panelPost', { panelId: panelId, data: data }); }, + openPanel: function (panelId) { return hostCall('ui.openPanel', { panelId: panelId }); }, + closePanel: function (panelId) { return hostCall('ui.closePanel', { panelId: panelId }); }, + togglePanel: function (panelId) { return hostCall('ui.togglePanel', { panelId: panelId }); }, grouping: Object.freeze({ publish: function (params) { return hostCall('ui.groupingPublish', params); }, clear: function (id) { return hostCall('ui.groupingClear', { id: id }); } diff --git a/src/main/preload/plugins.ts b/src/main/preload/plugins.ts index 454e81425d..b20397cdb8 100644 --- a/src/main/preload/plugins.ts +++ b/src/main/preload/plugins.ts @@ -155,6 +155,31 @@ export function createPluginsApi() { }; }, + /** + * Subscribe to plugin-requested modal-panel show/hide (`ui.openPanel` / + * `ui.closePanel` / `ui.togglePanel`). The main process broadcasts + * `plugins:panel-visibility` with the already-namespaced panel id (resolved + * against the calling plugin's OWN declarations) and the requested action; + * the renderer's single modal-panel mount applies it. Read-only signal - + * there is no reply channel and no payload data. + */ + onPanelVisibility: ( + callback: (payload: { + pluginId: string; + panelId: string; + action: 'open' | 'close' | 'toggle'; + }) => void + ): (() => void) => { + const handler = ( + _event: unknown, + payload: { pluginId: string; panelId: string; action: 'open' | 'close' | 'toggle' } + ): void => callback(payload); + ipcRenderer.on('plugins:panel-visibility', handler); + return () => { + ipcRenderer.removeListener('plugins:panel-visibility', handler); + }; + }, + onGroupingsChanged: (callback: () => void): (() => void) => { const handler = (): void => callback(); ipcRenderer.on('plugins:groupings-changed', handler); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3f1f71cd57..d6a213f168 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -172,6 +172,7 @@ import { InlineWizardProvider, useInlineWizardContext } from './contexts/InlineW import { useQuitWhenIdle } from './hooks/useQuitWhenIdle'; import { usePluginCommandBridge } from './hooks/usePluginCommandBridge'; import { usePluginKeybindings } from './hooks/usePluginKeybindings'; +import { PluginModalPanelMount } from './components/plugins/PluginModalPanelMount'; // Import services // gitService - now used in useModalHandlers (Tier 3C) @@ -2984,6 +2985,9 @@ function MaestroConsoleInner() { {/* Owns Left Bar sort/nav/starred subscriptions; memoized so App wakes do not re-run this host. Must sit under WindowProvider (ownsSession). */} + {/* The ONE mount for modal-placement plugin panels: serves both the + Settings launch button and a plugin summoning its own overlay. */} + Encore -> Plugins). + * Modal host for a plugin-contributed UI panel (the `modal` placement). + * + * Mounted ONCE at App level by `PluginModalPanelMount`, driven by + * `uiStore.openPluginPanelId`. Both entry points converge here: the + * Settings -> Encore -> Plugins launch button and a plugin summoning its own + * panel through `ui.openPanel` / `ui.togglePanel`. * * The isolated panel surface (a per-plugin-partition , hardened in the * main process: no Node, contextIsolation, broker-only preload, nav/egress @@ -8,13 +12,20 @@ * provenance line all live in the shared `PluginPanelFrame` (the ONE place a * panel renders). This component only supplies the modal chrome (backdrop, * title bar, close affordance) around that frame. + * + * Chrome size follows the panel's `size` contribution: `default` is the historic + * fixed dialog, `full` an edge-to-edge overlay for summonable mission-control + * surfaces. Escape goes through the layer stack (in the reserved plugin band, so + * a first-party modal above it still takes Escape first) rather than a local key + * handler, which never fired on this non-focusable backdrop. */ -import { useCallback } from 'react'; import { X } from 'lucide-react'; import type { Theme } from '../../types'; import type { PanelContribution } from '../../../shared/plugins/contributions'; import { PluginPanelFrame } from '../plugins/PluginPanelFrame'; +import { useModalLayer } from '../../hooks/ui/useModalLayer'; +import { pluginPanelPriority } from '../../constants/modalPriorities'; interface PluginPanelHostProps { theme: Theme; @@ -23,23 +34,21 @@ interface PluginPanelHostProps { } export function PluginPanelHost({ theme, panel, onClose }: PluginPanelHostProps) { - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, - [onClose] - ); + useModalLayer(pluginPanelPriority(0), panel.title, onClose); + + const isFull = panel.size === 'full'; return (
e.stopPropagation()} > diff --git a/src/renderer/components/Settings/PluginsPanel.tsx b/src/renderer/components/Settings/PluginsPanel.tsx index 6924b26e28..b1a1d9da72 100644 --- a/src/renderer/components/Settings/PluginsPanel.tsx +++ b/src/renderer/components/Settings/PluginsPanel.tsx @@ -22,12 +22,9 @@ import { import type { Theme } from '../../types'; import type { PluginListSnapshot } from '../../../main/ipc/handlers/plugins'; import type { PluginRecord } from '../../../shared/plugins/plugin-registry'; -import type { - AggregatedContributions, - PanelContribution, -} from '../../../shared/plugins/contributions'; +import type { AggregatedContributions } from '../../../shared/plugins/contributions'; import { notifyToast } from '../../stores/notificationStore'; -import { PluginPanelHost } from './PluginPanelHost'; +import { useUIStore } from '../../stores/uiStore'; import { PluginActivityView } from './PluginActivityView'; interface PluginsPanelProps { @@ -47,7 +44,10 @@ export function PluginsPanel({ theme }: PluginsPanelProps) { const [loading, setLoading] = useState(false); const [busyId, setBusyId] = useState(null); const [contributions, setContributions] = useState(null); - const [openPanel, setOpenPanel] = useState(null); + // The modal panel host is mounted ONCE at App level (PluginModalPanelMount); + // this launch button just names the panel to open, so the Settings path and a + // plugin's own `ui.openPanel` summon share one mount and one webview guest. + const setOpenPluginPanelId = useUIStore((s) => s.setOpenPluginPanelId); const load = useCallback(async () => { setLoading(true); @@ -383,7 +383,7 @@ export function PluginsPanel({ theme }: PluginsPanelProps) { backgroundColor: theme.colors.accent + '18', color: theme.colors.accent, }} - onClick={() => setOpenPanel(panel)} + onClick={() => setOpenPluginPanelId(panel.id)} title={`Open ${panel.title}`} > @@ -401,10 +401,6 @@ export function PluginsPanel({ theme }: PluginsPanelProps) { {/* Read-only per-plugin observability for running tier-1 plugins. */} - - {openPanel && ( - setOpenPanel(null)} /> - )}
); } diff --git a/src/renderer/components/plugins/PluginModalPanelMount.tsx b/src/renderer/components/plugins/PluginModalPanelMount.tsx new file mode 100644 index 0000000000..cc6b638445 --- /dev/null +++ b/src/renderer/components/plugins/PluginModalPanelMount.tsx @@ -0,0 +1,56 @@ +/** + * The single, app-level mount for `modal`-placement plugin panels. + * + * Which panel is open (if any) lives in `uiStore.openPluginPanelId` as a + * namespaced `/`. Two paths write it and they converge here + * so only one webview guest ever exists for a panel: + * - Settings -> Encore -> Plugins launch button (sets the store field). + * - A plugin summoning its OWN panel via `ui.openPanel` / `ui.closePanel` / + * `ui.togglePanel`, which main broadcasts on `plugins:panel-visibility` + * (already own-panel-resolved and namespaced host-side). + * + * Renders nothing when no panel is open, when the `plugins` Encore flag is off + * (then `usePluginContributions` returns empty buckets), or when the open id no + * longer resolves to a live panel - so uninstalling or disabling a plugin with + * its overlay up cleanly drops the overlay instead of stranding it. Resolution + * is by id alone: the Settings launch button has always been able to pop a + * DOCKED panel into this host too, and the modal-only restriction belongs on + * the `ui.*Panel` verbs (where it is enforced) rather than here. + */ + +import { useEffect, useMemo } from 'react'; +import type { Theme } from '../../types'; +import { usePluginContributions } from '../../hooks/usePluginContributions'; +import { useUIStore } from '../../stores/uiStore'; +import { PluginPanelHost } from '../Settings/PluginPanelHost'; + +export function PluginModalPanelMount({ theme }: { theme: Theme }) { + const contributions = usePluginContributions(); + const openPluginPanelId = useUIStore((s) => s.openPluginPanelId); + const setOpenPluginPanelId = useUIStore((s) => s.setOpenPluginPanelId); + const toggleOpenPluginPanelId = useUIStore((s) => s.toggleOpenPluginPanelId); + + useEffect(() => { + const plugins = window.maestro?.plugins; + if (!plugins?.onPanelVisibility) return; + return plugins.onPanelVisibility(({ panelId, action }) => { + if (action === 'open') setOpenPluginPanelId(panelId); + else if (action === 'toggle') toggleOpenPluginPanelId(panelId); + // `close` only ever closes the plugin's OWN panel, never whatever else + // happens to be open. + else if (useUIStore.getState().openPluginPanelId === panelId) setOpenPluginPanelId(null); + }); + }, [setOpenPluginPanelId, toggleOpenPluginPanelId]); + + const panel = useMemo( + () => + openPluginPanelId + ? (contributions.panels.find((p) => p.id === openPluginPanelId) ?? null) + : null, + [contributions.panels, openPluginPanelId] + ); + + if (!panel) return null; + + return setOpenPluginPanelId(null)} />; +} diff --git a/src/renderer/components/plugins/__tests__/PluginPanelSlot.test.tsx b/src/renderer/components/plugins/__tests__/PluginPanelSlot.test.tsx index 85b76717d8..ee3b1107dc 100644 --- a/src/renderer/components/plugins/__tests__/PluginPanelSlot.test.tsx +++ b/src/renderer/components/plugins/__tests__/PluginPanelSlot.test.tsx @@ -37,6 +37,7 @@ function panel(over: Partial = {}): PanelContribution { title: 'Acme Board', entry: 'board.html', placement: 'left', + size: 'default', ...over, }; } diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index df8a05a297..6a376bda8c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -3990,6 +3990,13 @@ interface MaestroAPI { onPanelData: ( callback: (payload: { pluginId: string; panelId: string; data: unknown }) => void ) => () => void; + onPanelVisibility: ( + callback: (payload: { + pluginId: string; + panelId: string; + action: 'open' | 'close' | 'toggle'; + }) => void + ) => () => void; onRunUiCommand: ( callback: (commandId: string, args: unknown) => boolean | Promise ) => () => void; diff --git a/src/renderer/stores/uiStore.ts b/src/renderer/stores/uiStore.ts index a3493123fd..871818c0a9 100644 --- a/src/renderer/stores/uiStore.ts +++ b/src/renderer/stores/uiStore.ts @@ -137,6 +137,14 @@ export interface UIStoreState { // settings write-through (mirrors hiddenQuotaAccounts) and hydrated by // loadAllSettings on startup. hiddenPluginPanels: string[]; + + // Namespaced id (`/`) of the ONE `modal`-placement plugin + // panel currently open, or null. Deliberately global rather than local to + // Settings: the same mount serves the Settings -> Encore -> Plugins launch + // path and a plugin summoning its own panel via `ui.openPanel`/`togglePanel`, + // so the two can never fight over the panel's webview guest. Transient (not + // persisted) - a summoned overlay should not survive a restart. + openPluginPanelId: string | null; } export interface UIStoreActions { @@ -231,6 +239,11 @@ export interface UIStoreActions { // Toggle a docked plugin panel between shown and collapsed (reopen rail). toggleHiddenPluginPanel: (panelId: string) => void; + + /** Open (or, with null, close) the single modal plugin-panel mount. */ + setOpenPluginPanelId: (panelId: string | null) => void; + /** Open the panel, or close it if that same panel is already open. */ + toggleOpenPluginPanelId: (panelId: string) => void; } export type UIStore = UIStoreState & UIStoreActions; @@ -332,6 +345,7 @@ export const useUIStore = create()((set) => ({ hiddenQuotaAccounts: {}, usageRefreshIntervals: {}, hiddenPluginPanels: [], + openPluginPanelId: null, // --- Actions --- setLeftSidebarOpen: (v) => set((s) => ({ leftSidebarOpen: resolve(v, s.leftSidebarOpen) })), @@ -462,4 +476,12 @@ export const useUIStore = create()((set) => ({ persistHiddenPluginPanels(next); return { hiddenPluginPanels: next }; }), + + setOpenPluginPanelId: (panelId) => set({ openPluginPanelId: panelId }), + + // Toggle by namespaced id: open it, or close it if that exact panel is already + // the open one. A DIFFERENT panel being open swaps to the requested one rather + // than closing, since only one modal panel mount exists. + toggleOpenPluginPanelId: (panelId) => + set((s) => ({ openPluginPanelId: s.openPluginPanelId === panelId ? null : panelId })), })); diff --git a/src/shared/plugins/contributions.ts b/src/shared/plugins/contributions.ts index 11ee348877..ef193ea799 100644 --- a/src/shared/plugins/contributions.ts +++ b/src/shared/plugins/contributions.ts @@ -180,6 +180,15 @@ export interface CommandContribution { * UI slot via the contribution registry. */ export type PanelPlacement = 'modal' | 'left' | 'right' | 'main' | 'settings'; +/** + * How much room a `modal`-placement panel takes. `default` is the historic fixed + * dialog chrome; `full` renders edge-to-edge (a summonable full-window overlay). + * Presentation only - it never changes WHERE a panel routes, which is why it is + * a separate optional field rather than a sixth `PanelPlacement` value that every + * routing switch would have to grow a case for. Ignored by docked placements. + */ +export type PanelSize = 'default' | 'full'; + /** * A UI panel a (tier-1) plugin contributes. Rendered in a locked-down sandboxed * iframe (no same-origin, no top navigation) in the reserved plugin modal band, @@ -195,6 +204,8 @@ export interface PanelContribution { entry: string; /** Where the panel docks. Defaults to `modal`. */ placement: PanelPlacement; + /** Chrome size for `modal` panels. Defaults to `default`. */ + size: PanelSize; } /** @@ -1423,6 +1434,26 @@ function parsePanelPlacement( return 'modal'; } +const PANEL_SIZES: readonly PanelSize[] = ['default', 'full']; + +/** Parse an optional panel size, defaulting to `default`; an invalid value is an + * error but never drops the panel (it renders at the safe default size). Absent + * is NOT an error, so every manifest written before this field behaves exactly + * as it did - which matters because bundled plugins are signature-pinned. */ +function parsePanelSize( + pluginId: string, + localId: string, + raw: unknown, + errors: string[] +): PanelSize { + if (raw === undefined) return 'default'; + if (typeof raw === 'string' && (PANEL_SIZES as readonly string[]).includes(raw)) { + return raw as PanelSize; + } + errors.push(`[${pluginId}] panel "${localId}" has an invalid size; defaulting to default`); + return 'default'; +} + function parsePanel(pluginId: string, raw: unknown, errors: string[]): PanelContribution | null { if (!isPlainObject(raw)) { errors.push(`[${pluginId}] a panel contribution is not an object`); @@ -1439,6 +1470,7 @@ function parsePanel(pluginId: string, raw: unknown, errors: string[]): PanelCont return null; } const placement = parsePanelPlacement(pluginId, localId, raw.placement, errors); + const size = parsePanelSize(pluginId, localId, raw.size, errors); return { id: namespaced(pluginId, localId), localId, @@ -1446,6 +1478,7 @@ function parsePanel(pluginId: string, raw: unknown, errors: string[]): PanelCont title: raw.title.trim(), entry: raw.entry.trim(), placement, + size, }; } diff --git a/src/shared/plugins/rpc-protocol.ts b/src/shared/plugins/rpc-protocol.ts index 1f5cd81d0c..6310664e3d 100644 --- a/src/shared/plugins/rpc-protocol.ts +++ b/src/shared/plugins/rpc-protocol.ts @@ -56,6 +56,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' }, From 72aeeff338d095f13a97ff6971e9e71fc9380174 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 10:55:58 +0200 Subject: [PATCH 14/24] MAESTRO: bump plugin host API to 1.16.0 Covers the three Phase 1 agent-flow overlay additions: the metadata-only session.activated event, the sessions.focus verb plus its narrow sessions:focus capability, and ui.openPanel/closePanel/togglePanel plus the optional panel size field. 1.15.0 is taken by the Board + Profiles work on this fork, so it is skipped. Mirrors the constant and comment into the vendored plugin-sdk (package 0.9.0 -> 0.11.0; 0.10.0 is also taken by Board + Profiles), moves the drift-guard pin, and lands the doc rows deferred from the three host-API tasks in PLUGIN-DEVELOPMENT.md and CLAUDE-PLUGINS.md. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE-PLUGINS.md | 18 ++-- docs/agent-guides/PLUGIN-DEVELOPMENT.md | 90 ++++++++++++------- packages/plugin-sdk/package.json | 4 +- .../plugin-sdk/src/__tests__/drift.test.ts | 4 +- packages/plugin-sdk/src/index.ts | 13 ++- src/shared/plugins/host-api.ts | 13 ++- 6 files changed, 95 insertions(+), 47 deletions(-) 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..b9462e2cad 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`; an unknown value reports a manifest error and falls back to `default` rather than dropping the panel). `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/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 e121121067..1591ee070d 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -419,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 @@ -441,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 { diff --git a/src/shared/plugins/host-api.ts b/src/shared/plugins/host-api.ts index 52223497d3..331918d4c7 100644 --- a/src/shared/plugins/host-api.ts +++ b/src/shared/plugins/host-api.ts @@ -21,7 +21,16 @@ import semver from 'semver'; /** - * 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 @@ -43,7 +52,7 @@ import semver from 'semver'; * `ui:contribute` / `ui:panel` / `ui:render-unsafe` UI capabilities; 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 { From fc2af682267013830bb8281264863c2e5c3ad4cb Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 10:58:42 +0200 Subject: [PATCH 15/24] MAESTRO: retarget agent-flow panel to a full-window summonable overlay Manifest-only change: the flow panel moves from placement 'right' to placement 'modal' + size 'full', gains an 'overlay' command bound to Alt+Shift+F, and requests sessions:focus for click-to-jump. minHostApi tracks the new 1.16.0 host surface. Alt+Shift+F rather than Ctrl+Shift+F: the latter is the app's Go to Files shortcut and usePluginKeybindings folds Ctrl into meta, so it would never have fired. Co-Authored-By: Claude Opus 5 (1M context) --- examples/plugins/agent-flow/plugin.json | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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" + } ] } } From 849f751ff3219ff340e9b34b6f0f27dfb38e6780 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 11:06:21 +0200 Subject: [PATCH 16/24] MAESTRO: wire agent-flow sandbox to the overlay (toggle, focus, jump) Add the overlay command (ui.togglePanel on the plugin's own flow panel), track the focused agent from the metadata-only session.activated topic and ship it in snapshots as focusedSessionId, and handle the panel-posted jump message by calling sessions.focus. jump stays out of contributes.commands: it is meaningless without args, and the sandbox dispatches registered handlers without a manifest cross-check. Co-Authored-By: Claude Opus 5 (1M context) --- examples/plugins/agent-flow/main.js | 66 +++++++- src/__tests__/plugins/agent-flow-main.test.ts | 149 ++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/plugins/agent-flow-main.test.ts diff --git a/examples/plugins/agent-flow/main.js b/examples/plugins/agent-flow/main.js index b418e6f45a..ed292f900f 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,28 @@ 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 () { + return maestro.sessions.focus(args.sessionId, tabId); + }); + }); + // The contributed "clear" command resets the whole graph. maestro.commands.register('clear', function () { resetModel(); @@ -558,6 +621,7 @@ function deactivate() { snapshotTimer = 0; } resetModel(); + focusedSessionId = ''; sdk = null; } 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..8ed4c57ec8 --- /dev/null +++ b/src/__tests__/plugins/agent-flow-main.test.ts @@ -0,0 +1,149 @@ +/** + * 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; + 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 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 }, + 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, + 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('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'); + }); +}); From 8ca5769868bdefe8b22aaf315ffd23d91709656f Mon Sep 17 00:00:00 2001 From: chr1syy Date: Tue, 28 Jul 2026 11:15:51 +0200 Subject: [PATCH 17/24] MAESTRO: render agent-flow as a shared-canvas overlay Replace the per-session lane columns in the agent-flow panel with one node per agent on a single canvas: status-coloured pulsing halo, cost pill and token bar, up to six orbiting tool satellites (name + phase + duration only), and click-to-jump on a node or a finished card. Pan/zoom with auto-fit until the user moves the view. Adds a jsdom test that loads the real panel.html and drives it through the host's message bridge, and refreshes the plugin README. --- examples/plugins/agent-flow/README.md | 148 ++- examples/plugins/agent-flow/panel.html | 1127 +++++++---------- .../plugins/agent-flow-panel.test.ts | 275 ++++ vitest.config.mts | 3 + 4 files changed, 840 insertions(+), 713 deletions(-) create mode 100644 src/__tests__/plugins/agent-flow-panel.test.ts 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/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/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]*?)