cyberine
Design notes

CLI Streaming Output — T435 Design Note

Status: shipped — Path 1 landed (T435). See src/orchestrator/Orchestrator.ts:144,374-375. Source cross-checked: src/commands/fleet.ts, src/cli.ts, src/orchestrator/run.ts, src/orchestrator/Orchestrator.ts, src/engine/WorkflowEngine.ts, src/store/RunStore.ts, src/store/tokenBus.ts, src/store/runTypes.ts, src/tui/useRunTokenStream.ts, src/core/Agent.ts, src/core/progress.ts.

1. The Problem

cyberine run "<task>" dispatches work via cmdRun (src/commands/fleet.ts:22), which calls orchestrate() (src/orchestrator/run.ts:26), which calls Orchestrator.dispatch() (src/orchestrator/Orchestrator.ts:27). Orchestrator.dispatch calls only agent.run() — the non-streaming path (src/core/Agent.ts:8). It never calls agent.runStreaming().

Update 2026-09-22: Path 1 below has landed — Orchestrator.dispatch now accepts an optional onProgress callback and calls agent.runStreaming() for streaming adapters, falling back to run() otherwise, see src/orchestrator/Orchestrator.ts:144,374-375.

The streaming infrastructure already exists and is proven: WorkflowEngine.runWorker() (src/engine/WorkflowEngine.ts:3032) calls runStreaming, routes ProgressEvent into store.bufferTokenDelta() (src/engine/WorkflowEngine.ts:3153), which both emits on the in-process tokenBus (src/store/tokenBus.ts:18) and batches writes to events.jsonl every 50 ms (src/store/RunStore.ts:300). The fleet TUI's useRunTokenStream hook (src/tui/useRunTokenStream.ts:36) reads from both paths. The one-shot CLI path (cmdRun and src/cli.ts) uses Orchestrator, not WorkflowEngine, and therefore bypasses every streaming hook. All output appears at once after await orchestrate(task) returns.

2. Compete-Mode Decision: What to Stream

In compete mode N adapters run in parallel. The design must decide what the CLI emits before the judge picks a winner.

Options considered:

Option A — Winner after judge only: suppress all per-worker tokens; print the winning output once the judge resolves. This is the current behaviour, minus the buffering. It does not reduce the UX gap because the user still sees nothing for the full run duration.

Option B — Live per-worker progress (interleaved): emit each adapter's token stream prefixed with its agent id, interleaved in arrival order. The judge output appears at the end. Maximally informative but produces noisy, interleaved output that is hard to read and unsuitable for piping.

Option C — Hybrid (per-worker progress while running, winner only as final output): while adapters are running, emit per-worker progress lines to stderr prefixed with agent id; when the judge resolves, emit the winner's complete output to stdout (matching the existing --print mode contract). Progress goes to stderr so stdout stays clean for piping.

Recommendation: Option C — hybrid.

Rationale: progress lines to stderr already follows the pattern established in cmdRun (src/commands/fleet.ts:43, src/commands/fleet.ts:49) where stderr is the diagnostic channel and stdout is the answer channel. Option C preserves --print semantics exactly (stdout is still just the winner's output) and makes the run feel alive without polluting a pipe. Per-worker prefixes on stderr give the operator visibility into which agent is ahead without scrambling the captured answer. This mirrors how claude -p works: diagnostics to stderr, answer to stdout.

3. Output Format

Non-TTY (pipe mode): stdout emits the winner's output once, as today. Stderr emits agent-prefixed progress lines as they arrive. A pipeline cyberine run -p "..." | jq continues to work because stdout is still a single clean blob.

TTY: stderr emits the same agent-prefixed progress lines, which appear as interleaved status in the terminal. If ANSI is available (detect via process.stderr.isTTY) lines may use dim() for the prefix to visually distinguish progress noise from the final answer. The format is:

[<agentId>] <one-line detail>

Examples:

[loop-deepseek-build] running: tool: write_file(src/foo.ts)
[loop-kimi-build] running: generating implementation...
[verdict:consensus] winner=loop-deepseek-build

Interaction with --print / -p (src/cyberine.ts:269): The -p flag already redirects all chrome to stderr and emits only the winner to stdout (src/commands/fleet.ts:68). The streaming design preserves this contract: progress lines always go to stderr regardless of --print.

Interaction with --json (src/cli.ts:17): The --json path in src/cli.ts routes human logs to stderr via its own log binding (src/cli.ts:28). Streaming progress lines in --json mode should also go to stderr so the stdout JSON blob remains parseable by callers. The token stream would only emit the winner's output inside the JSON payload at the end, not interleaved.

4. The Root Cause: Orchestrator Has No Streaming Path

Orchestrator.dispatch (src/orchestrator/Orchestrator.ts:27) calls agent.run() exclusively. It has no ProgressHandler parameter and no runStreaming call. The WorkflowEngine path is what wires up streaming, but cmdRun never touches WorkflowEngine.

Update 2026-09-22: Orchestrator.dispatch now carries an onProgress parameter and calls runStreaming for streaming adapters (src/orchestrator/Orchestrator.ts:144,374-375). cmdRun can also route through WorkflowEngine when foregroundUsesEngine is true, see src/commands/fleet.ts:108-141.

There are two ways to add streaming to the CLI path:

Path 1 — Add a dispatchStreaming capability to Orchestrator: add an optional onProgress?: (agentId: string, event: ProgressEvent) => void parameter to Orchestrator.dispatch, call runStreaming when the adapter supports it, and fall back to run() otherwise. cmdRun would pass a progress callback that writes to process.stderr. This is the minimal change: Orchestrator stays as the thin fan-out vehicle it is today and cmdRun stays on the orchestrate() path.

Path 2 — Route cmdRun through WorkflowEngine: create a single-step workflow that wraps the existing implementTaskWorkflow pattern and use WorkflowEngine.run() for foreground CLI runs too. This makes the CLI and the TUI use the same codepath end-to-end. The disadvantage is that WorkflowEngine carries a RunStore dependency (needs a run directory, writes run.json and events.jsonl) which is heavier than cmdRun's current zero-disk-write path.

Recommended: Path 1. It is the smallest safe change. The Orchestrator is already the shared fan-out primitive for cmdRun, src/cli.ts, and the MCP surface. Adding an optional onProgress callback keeps the contract additive (callers that pass nothing keep the current behaviour) and avoids imposing a RunStore on the one-off interactive CLI surface. Path 2 is worth considering if a future task wants full run persistence for foreground cyberine run runs (which would enable cyberine wait and cyberine digest on them), but that is a separate design decision.

5. Concrete Code Touch-Points

src/orchestrator/Orchestrator.ts — dispatch() method (line 27): Add an optional onProgress?: (agentId: string, event: ProgressEvent) => void parameter. When present and the adapter implements StreamingAgent, call adapter.runStreaming(task, (ev) => onProgress(agent.info.id, ev), signal) instead of agent.run(task, signal). Non-streaming adapters fall back to agent.run() and never call onProgress. The Result contract is unchanged.

src/orchestrator/run.ts — orchestrate() function (line 26): Accept and forward an optional onProgress callback to the Orchestrator constructor or to dispatch(). orchestrate is the only caller of new Orchestrator(...).dispatch(), so changes here propagate to all callers.

src/commands/fleet.ts — cmdRun() function (line 22): After the orchestrate(task) call signature changes to accept onProgress, pass a callback that writes agent-prefixed detail lines to process.stderr. The callback only fires on ProgressEvent.type === 'text' (for textDelta lines) and type === 'tool' (for tool-call labels). Progress lines are unconditionally written to process.stderr — they do not go to process.stdout and are independent of the opts.print flag.

src/cli.ts — top-level one-shot path (line 40): The npm run cli path calls orchestrate() directly. Pass the same progress callback, routing to console.error (the log alias already defined at line 28 when jsonMode is true, or console.log when false). For --json mode, suppress token-delta progress lines to keep stderr diagnostic-only (matching the existing contract that --json routes human logs to stderr).

src/core/progress.ts — no change. The ProgressEvent type already carries agentId, textDelta, toolName, and detail. The callback in Orchestrator can use them directly without any type changes.

6. Interaction with T438 (--timeout) and T439 (digest --json)

T438 (--timeout): when it lands, Orchestrator.dispatch will need an AbortSignal or timeout parameter to abort in-flight adapters. The progress callback design is orthogonal: onProgress would simply stop being called once adapters are cancelled or timed out. The streaming design imposes no new constraint on the timeout design beyond ensuring the progress-writer callback is idempotent on zero-or-late calls.

T439 (digest --json): cmdDigest (src/commands/lifecycle.ts:249) reads run.json after a run completes. Foreground cmdRun does not write run.json (it uses Orchestrator, not RunStore). If T439 adds a --json flag to cmdDigest for post-run inspection, it operates on persisted runs (--bg), not foreground cyberine run output. The streaming design for T435 does not change this: the foreground path still produces no run.json. If a future task wants cyberine run to produce a run record for cyberine digest to read, that is Path 2 above.

7. Risks and Open Questions

Risk: interleaved stderr lines break log-capture pipelines. A caller doing cyberine run "..." 2>err.log will accumulate progress lines in err.log. This is intentional and consistent with the existing behaviour where per-worker status lines already go to stderr (src/commands/fleet.ts:44). The new change adds more lines; it does not change the channel.

Risk: non-streaming adapters produce no progress. Not all adapters implement StreamingAgent. CliAgentAdapter does (src/adapters/CliAgentAdapter.ts:160). LoopAgentAdapter does too (it calls runStreaming internally). Adapters that only implement Agent.run() will produce no progress lines. The user will still see the per-worker status summary after completion. This is acceptable; the fallback is the current behaviour.

Open question: should progress lines include the textDelta text or only detail? Emitting raw textDelta (streaming token text) to stderr would produce very noisy output for long code-generation runs. The recommendation is to emit only detail lines (the one-liner status) and tool event labels, not raw token text. Raw token text should only appear as the final stdout winner output. Implementers should filter: emit on event.type === 'tool' and event.detail !== undefined, skip on event.type === 'text'.

Open question: line-buffer flushing under non-TTY stderr. process.stderr.write() forces a flush per call in Node.js (stderr is unbuffered in Node), so this is not a concern in practice.

Open question: compete vs. swarm mode. Swarm mode uses WorkflowEngine (not Orchestrator) and already has streaming via the existing bufferTokenDelta path. The T435 change only affects the Orchestrator path used by compete/smart/rush/deep modes that go through cmdRun -> orchestrate(). Implementers should verify which modes flow through Orchestrator and which through WorkflowEngine before applying the change.

8. Implementation Order

  1. Add onProgress to Orchestrator.dispatch (additive, no callers change yet).
  2. Thread it through orchestrate() in src/orchestrator/run.ts.
  3. Wire the callback in cmdRun in src/commands/fleet.ts, writing [agentId] detail lines to stderr.
  4. Wire the callback in src/cli.ts for the npm run cli path.
  5. Manual verification: cyberine run "What is 2+2?" on a TTY should show rolling per-worker progress lines on stderr as adapters work, then the winner's output on stdout.
  6. Verify piped mode: cyberine run -p "What is 2+2?" | cat should produce clean stdout (winner only) with diagnostic lines on stderr.