From ac3ecef25fd94351e2ee8aa677b397dc9bfad78f Mon Sep 17 00:00:00 2001 From: chr1syy Date: Sun, 31 May 2026 10:58:37 +0200 Subject: [PATCH] feat(maestro): wrap dispatch, session list/show, send --tab/--no-system-prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds wrappers for the four CLI surfaces that landed in upstream Maestro since the kernel refactor and that target relay-style external consumers: - maestro.dispatch(agentId, message, { newTab?, tabId?, force? }) — hand a prompt to the desktop app and return { agentId, sessionId, tabId } so callers can address the same tab on follow-up calls. - maestro.sessionList() — enumerate every open AI tab across every agent in the running desktop. - maestro.sessionShow(tabId, { since?, tail? }) — fetch a tab's conversation history; --since auto-detects ISO-8601 vs epoch ms/sec. - maestro.send() options bag now exposes openTab (-t, focus the tab after delivery) and noSystemPrompt (--no-system-prompt, opt out of the Maestro system context the CLI appends by default). send()'s positional signature is folded into an options bag — the queue is the only caller, updated in the same commit. Defaults preserve current behavior: system prompt included, tab not opened. Error envelopes from the CLI ({ success: false, error, code }) are unwrapped into native Errors so the new methods throw with the same ergonomics as the existing wrappers. dispatch reuses send's stdout-recovery fall-through for the non-zero-exit-with-JSON case. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/maestro.ts | 139 ++++++++++++++++++++++++++++++++++++++++++-- src/core/queue.ts | 18 +++--- 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/core/maestro.ts b/src/core/maestro.ts index f584d5c..d748475 100644 --- a/src/core/maestro.ts +++ b/src/core/maestro.ts @@ -44,6 +44,48 @@ export interface SendResult { }; } +export interface DispatchResult { + success: boolean; + agentId?: string; + /** Tab id the prompt was delivered to. Identical to `tabId` — the CLI emits + * both keys so polling consumers can use either. */ + sessionId?: string | null; + tabId?: string | null; + error?: string; + code?: string; +} + +export interface DesktopSessionEntry { + tabId: string; + sessionId: string; + agentId: string; + agentName: string; + toolType: string; + name: string | null; + agentSessionId: string | null; + state: 'idle' | 'busy'; + createdAt: number; + starred: boolean; +} + +export interface SessionHistoryMessage { + id: string; + role: string; + source: string; + content: string; + /** ISO-8601 — round-trip directly into `sessionShow({ since })`. */ + timestamp: string; +} + +export interface SessionHistory { + success: true; + tabId: string; + sessionId: string; + agentId: string; + agentSessionId: string | null; + messages: SessionHistoryMessage[]; +} + export interface MaestroPlaybook { id: string; name: string; @@ -271,12 +313,23 @@ export const maestro = { async send( agentId: string, message: string, - sessionId?: string, - readOnly?: boolean, + opts: { + sessionId?: string; + readOnly?: boolean; + openTab?: boolean; + /** + * Opt out of the Maestro system prompt that `maestro-cli send` appends by + * default (agent identity, git branch, history file, conductor profile). + * Leave undefined/false to match the CLI default. + */ + noSystemPrompt?: boolean; + } = {}, ): Promise { const args = ['send']; - if (sessionId) args.push('-s', sessionId); - if (readOnly) args.push('-r'); + if (opts.sessionId) args.push('-s', opts.sessionId); + if (opts.readOnly) args.push('-r'); + if (opts.openTab) args.push('-t'); + if (opts.noSystemPrompt) args.push('--no-system-prompt'); args.push(agentId, '--', message); try { const raw = await runSpawn(args); @@ -297,6 +350,84 @@ export const maestro = { } }, + /** + * Hand a prompt off to the Maestro desktop app and return the tab/session id + * the prompt was delivered to. Pair with `sessionShow` to poll the + * conversation without owning a persistent channel. + */ + async dispatch( + agentId: string, + message: string, + opts: { newTab?: boolean; tabId?: string; force?: boolean } = {}, + ): Promise { + if (opts.newTab && opts.tabId) { + throw new Error('dispatch: --new-tab cannot be combined with --tab'); + } + const args = ['dispatch']; + if (opts.newTab) args.push('--new-tab'); + if (opts.tabId) args.push('-t', opts.tabId); + if (opts.force) args.push('-f'); + args.push(agentId, '--', message); + try { + const raw = await runSpawn(args); + return JSON.parse(raw) as DispatchResult; + } catch (err: unknown) { + // CLI exits non-zero on error but still emits a JSON error shape on stdout. + const errMsg = err instanceof Error ? err.message : String(err); + const stdoutMatch = errMsg.match(/stdout: ({[\s\S]*})/); + if (stdoutMatch) { + try { + return JSON.parse(stdoutMatch[1]) as DispatchResult; + } catch { + /* fall through */ + } + } + throw err; + } + }, + + /** List every open AI tab across every agent in the running Maestro desktop. */ + async sessionList(): Promise { + const raw = await run(['session', 'list', '--json']); + const parsed = JSON.parse(raw) as { + success?: boolean; + sessions?: DesktopSessionEntry[]; + error?: string; + code?: string; + }; + if (parsed.success === false) { + throw new Error( + `session list failed: ${parsed.error ?? 'unknown'} (${parsed.code ?? 'UNKNOWN'})`, + ); + } + return parsed.sessions ?? []; + }, + + /** + * Fetch conversation history for a desktop tab. `since` accepts ISO-8601 or + * epoch ms/sec (auto-detected by magnitude), so a previous response's + * `messages[].timestamp` round-trips directly. + */ + async sessionShow( + tabId: string, + opts: { since?: string | number; tail?: number } = {}, + ): Promise { + const args = ['session', 'show', tabId, '--json']; + if (opts.since != null) args.push('--since', String(opts.since)); + if (opts.tail != null) args.push('--tail', String(opts.tail)); + const raw = await run(args); + const parsed = JSON.parse(raw) as + | SessionHistory + | { success: false; error?: string; code?: string }; + if (parsed.success === false) { + const err = parsed as { error?: string; code?: string }; + throw new Error( + `session show failed: ${err.error ?? 'unknown'} (${err.code ?? 'UNKNOWN'})`, + ); + } + return parsed; + }, + /** List all playbooks, optionally filtered by agent */ async listPlaybooks(agentId?: string): Promise { const args = ['list', 'playbooks', '--json']; diff --git a/src/core/queue.ts b/src/core/queue.ts index db5f2ef..50adc51 100644 --- a/src/core/queue.ts +++ b/src/core/queue.ts @@ -21,8 +21,12 @@ export type QueueDeps = { send: ( agentId: string, message: string, - sessionId?: string, - readOnly?: boolean, + opts?: { + sessionId?: string; + readOnly?: boolean; + openTab?: boolean; + noSystemPrompt?: boolean; + }, ) => Promise<{ success: boolean; response: string | null; @@ -163,12 +167,10 @@ export function createQueue(deps: QueueDeps) { const fullMessage = [options?.contentOverride ?? message.content, attachmentRefs] .filter(Boolean) .join('\n\n'); - const result = await deps.maestro.send( - conv.agentId, - fullMessage, - conv.sessionId ?? undefined, - conv.readOnly, - ); + const result = await deps.maestro.send(conv.agentId, fullMessage, { + sessionId: conv.sessionId ?? undefined, + readOnly: conv.readOnly, + }); if (!conv.sessionId && result.sessionId) { conv.persistSession(result.sessionId);