Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 135 additions & 4 deletions src/core/maestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +47 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Throw on parsed dispatch error envelopes.

dispatch() currently resolves with { success: false, error, code } from both the normal parse path and the stdout: fallback path. That breaks the wrapper contract the PR describes and makes a failed dispatch look like a successful await, leaving consumers with nullable IDs on the resolved path.

💡 Suggested fix
 export interface DispatchResult {
-  success: boolean;
-  agentId?: string;
+  success: true;
+  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;
+  sessionId: string | null;
+  tabId: string | null;
 }
@@
   async dispatch(
@@
   ): Promise<DispatchResult> {
@@
     try {
       const raw = await runSpawn(args);
-      return JSON.parse(raw) as DispatchResult;
+      const parsed = JSON.parse(raw) as
+        | DispatchResult
+        | { success: false; error?: string; code?: string };
+      if (parsed.success === false) {
+        throw new Error(`dispatch failed: ${parsed.error ?? 'unknown'} (${parsed.code ?? 'UNKNOWN'})`);
+      }
+      return parsed;
     } catch (err: unknown) {
@@
       if (stdoutMatch) {
         try {
-          return JSON.parse(stdoutMatch[1]) as DispatchResult;
+          const parsed = JSON.parse(stdoutMatch[1]) as
+            | DispatchResult
+            | { success: false; error?: string; code?: string };
+          if (parsed.success === false) {
+            throw new Error(`dispatch failed: ${parsed.error ?? 'unknown'} (${parsed.code ?? 'UNKNOWN'})`);
+          }
+          return parsed;
         } catch {
           /* fall through */
         }
       }
       throw err;

Also applies to: 371-385

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/maestro.ts` around lines 47 - 56, The DispatchResult path currently
returns { success: false, error, code } for parsed error envelopes which makes
await callers treat failures as fulfilled; update the dispatch function so that
after parsing the dispatch envelope (the branch that currently produces
DispatchResult) you detect error/code fields and throw an Error (or a
specialized exception carrying error/code) instead of returning success: false,
and apply the same behavior to the stdout: fallback path so both parsed and
fallback error envelopes reject; keep returning DispatchResult only for truly
successful envelopes (including agentId/sessionId/tabId). Also mirror this
change at the other dispatch handling block referenced (lines around 371-385) so
both code paths consistently throw on parsed errors.


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;
Expand Down Expand Up @@ -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<SendResult> {
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);
Expand All @@ -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<DispatchResult> {
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<DesktopSessionEntry[]> {
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<SessionHistory> {
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<MaestroPlaybook[]> {
const args = ['list', 'playbooks', '--json'];
Expand Down
18 changes: 10 additions & 8 deletions src/core/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down