From b7b72b7c9963ed84696de77a1213fc617483b40f Mon Sep 17 00:00:00 2001 From: Bruno Passos Date: Wed, 5 Aug 2026 20:16:20 -0300 Subject: [PATCH] feat(exec): add --output-format json to surface the session ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--exec` accepts `--resume `, but exec mode never writes the session ID anywhere a caller can read it — only `session.assistantReply` reaches stdout. A script that runs `deepcode --exec` therefore has no way to obtain the ID it needs to resume, so `--resume` is unreachable from the non-interactive path and every exec run is effectively single-shot. This adds an opt-in `--output-format json` flag that emits newline-delimited JSON events instead of the plain assistant reply. The shape follows Claude Code's `--output-format stream-json` convention — one object per line, each with a `type` discriminator and `session_id` — so existing headless agent tooling can consume Deep Code without a bespoke parser: {"type":"system","subtype":"init","session_id":"...","cwd":...,"model":...} {"type":"assistant","session_id":"...","message":{...}} {"type":"result","subtype":"success","is_error":false,"session_id":"...",...} Exactly one `init` event is emitted first and one `result` event last. A fresh session mints its ID inside handleUserPrompt, so `init` is emitted as soon as the first streamed message reports it — before the turn completes, which is what lets a caller capture the ID for a later `--resume`. The default remains `text`, so existing behaviour and exit codes are unchanged, and stderr diagnostics are left exactly as they were. Co-Authored-By: Claude Opus 5 --- packages/cli/src/cli-args.ts | 21 +++ packages/cli/src/cli.tsx | 1 + packages/cli/src/exec-json-output.ts | 171 +++++++++++++++++++++ packages/cli/src/exec-runner.ts | 64 ++++++-- packages/cli/src/tests/cli-args.test.ts | 40 +++++ packages/cli/src/tests/exec-runner.test.ts | 155 +++++++++++++++++++ 6 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/exec-json-output.ts diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 3e0949e5..106238ea 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -8,6 +8,7 @@ import Yargs from "yargs"; import { getCliVersion } from "./utils/version"; import { writeStderrLine } from "./utils/stdio-helpers"; import { hideBin } from "yargs/helpers"; +import { EXEC_OUTPUT_FORMATS, isExecOutputFormat, type ExecOutputFormat } from "./exec-json-output"; // UUID v4 regex pattern for validation const SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -39,6 +40,8 @@ export interface ParsedCliArgs { help: boolean; /** True when --last / -l was passed (resume the most recent session for the current project) */ last: boolean; + /** Output format for --exec: "text" (default) or "json" (newline-delimited events). */ + outputFormat: ExecOutputFormat; } const EPILOG = [ @@ -111,6 +114,13 @@ async function configureYargs(argv?: string[]) { default: false, describe: "Resume the most recent session for the current project directory.", }) + .option("output-format", { + type: "string", + choices: EXEC_OUTPUT_FORMATS, + default: "text", + describe: + "Output format for --exec: text prints the assistant reply, json emits newline-delimited events including the session ID", + }) .check((argv: { [x: string]: unknown }) => { const query = argv["query"] as string | string[] | undefined; const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; @@ -151,12 +161,22 @@ async function configureYargs(argv?: string[]) { if (exec && argv["resume"] === "") { return "--exec cannot use --resume without a session ID.\nUse --exec --resume --prompt ."; } + // `--help` runs .check() before defaults are applied, so treat an + // absent value as the "text" default rather than a conflict. + const outputFormat = argv["output-format"]; + if (outputFormat !== undefined && outputFormat !== "text" && !exec) { + return "--output-format only applies to --exec / -x."; + } return true; }) ) .example("deepcode", "Launch the interactive TUI in the current directory") .example("deepcode -p ", "Launch the TUI and submit a prompt") .example("deepcode -x -p ", "Run one prompt without launching the TUI") + .example( + "deepcode -x -p --output-format json", + "Emit newline-delimited JSON events, including the session ID for --resume" + ) .example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker") .example("deepcode -f, --fork [sessionId]", "Fork a session or the most recent session") .example('cat error.log | deepcode -x -p "Explain this error"', "Use piped stdin as additional context") @@ -217,5 +237,6 @@ export async function parseArguments(argv?: string[]): Promise { version: parsed.version === true, help: parsed.help === true, last: parsed.last === true, + outputFormat: isExecOutputFormat(parsed["output-format"]) ? parsed["output-format"] : "text", }; } diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 552e4a23..bea3774a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -76,6 +76,7 @@ async function main(): Promise { projectRoot, resumeSessionId: typeof resumeSessionId === "string" ? resumeSessionId : undefined, forkSessionId: typeof forkSessionId === "string" ? forkSessionId : undefined, + outputFormat: parsed.outputFormat, }); return; } diff --git a/packages/cli/src/exec-json-output.ts b/packages/cli/src/exec-json-output.ts new file mode 100644 index 00000000..f5286449 --- /dev/null +++ b/packages/cli/src/exec-json-output.ts @@ -0,0 +1,171 @@ +/** + * Newline-delimited JSON output for `--exec` mode. + * + * The event shape deliberately mirrors Claude Code's `--output-format + * stream-json` convention — one JSON object per line, each carrying a `type` + * discriminator and the `session_id` — so tooling that already drives headless + * coding agents can consume Deep Code without a bespoke parser. + * + * Every run emits exactly one `system`/`init` event first and exactly one + * `result` event last, with zero or more message events in between. Fields + * that have no Deep Code equivalent are omitted rather than invented. + */ + +import type { ModelUsage, SessionMessage, SessionStatus } from "@vegamo/deepcode-core"; + +export const EXEC_OUTPUT_FORMATS = ["text", "json"] as const; + +export type ExecOutputFormat = (typeof EXEC_OUTPUT_FORMATS)[number]; + +export function isExecOutputFormat(value: unknown): value is ExecOutputFormat { + return typeof value === "string" && (EXEC_OUTPUT_FORMATS as readonly string[]).includes(value); +} + +/** Why the run ended. `success` is the only non-error outcome. */ +export type ExecResultSubtype = "success" | "error" | "interrupted" | "permission_required" | "input_required"; + +export interface ExecJsonContext { + cwd: string; + model: string; + permissionMode: string; + mcpServers: string[]; + resumedFrom?: string; + forkedFrom?: string; +} + +export interface ExecResultInput { + subtype: ExecResultSubtype; + /** Assistant reply for a successful turn. */ + result?: string | null; + /** Human-readable failure reason, mirroring what goes to stderr. */ + error?: string; + /** Deep Code's own session status, passed through verbatim when known. */ + status?: SessionStatus; + usage?: ModelUsage | null; +} + +/** + * Serializes exec-mode progress as newline-delimited JSON. + * + * The emitter owns the "init is always first" invariant: the session id for a + * fresh session is minted inside `handleUserPrompt`, so `noteSessionId` is + * driven by the first streamed message. That still reaches stdout before the + * turn completes, which is what lets a caller capture the id for a later + * `--resume`. + */ +export class ExecJsonEmitter { + private readonly startedAt = Date.now(); + private sessionId: string | null = null; + private initEmitted = false; + + constructor( + private readonly write: (line: string) => void, + private readonly context: ExecJsonContext + ) {} + + /** Records the session id, emitting the `init` event the first time one is known. */ + noteSessionId(sessionId: string | null | undefined): void { + if (!sessionId) { + return; + } + this.sessionId = sessionId; + this.emitInit(); + } + + emitMessage(message: SessionMessage): void { + this.noteSessionId(message.sessionId); + this.emitInit(); + const payload: Record = { + type: message.role, + session_id: this.sessionId, + message: { + id: message.id, + role: message.role, + content: message.content, + visible: message.visible, + create_time: message.createTime, + content_params: message.contentParams ?? null, + message_params: message.messageParams ?? null, + }, + }; + // `system` is also the init event's type, so keep the two distinguishable. + if (message.role === "system") { + payload.subtype = "message"; + } + this.emit(payload, () => ({ + type: message.role, + session_id: this.sessionId, + message: { + id: message.id, + role: message.role, + content: message.content, + visible: message.visible, + create_time: message.createTime, + }, + })); + } + + emitResult(input: ExecResultInput): void { + this.emitInit(); + const payload: Record = { + type: "result", + subtype: input.subtype, + is_error: input.subtype !== "success", + session_id: this.sessionId, + result: input.result ?? "", + duration_ms: Date.now() - this.startedAt, + }; + if (input.error) { + payload.error = input.error; + } + if (input.status) { + payload.status = input.status; + } + if (input.usage) { + payload.usage = input.usage; + } + this.emit(payload); + } + + private emitInit(): void { + if (this.initEmitted) { + return; + } + this.initEmitted = true; + const payload: Record = { + type: "system", + subtype: "init", + session_id: this.sessionId, + cwd: this.context.cwd, + model: this.context.model, + permission_mode: this.context.permissionMode, + mcp_servers: this.context.mcpServers, + }; + if (this.context.resumedFrom) { + payload.resumed_from = this.context.resumedFrom; + } + if (this.context.forkedFrom) { + payload.forked_from = this.context.forkedFrom; + } + this.emit(payload); + } + + /** + * Writes one JSON line. Tool payloads reach us as `unknown`, so a value that + * cannot be serialized falls back to a reduced payload rather than throwing + * and taking the whole run down. + */ + private emit(payload: Record, fallback?: () => Record): void { + let line: string; + try { + line = JSON.stringify(payload); + } catch { + try { + line = JSON.stringify(fallback ? fallback() : { type: payload.type, session_id: this.sessionId }); + } catch { + return; + } + } + this.write(line); + } +} diff --git a/packages/cli/src/exec-runner.ts b/packages/cli/src/exec-runner.ts index 175f4ce5..9c5a3adf 100644 --- a/packages/cli/src/exec-runner.ts +++ b/packages/cli/src/exec-runner.ts @@ -8,6 +8,7 @@ import { type SessionManagerOptions, } from "@vegamo/deepcode-core"; import { buildExecPrompt, type ExecInputStream } from "./exec-input"; +import { ExecJsonEmitter, type ExecOutputFormat } from "./exec-json-output"; import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers"; type ExecSessionManager = Pick< @@ -32,6 +33,8 @@ export interface ExecRunnerOptions { projectRoot: string; resumeSessionId?: string; forkSessionId?: string; + /** `text` (default) prints the assistant reply; `json` emits NDJSON events. */ + outputFormat?: ExecOutputFormat; input?: ExecInputStream; } @@ -60,6 +63,7 @@ export async function runExecMode( ): Promise { const deps = { ...defaultDependencies, ...dependencies }; let manager: ExecSessionManager | null = null; + let json: ExecJsonEmitter | null = null; let interrupted = false; const handleSigint = (): void => { @@ -70,35 +74,51 @@ export async function runExecMode( deps.signalTarget.on("SIGINT", handleSigint); try { const settings = deps.resolveSettings(options.projectRoot); + if (options.outputFormat === "json") { + json = new ExecJsonEmitter(deps.writeStdoutLine, { + cwd: options.projectRoot, + model: settings.model, + permissionMode: settings.permissions.defaultMode, + mcpServers: Object.keys(settings.mcpServers ?? {}), + resumedFrom: options.resumeSessionId, + forkedFrom: options.forkSessionId, + }); + } manager = deps.createSessionManager({ projectRoot: options.projectRoot, createOpenAIClient: () => createOpenAIClient(options.projectRoot), getResolvedSettings: () => deps.resolveSettings(options.projectRoot), renderMarkdown: (text) => text, nonInteractive: true, - onAssistantMessage: () => {}, + onAssistantMessage: json ? (message) => json?.emitMessage(message) : () => {}, }); await manager.initMcpServers(settings.mcpServers); if (interrupted) { + json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted." }); return 130; } if (options.resumeSessionId) { if (!manager.getSession(options.resumeSessionId)) { - deps.writeStderrLine(`No saved session found with ID "${options.resumeSessionId}".`); + const message = `No saved session found with ID "${options.resumeSessionId}".`; + deps.writeStderrLine(message); + json?.emitResult({ subtype: "error", error: message }); return 1; } } if (options.forkSessionId) { if (!manager.getSession(options.forkSessionId)) { - deps.writeStderrLine(`No saved session found with ID "${options.forkSessionId}".`); + const message = `No saved session found with ID "${options.forkSessionId}".`; + deps.writeStderrLine(message); + json?.emitResult({ subtype: "error", error: message }); return 1; } } const prompt = await deps.buildPrompt(options.prompt, options.input ?? process.stdin); if (interrupted) { + json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted." }); return 130; } @@ -107,44 +127,68 @@ export async function runExecMode( } else if (options.resumeSessionId) { manager.setActiveSessionId(options.resumeSessionId); } + // Resumed and forked runs know their id up front; a fresh session mints one + // inside handleUserPrompt and reports it through the first streamed message. + json?.noteSessionId(manager.getActiveSessionId()); await manager.handleUserPrompt({ text: prompt }); const sessionId = manager.getActiveSessionId(); const session = sessionId ? manager.getSession(sessionId) : null; + json?.noteSessionId(sessionId); if (interrupted || session?.status === "interrupted") { if (!interrupted) { deps.writeStderrLine("Execution was interrupted."); } + json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted.", status: session?.status }); return interrupted ? 130 : 1; } if (!session) { - deps.writeStderrLine("Execution failed before a session was created."); + const message = "Execution failed before a session was created."; + deps.writeStderrLine(message); + json?.emitResult({ subtype: "error", error: message }); return 1; } if (session.status === "ask_permission") { - deps.writeStderrLine(formatPermissionConfirmationError(session.askPermissions, settings.permissions)); + const message = formatPermissionConfirmationError(session.askPermissions, settings.permissions); + deps.writeStderrLine(message); + json?.emitResult({ subtype: "permission_required", error: message, status: session.status }); return 1; } if (session.status === "waiting_for_user") { - deps.writeStderrLine("Execution requires user input, which is unavailable in --exec mode."); + const message = "Execution requires user input, which is unavailable in --exec mode."; + deps.writeStderrLine(message); + json?.emitResult({ subtype: "input_required", error: message, status: session.status }); return 1; } if (session.status !== "completed") { - deps.writeStderrLine( - session.failReason ? `Execution failed: ${session.failReason}` : `Execution failed (${session.status}).` - ); + const message = session.failReason + ? `Execution failed: ${session.failReason}` + : `Execution failed (${session.status}).`; + deps.writeStderrLine(message); + json?.emitResult({ subtype: "error", error: message, status: session.status }); return 1; } - deps.writeStdoutLine(session.assistantReply ?? ""); + if (json) { + json.emitResult({ + subtype: "success", + result: session.assistantReply ?? "", + status: session.status, + usage: session.usage, + }); + } else { + deps.writeStdoutLine(session.assistantReply ?? ""); + } return 0; } catch (error) { if (interrupted) { + json?.emitResult({ subtype: "interrupted", error: "Execution was interrupted." }); return 130; } const message = error instanceof Error ? error.message : String(error); deps.writeStderrLine(`deepcode: ${message}`); + json?.emitResult({ subtype: "error", error: message }); return 1; } finally { deps.signalTarget.off("SIGINT", handleSigint); diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts index 90beb6a9..f558e58a 100644 --- a/packages/cli/src/tests/cli-args.test.ts +++ b/packages/cli/src/tests/cli-args.test.ts @@ -376,3 +376,43 @@ test("parseArguments exits when --last is combined with bare --resume", async () assert.ok(exitSpy.calls.length >= 1); }); }); + +// ── parseArguments: --output-format ──────────────────────────────────────────── + +test("parseArguments defaults outputFormat to text", async () => { + const r = await parseArguments(["-x", "-p", "hello"]); + assert.equal(r.outputFormat, "text"); +}); + +test("parseArguments accepts --output-format json with exec", async () => { + const r = await parseArguments(["-x", "-p", "hello", "--output-format", "json"]); + assert.equal(r.outputFormat, "json"); + assert.equal(r.exec, true); +}); + +test("parseArguments accepts an explicit --output-format text", async () => { + const r = await parseArguments(["-x", "-p", "hello", "--output-format", "text"]); + assert.equal(r.outputFormat, "text"); +}); + +test("parseArguments exits when --output-format json is used without --exec", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-p", "hello", "--output-format", "json"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.includes(1)); + }); +}); + +test("parseArguments exits on an unknown --output-format value", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-x", "-p", "hello", "--output-format", "yaml"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.includes(1)); + }); +}); diff --git a/packages/cli/src/tests/exec-runner.test.ts b/packages/cli/src/tests/exec-runner.test.ts index 1faa40ea..7b32d0ac 100644 --- a/packages/cli/src/tests/exec-runner.test.ts +++ b/packages/cli/src/tests/exec-runner.test.ts @@ -369,3 +369,158 @@ test("runExecMode disposes resources when stdin cannot be read", async () => { assert.match(harness.stderr.join("\n"), /Failed to read stdin: stdin unavailable/); assert.equal(harness.disposed, 1); }); + +// ── --output-format json ────────────────────────────────────────────────────── + +function parseEvents(stdout: string[]): Record[] { + return stdout.map((line) => { + assert.doesNotMatch(line, /\n/, "each event must occupy exactly one line"); + return JSON.parse(line) as Record; + }); +} + +test("runExecMode emits an init event carrying the session id before the result", async () => { + const harness = createHarness(); + const code = await runExecMode( + { prompt: "task", projectRoot: "/tmp/project", outputFormat: "json", input: ttyInput() }, + harness.dependencies + ); + + assert.equal(code, 0); + const events = parseEvents(harness.stdout); + const init = events[0]; + assert.equal(init.type, "system"); + assert.equal(init.subtype, "init"); + // The id is minted inside handleUserPrompt, so it reaches stdout via the + // first streamed message — before the turn finishes. + assert.equal(init.session_id, "new-session"); + assert.equal(init.cwd, "/tmp/project"); + assert.equal(init.model, "test-model"); + assert.deepEqual(init.mcp_servers, []); + + const result = events[events.length - 1]; + assert.equal(result.type, "result"); + assert.equal(result.subtype, "success"); + assert.equal(result.is_error, false); + assert.equal(result.session_id, "new-session"); + assert.equal(result.result, "final answer"); + assert.equal(typeof result.duration_ms, "number"); +}); + +test("runExecMode streams session messages as their own json events", async () => { + const harness = createHarness(); + await runExecMode( + { prompt: "task", projectRoot: "/tmp/project", outputFormat: "json", input: ttyInput() }, + harness.dependencies + ); + + const events = parseEvents(harness.stdout); + const assistant = events.find((event) => event.type === "assistant"); + assert.ok(assistant, "expected an assistant event"); + assert.equal(assistant.session_id, "new-session"); + const message = assistant.message as Record; + assert.equal(message.id, "tool-message"); + assert.equal(message.role, "assistant"); + assert.deepEqual(message.message_params, { tool_calls: [{ function: { name: "read" } }] }); +}); + +test("runExecMode emits exactly one init and one result event", async () => { + const harness = createHarness(); + await runExecMode( + { prompt: "task", projectRoot: "/tmp/project", outputFormat: "json", input: ttyInput() }, + harness.dependencies + ); + + const events = parseEvents(harness.stdout); + assert.equal(events.filter((event) => event.subtype === "init").length, 1); + assert.equal(events.filter((event) => event.type === "result").length, 1); +}); + +test("runExecMode reports the resumed session id in the init event", async () => { + const harness = createHarness({ resumeExists: true }); + const code = await runExecMode( + { + prompt: "task", + projectRoot: "/tmp/project", + resumeSessionId: RESUME_ID, + outputFormat: "json", + input: ttyInput(), + }, + harness.dependencies + ); + + assert.equal(code, 0); + const init = parseEvents(harness.stdout)[0]; + assert.equal(init.session_id, RESUME_ID); + assert.equal(init.resumed_from, RESUME_ID); +}); + +test("runExecMode emits an error result event for a failed turn", async () => { + const harness = createHarness({ finalStatus: "failed", failReason: "model exploded" }); + const code = await runExecMode( + { prompt: "task", projectRoot: "/tmp/project", outputFormat: "json", input: ttyInput() }, + harness.dependencies + ); + + assert.equal(code, 1); + const result = parseEvents(harness.stdout).at(-1)!; + assert.equal(result.type, "result"); + assert.equal(result.subtype, "error"); + assert.equal(result.is_error, true); + assert.equal(result.status, "failed"); + assert.match(String(result.error), /model exploded/); + // The human-readable diagnostic is unchanged on stderr. + assert.match(harness.stderr.join("\n"), /model exploded/); +}); + +test("runExecMode emits a permission_required result event without a session reply", async () => { + const harness = createHarness({ + finalStatus: "ask_permission", + askPermissions: [ + { toolCallId: "call-1", name: "bash", command: "rm -rf /", description: "", scopes: ["write-out-cwd"] }, + ], + permissions: { allow: [], deny: [], ask: ["write-out-cwd"], defaultMode: "allowAll" }, + }); + const code = await runExecMode( + { prompt: "task", projectRoot: "/tmp/project", outputFormat: "json", input: ttyInput() }, + harness.dependencies + ); + + assert.equal(code, 1); + const result = parseEvents(harness.stdout).at(-1)!; + assert.equal(result.subtype, "permission_required"); + assert.equal(result.is_error, true); + assert.match(String(result.error), /permission confirmation/); +}); + +test("runExecMode still emits an init and result event when no session is created", async () => { + const harness = createHarness(); + const code = await runExecMode( + { + prompt: "task", + projectRoot: "/tmp/project", + resumeSessionId: RESUME_ID, + outputFormat: "json", + input: ttyInput(), + }, + harness.dependencies + ); + + assert.equal(code, 1); + const events = parseEvents(harness.stdout); + assert.equal(events[0].subtype, "init"); + assert.equal(events[0].session_id, null); + assert.equal(events[1].type, "result"); + assert.equal(events[1].is_error, true); +}); + +test("runExecMode writes plain text and no json events by default", async () => { + const harness = createHarness(); + const code = await runExecMode( + { prompt: "task", projectRoot: "/tmp/project", input: ttyInput() }, + harness.dependencies + ); + + assert.equal(code, 0); + assert.deepEqual(harness.stdout, ["final answer"]); +});