Skip to content

feat: add --stream-json streaming NDJSON output for headless mode - #69

Open
tornado404 wants to merge 1 commit into
kingsword09:mainfrom
tornado404:feat/stream-json-output
Open

feat: add --stream-json streaming NDJSON output for headless mode#69
tornado404 wants to merge 1 commit into
kingsword09:mainfrom
tornado404:feat/stream-json-output

Conversation

@tornado404

@tornado404 tornado404 commented Aug 12, 2026

Copy link
Copy Markdown

Update: While waiting for review on this PR, the fork has been independently published to npm as zcode-cli-stream so the streaming feature is available now. The bin command remains zcode, so downstream integrations (e.g. Multica) work with either package. This PR remains open for upstream consideration — see the fork at tornado404/zcode-cli.

Background

zcode-cli's headless mode (zcode --prompt <text> --json) runs a whole turn and prints a single JSON summary object at the end. While the bundled runtime emits rich live session events internally (assistant text deltas, reasoning, tool calls, tool results, usage), the --prompt path ignores them — runPrompt calls submitPrompt({abortSignal}) without passing an onEvent callback, so the event stream is never surfaced.

This means integrations that drive zcode headlessly (e.g. Multica) see no activity while the agent works — no thinking, no tool calls, no incremental text — until the entire turn finishes. The inactivity watchdog sees a single message at the end, which is indistinguishable from a hang.

What this PR does

Adds a --stream-json flag that surfaces the live event stream as qwen-compatible NDJSON on stdout (one JSON object per line), so integrations can observe the full agent workflow in real time.

Event mapping (runtime native → NDJSON)

runtime event emitted NDJSON line
turn_started {"type":"system","subtype":"init","session_id":"..."}
model_streaming (text_delta) {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}
model_streaming (reasoning_delta) {"type":"assistant","message":{"content":[{"type":"thinking","thinking":"..."}]}}
tool_call_scheduled {"type":"assistant","message":{"content":[{"type":"tool_use","id":"...","name":"Bash","input":{...}}]}}
tool_call_result / tool_call_error {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"...","content":"..."}]}}
turn_complete {"type":"result","subtype":"success","is_error":false,"result":"...","usage":{...}}
turn_failed {"type":"result","subtype":"error_during_execution","is_error":true,"error":{"message":"..."}}

The schema intentionally matches qwen-code's --output-format stream-json so downstream consumers can reuse a single streaming parser.

Example output

{"type":"system","subtype":"init","session_id":"sess_abc"}
{"type":"assistant","session_id":"sess_abc","message":{"content":[{"type":"thinking","thinking":"Let me check"}]}}
{"type":"assistant","session_id":"sess_abc","message":{"content":[{"type":"tool_use","id":"call_1","name":"Bash","input":{"command":"ls"}}]}}
{"type":"user","session_id":"sess_abc","message":{"content":[{"type":"tool_result","tool_use_id":"call_1","content":"file.txt"}]}}
{"type":"assistant","session_id":"sess_abc","message":{"content":[{"type":"text","text":"Found file.txt"}]}}
{"type":"result","subtype":"success","session_id":"sess_abc","is_error":false,"result":"Found file.txt","usage":{"input_tokens":100,"output_tokens":5,"cache_read_input_tokens":80}}

Implementation

The patch lives in scripts/patch-runtime-stream.ts — a post-sync patch applied automatically after sync-runtime.ts extracts vendor/zcode.cjs from ZCode Desktop. Since vendor/zcode.cjs is a minified upstream artifact (regenerated on every sync), editing it directly would be overwritten; the patch script keeps the change reproducible across syncs.

The patch makes 4 anchored string replacements in the bundle:

  1. argv parser — add "stream-json" boolean option (strict mode would otherwise reject it)
  2. options builder (lva) — map stream-json argv value to o.streamJson (camelCase) for runPrompt
  3. runPrompt submitPrompt call — inject onEvent: o.streamJson ? __zcodeStreamEmit : undefined
  4. summary branch — short-circuit the final summary print when streaming (the terminal result NDJSON line carries response + usage)

The __zcodeStreamEmit helper (injected at module scope after "use strict") subscribes via the runtime's existing runtime.subscribeEvents({onSessionEvent}) mechanism — the same one the TUI uses for live rendering.

Properties

  • Idempotent: re-running on an already-patched bundle is a no-op (checks for __zcodeStreamEmit)
  • Fail-fast: if an upstream upgrade changes an anchor, the patch errors out instead of silently corrupting the bundle
  • Non-invasive: when --stream-json is NOT set, behavior is identical to before (existing --prompt --json unaffected)

Other changes

  • src/launcher.ts: add --stream-json to runtimeBooleanOptions so the launcher forwards it instead of flagging it invalid
  • package.json: wire the patch into sync / sync:local / sync:locked; add check:stream script
  • scripts/test-stream.ts: end-to-end validation (runs a prompt with --stream-json, asserts NDJSON shape, non-zero usage)

Verification

$ zcode --prompt "reply with exactly the word hello" --stream-json
{"type":"system","subtype":"init","session_id":"sess_..."}
{"type":"assistant","session_id":"sess_...","message":{"content":[{"type":"text","text":"hello"}]}}
{"type":"result","subtype":"success","session_id":"sess_...","is_error":false,"result":"hello","usage":{"input_tokens":11423,"output_tokens":3,"cache_read_input_tokens":11392}}

Tested with tool-calling prompts (34 events: thinking → tool_use → tool_result → text → result) — all event types map correctly.

Backward compatibility

--stream-json is additive. Existing --prompt and --prompt --json behavior is unchanged. TUI mode and app-server are untouched.

Security analysis

  • No credential/auth/billing code touched. The diff is limited to scripts/patch-runtime-stream.ts, scripts/test-stream.ts, src/launcher.ts (one line), package.json, .gitignore. Scanned for API keys, tokens, secrets — none present.
  • The injected helper only writes to stdout. It serializes runtime session events (already in-process) as JSON lines. No network calls, no file reads beyond what the runtime already does, no environment variable exfiltration.
  • The patch operates on a local build artifact (vendor/zcode.cjs), not on user data or network-fetched content.
  • No new dependencies.

npm package

Published as zcode-cli-stream (@3.7.5-11):

npm install -g zcode-cli-stream@latest
zcode --prompt "hello" --stream-json

Forked from kingsword09/zcode-cli with full attribution. MIT-licensed. This upstream PR remains open for consideration.

AI Disclosure

AI tool used: ZCode (GLM-5.2)

Approach: The patch anchors and event-schema mapping were derived by probing the bundled runtime with a temporary onEvent dump, capturing real event payloads from both a simple prompt and a tool-calling prompt, then writing the qwen-compatible mapping against the observed shapes. The streaming backend consumer (Multica side) reuses the existing qwen streaming parser.

The bundled ZCode runtime (vendor/zcode.cjs) already emits live session
events internally via runtime.subscribeEvents, but the headless --prompt
--json path ignores them and prints a single summary object at turn end.
Integrations like Multica therefore see no tool calls, thinking, or
incremental text while the agent works.

This adds a --stream-json flag that surfaces the live event stream as
qwen-compatible NDJSON on stdout (one JSON object per line):

  system → assistant/thinking → assistant/text → tool_use →
  user/tool_result → ... → result

The implementation is a post-sync patch (scripts/patch-runtime-stream.ts)
that injects an onEvent callback into runPrompt's submitPrompt call and
maps runtime events (model_streaming, tool_call_*, turn_complete) to the
qwen schema {type, message:{content:[...]}}. The patch anchors on stable
substrings in the minified bundle, fails loudly on upstream changes, and
is idempotent. It runs automatically as part of sync/sync:local/sync:locked.

Verification: scripts/test-stream.ts runs a prompt end-to-end and asserts
the NDJSON shape (system/assistant/result events, non-zero usage).

Launcher change: --stream-json added to runtimeBooleanOptions so the flag
is forwarded to the runtime instead of being flagged invalid.
@tornado404

Copy link
Copy Markdown
Author

@kingsword09 作者你好,我希望你能评审并采纳这个流式输出功能,配合使用 我发起的PR multica ,可以在多智能体工作流multica调用zcode desktop 享受1.5倍token的权益

@kingsword09

kingsword09 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

感谢PR。针对这个方案,我又借助 GPT-5.6-Sol 做了一轮交叉审查,并对官方 ZCode runtime 进行了实际协议测试。最终结论主要基于 runtime 的真实行为:现有的 zcode app-server 已经可以提供 Multica 所需的完整流式能力。包括:

  • model.streaming:文本和 reasoning 增量
  • tool.updated:工具调用、进度、结果和错误
  • turn.completed / turn.failed:终态与 usage
  • session/create / session/resume:会话创建和恢复
  • session/stop:取消执行
  • interaction/requestPermission:权限交互

因此,对于 Multica 集成,不需要在压缩后的 zcode.cjs 中注入 --stream-json。更合适的方案是参考 Multica 的
Codex backend,直接驱动 ZCode app-server:

zcode app-server
→ session/create 或 session/resume
→ session/subscribe
→ session/send
→ 消费 session/event
→ session/close

Codex 和 ZCode 的具体协议不同,所以不能直接复用 codexClient,但可以复用它的整体架构,包括长期进程管理、
NDJSON 请求匹配、反向请求处理、超时和进程回收。

建议 Multica PR 保留 runtime discovery、migration 和 UI 等改动,但将 zcode --prompt --stream-json
backend 改为原生 app-server backend。

基于这个结论,当前 PR 中对 runtime bundle 的注入方案暂时不合并。原因不是流式需求不合理,而是官方 runtime
已经提供了更直接、稳定且完整的接口。如果未来仍需要面向 shell 用户提供独立的 --stream-json,可以作为单独
的 CLI 功能设计,而不应依赖压缩 bundle 的字符串注入。


For the Multica integration, the preferred approach is therefore to drive the native app-server:

session/create or session/resume
→ session/subscribe
→ session/send
→ consume session/event notifications
→ session/close

This is similar to Multica's Codex backend, although ZCode needs its own protocol adapter because the
method names, event schemas, reverse requests, and request ID types differ.

Please keep the discovery, migration, and UI changes in the Multica PR, but replace the zcode --prompt
--stream-json backend with a native zcode app-server backend.

Given that the runtime already provides this capability, I don't plan to merge the current minified-
bundle injection. If a standalone --stream-json CLI mode is still useful beyond Multica, it should be
considered separately as a first-class CLI feature rather than implemented through bundle string
injection.

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.

2 participants