Skip to content

feat(exec): add --output-format json so --exec can surface its session ID - #272

Draft
bruno-rpassos wants to merge 1 commit into
lessweb:mainfrom
bruno-rpassos:feat/exec-output-format-json
Draft

feat(exec): add --output-format json so --exec can surface its session ID#272
bruno-rpassos wants to merge 1 commit into
lessweb:mainfrom
bruno-rpassos:feat/exec-output-format-json

Conversation

@bruno-rpassos

Copy link
Copy Markdown

Motivation

--exec already accepts --resume <sessionId>, but exec mode never writes the session ID anywhere a caller can read it. exec-runner.ts calls manager.getActiveSessionId() internally, yet only session.assistantReply reaches stdout.

So a script that runs deepcode --exec has no way to learn the ID it would need to pass back to --resume. The flag is reachable from the interactive /resume picker, but unreachable from the non-interactive path — which makes every --exec run effectively single-shot unless the caller scrapes ~/.deepcode's session store.

This PR closes that gap rather than adding a convenience: it makes an already-shipped flag usable from the mode it was clearly meant for.

What this adds

An opt-in --output-format json flag for --exec that emits newline-delimited JSON events instead of the plain assistant reply.

$ deepcode --exec --prompt "say hello" --output-format json
{"type":"system","subtype":"init","session_id":"782eb448-…","cwd":"/tmp/proj","model":"…","permission_mode":"allowAll","mcp_servers":[]}
{"type":"assistant","session_id":"782eb448-…","message":{"id":"…","role":"assistant","content":"Hello.","visible":true,"create_time":"…","content_params":null,"message_params":null}}
{"type":"result","subtype":"success","is_error":false,"session_id":"782eb448-…","result":"Hello.","duration_ms":120,"status":"completed","usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16}}

The caller can now read session_id off the first line and feed it straight back:

$ deepcode --exec --resume 782eb448-… --prompt "and again" --output-format json

Why this shape

Rather than invent a new schema, the events follow Claude Code's --output-format stream-json convention: one JSON object per line, each carrying a type discriminator and session_id, with a system/init event first and a result event last. Tooling that already drives headless coding agents can then consume Deep Code without a bespoke parser.

Fields Deep Code has no equivalent for are omitted rather than faked — there is no total_cost_usd, and usage passes through Deep Code's own ModelUsage verbatim. Message events use Deep Code's own roles (assistant, user, tool, system), since forcing them into Claude's role set would misrepresent them.

Guaranteed invariants

  • Exactly one init event first, exactly one result event last.
  • Every event carries session_id.
  • A fresh session mints its ID inside handleUserPrompt, so init is emitted the moment the first streamed message reports it — before the turn completes. That is what lets a caller capture the ID even if the run later fails or is interrupted.
  • Error paths (permission_required, input_required, interrupted, error) all emit a result event, so a consumer always gets a terminating event and never has to infer failure from a closed pipe.

Compatibility

  • Default is text; existing behaviour, stdout content, and exit codes are unchanged.
  • stderr diagnostics are untouched — JSON mode adds structured output on stdout without removing the human-readable message.
  • --output-format is rejected outside --exec.

Implementation notes

Deliberately small and additive — it sits on the deps.writeStdoutLine seam that already existed, and does not restructure the exec path:

File Change
packages/cli/src/exec-json-output.ts New. ExecJsonEmitter + event types (~170 lines)
packages/cli/src/exec-runner.ts Wires the emitter into the existing branches
packages/cli/src/cli-args.ts The --output-format flag and its validation
packages/cli/src/cli.tsx Passes the parsed value through
packages/cli/src/tests/* 13 new tests

Serialization is defensive: tool payloads arrive as unknown, so a value that cannot be stringified falls back to a reduced payload rather than throwing and taking the run down.

Testing

  • 13 new tests covering event ordering, the session ID appearing in init, resume/fork attribution, each error subtype, and that text mode is byte-for-byte unchanged.
  • npm run check (typecheck + lint + format) passes; full suite is green (312 tests in the CLI package).
  • Verified end-to-end against the built binary — including capturing the session ID from init and successfully resuming with it, producing a single session containing both turns.

Branched from bea340f.

Open questions for the maintainer

  1. Flag name / shape. Happy to rename (--json, --output-format stream-json) or reshape the events if you'd prefer something different — I'd rather match your preference now than have consumers build on the wrong schema.
  2. Docs. The READMEs don't currently document --exec at all, so I only updated the --help examples and epilog. Glad to add a README section covering headless mode and this flag if you want one.
  3. Tool-level events. onAssistantMessage also carries tool messages, so type: "tool" events appear in the stream. Let me know if you'd rather filter those out by default or gate them behind a verbosity flag.

`--exec` accepts `--resume <sessionId>`, 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant