Re-review of the reversed doc came back APPROVE_WITH_MINOR. The reversal itself was verified complete (worktree-wide grep: no surviving impossibility claim) and NOT over-claimed in the other direction (the reviewer recomputed every headline number from the committed messagedisplay-deltas.jsonl and re-ran the invariant on two further turns: 4 independent turns total, 5/6/4/18 fires, 609/696/239/1973 bytes, concat(deltas) === T TRUE in all four). But two additive caveats were missing, and both are load-bearing for the streaming PR now in flight: 1. CONCURRENCY DEMUX (severe, and live TODAY — not a warm-pool future problem). OCP_TUI_MAX_CONCURRENT defaults to 2, so two `claude` processes already run concurrently. One MessageDisplay hook writing to one shared sink would INTERLEAVE deltas from two different turns into a single stream — request A's client receiving request B's text. A single-request test never surfaces it. The payload carries session_id, so the sink must be keyed by it (which also keeps the design warm-pool compatible: a pre-booted pane's session-id is fixed at boot, so one static hook script serves every pane). Documented, with the required ≥2-concurrent-request test. 2. THINKING-EXCLUSION IS NOT STRESS-TESTED (severe if wrong). The exclusion was inferred from a code snippet that turns out to be the final:true call site, not the incremental one. Four live turns showed no thinking in any delta — but every transcript's thinking block was EMPTY (thinking:"", 0 chars), so it was never actually stressed. If thinking deltas do fire on Opus/xhigh, concat(deltas) !== T AND OCP streams the model's private reasoning to the caller; the concat === T assertion detects that but cannot un-send an SSE delta. Flagged as a must-verify-before-shipping item. Also: the "5-7 chunks per answer" figure is size-dependent (18 fires on a ~2 KB answer) — rescoped to "once per rendered block, scales with answer length" in both the doc and the README, so no implementer hard-codes a chunk-count assumption. Both caveats were relayed to the streaming implementation immediately rather than waiting for this merge. Docs-only. No code change, no version bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
16 KiB
Backlog #2 (real streaming): achievable — via the MessageDisplay hook
Date: 2026-07-13
Status: prereq-spike result. Streaming IS achievable on the TUI path, byte-faithfully, on the
subscription pool. Three obvious sources are dead ends; a fourth one works.
Scope: answers the prereq spike that README.md § "Backlog #2" demanded before any
streaming design:
Prereq spike (do this before designing anything): does the transcript JSONL grow during a turn, or only at the end? If only at the end, (a) is dead and you are stuck with (b).
The answer: (a) is dead, (b) is dead — and you are not stuck with either. The CLI exposes its own streaming interface as a hook, which the backlog did not consider.
Measured on: Mac mini / Claude Code v2.1.207 / Sonnet 4.6 + Sonnet 5 / Claude Max / real-home mode. Every claim below is reproducible from the commands given.
Honesty note on how this document was produced. Its first version concluded the exact opposite — "streaming is not achievable; the CLI exposes no byte-faithful incremental source" — and was wrong. An adversarial reviewer, commissioned specifically to refute it, found
MessageDisplayon a second pass; its own first pass had enumerated the hook registry with a truncated grep (it reported 21 events — there are 30). Both the wrong conclusion and its refutation are preserved here, because "we checked, it's impossible" is the most expensive kind of claim to get wrong: it closes a door and nobody re-opens it.
✅ The source that works: the MessageDisplay hook
claude fires a MessageDisplay hook as it renders each block of the assistant's reply. The
payload carries the raw markdown source of an incremental delta, plus a monotonic index and a
final flag:
{ "hook_event_name": "MessageDisplay",
"turn_id": "6cb31d21-…", "message_id": "84ab9832-…",
"index": 0, "final": false, "delta": "## Mutex\n\n" }
(payload also carries session_id, transcript_path, prompt_id, cwd)
Registered as an ordinary command hook via --settings on a plain interactive TUI spawn (no -p,
no --bare), claude-sonnet-4-6, --effort low. Banner verified:
▝▜█████▛▘ Sonnet 4.6 with low effort · Claude Max — subscription pool, not metered billing.
One live turn — 7 fires, spread across generation:
index=0 final=false len= 10 '## Mutex\n\n'
index=1 final=false len= 140 'A **mutual exclusion lock** prevents concurrent access to a shar…'
index=2 final=false len= 83 '- Acquiring a locked mutex blocks the caller until the current h…'
index=3 final=false len= 163 '- Failing to release a mutex causes a deadlock, freezing all wai…'
index=4 final=false len= 96 'let counter = 0;\n\nasync function increment() {\n const release =…'
index=5 final=false len= 84 ' counter++; // only one caller here at a time\n } finally {\n …'
index=6 final=true len= 3 '```'
Every invariant a proxy needs — all hold:
| requirement | result |
|---|---|
| byte-faithful — deltas are the model's source, not the rendered pane | ✅ ## , **, ```javascript all present in the deltas |
exactness — concat(deltas) === T (the transcript-authoritative text) |
✅ true, 579 == 579 bytes |
prefix-stable — T.startsWith(concat(deltas[0..n])) at every n |
✅ true at all 7 steps |
| incremental — arrives during generation, not at the end | ✅ 7 fires spread across the turn |
no -p — stays out of the metered sdk-cli pool |
✅ plain interactive TUI |
| subscription pool | ✅ banner · Claude Max |
This is exactly the contract a streaming design needs: deltas forward straight into SSE
delta.content chunks, and the transcript's final text T stays a cheap end-of-turn assertion
(concat === T) instead of a reconciliation problem.
Caveats for the implementer
- Block-level granularity, not token-level — the hook fires once per rendered block (roughly one
per paragraph / list item / code block), so the chunk count scales with answer length: 7 fires for a
~600-byte answer, 18 for a ~2 KB one. Plenty for SSE (
delta.contenthas no minimum size), but do not promise token-by-token output, and do not hard-code any assumption about chunk count. - 🔴 The sink MUST be keyed by
session_id— this is live TODAY, not a future concern.OCP_TUI_MAX_CONCURRENTdefaults to 2, so twoclaudeprocesses already run concurrently. One hook command writing to one shared sink would interleave deltas from two different turns into one stream — request A's client receiving request B's text, the worst failure a proxy can have, and one a single-request test will never surface. The payload carriessession_id(andturn_id/message_id), so demux is easy: derive the sink path fromsession_id(<dir>/<session_id>.jsonl) and read only your own turn's file. This also keeps the design warm-pool compatible, because a pre-booted pane's session-id is fixed at boot — one static hook script serves every pane. Test it with ≥2 concurrent streaming requests carrying distinguishable prompts and assert zero cross-contamination. - ⚠️
forceSyncExecution: truein the hook's source —claudeBLOCKS on the hook. A slow hook adds latency to every delta. The hook must write and exit immediately (e.g. write to a FIFO / unix socket that OCP reads; never work inline). Measure the added per-delta latency. - Thinking blocks appear to be excluded — but this is NOT yet stress-tested. Verify before shipping.
The exclusion is inferred from
content.map(c => c.type === "text" ? c.text : "")— but that snippet is from thefinal:truecall site, not the incremental one. Four live turns (incl. two at--effort high) showed no thinking text in any delta andconcat === Theld — but each transcript's thinking block was empty (thinking:"", 0 chars), so the exclusion was never actually stressed. The failure mode is severe: if thinking deltas do fire on some config (Opus,xhigh),concat(deltas) !== Tand OCP streams the model's private reasoning to the caller. The end-of-turnconcat === Tassertion would detect that but cannot prevent it — SSE deltas cannot be un-sent. Before shipping, run a turn on a model+effort that produces substantive thinking (a hard reasoning prompt on Opus /xhigh) and confirm both (a) no thinking text in any delta and (b)concat === Tstill holds. - OCP already owns the spawn (isolated HOME, its own flags), so injecting
--settingswith aMessageDisplayhook sits inside the existing architecture. ALIGNMENT.md: this consumesclaude's own hook surface as emitted — forwarding, not inventing. Not a new endpoint, not a fabricated protocol. (Class B / ADR 0007 — the TUI spawn is OCP-owned; nocli.jscitation applies.)
Reproduce in 60 seconds
# hook script: append the payload (arrives on stdin) and exit immediately
printf '#!/bin/bash\ncat >> "$MD_LOG"; printf "\\n" >> "$MD_LOG"; exit 0\n' > /tmp/h.sh && chmod +x /tmp/h.sh
echo '{"hooks":{"MessageDisplay":[{"hooks":[{"type":"command","command":"MD_LOG=/tmp/deltas.jsonl /tmp/h.sh"}]}]}}' > /tmp/s.json
# plain interactive claude in tmux (prefix NOT ocp-tui-*, and never kill-server)
tmux new-session -d -s md-probe -x 220 -y 50 \
"claude --model claude-sonnet-4-6 --effort low --session-id $(uuidgen) --settings /tmp/s.json"
# …wait for '? for shortcuts', paste a markdown-producing prompt, press Enter…
jq -r '"\(.index) \(.final) \(.delta|@json)"' /tmp/deltas.jsonl # incremental raw-markdown deltas
# then assert: concat(deltas) == extractLatestAssistantText(<transcript>.jsonl)
The three dead ends (still worth knowing — they say what NOT to build)
(a) Incremental transcript reads — dead: event granularity, not token granularity
The transcript JSONL does grow during a turn, but one whole event at a time; the assistant's text
event is written as one complete line, appearing only ~0.3 s before the terminal turn_duration.
Observed (session efd5b161, turn_duration: 7319 ms):
#6 t+0.0s type=user (the prompt)
#15 t+4.7s type=assistant blocks=thinking
#16 t+7.0s type=assistant blocks=text ← the ENTIRE answer, in one line
#21 t+7.3s type=system subtype=turn_duration ← terminal
Cross-checked at 20 ms polling + fs.watch (25× finer): a partial line never touches disk —
one write, +1 line, carrying the complete answer. Also forced with the undocumented
CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1: still 1 assistant event, 0 partials (interactive mode has no
stream-json sink for it to write to).
The transcript is still needed — as the terminal-turn signal, as the authoritative concat === T
check, and as the input to the existing honesty gates (auth-banner detection, truncated). It is just
not the streaming source.
(b) tmux capture-pane diffing — dead: the pane is a RENDERED view, not the text
The backlog expected to fall back to this, calling it "lossy … (wrapping, scrollback, spinner lines)".
The loss is far worse than formatting noise: the pane does not contain the answer's source bytes at
all. The TUI renders markdown, and capture-pane -p strips the ANSI that rendering produced.
Same turn, same lines:
TRANSCRIPT (authoritative T): PANE (capture-pane -p -J -S -500):
'## Semaphore' '⏺ Semaphore' ← heading marker gone
'' ''
'A **semaphore** is a synchro…' ' A semaphore is a synchro…' ← bold markers gone, indented
| token in the answer | in T |
in the pane's answer region |
|---|---|---|
## (ATX heading) |
yes | no — rendered as ⏺ |
** (bold markers) |
yes | no — rendered to ANSI bold, then stripped by -p |
```javascript (fence + language) |
yes | no — fence and language tag both gone |
- (list item) |
yes | yes |
(A literal ** does appear elsewhere in the pane — in the prompt echo, because the prompt asked
for bold. Not in the answer.)
capture-pane -e (keeping the ANSI) does not rescue it — the inverse is provably non-unique.
With T = "## Alpha\n\n**bravo**\n\n```javascript\nlet x=1;\n```":
⏺\e[39m \e[1mAlpha\n\n\e[0m \e[1mbravo\n\n\e[0m \e[34mlet\e[39m x=\e[32m1\e[39m;
## Alpha → SGR 1 (bold). **bravo** → SGR 1 (bold). Identical ANSI — an H2 and a bold span
are indistinguishable, never mind ** vs __. The fence and its javascript tag are consumed by the
syntax highlighter into colours; recovering the tag would mean inverting a highlighter, and
let x=1; is valid in several languages.
So T.startsWith(paneText) is false — raw and indent-stripped, on essentially every markdown
answer. A proxy streaming pane text would be streaming something the model did not say. With
MessageDisplay available there is no reason to go near it.
(c) --debug-file — dead: it logs stream timing, never stream content
Worth stating precisely, because a casual check misleads in both directions here.
The default log level is debug, which suppresses every verbose site. Raise it and per-chunk
lines do appear, spread across generation:
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose claude --debug-file /tmp/d.log …
05:51:11.088 [VERBOSE] [shoji-engine] yield stream_event/- ← 16 of these, mid-turn,
05:51:11.537 [VERBOSE] [shoji-engine] yield stream_event/- over ~3.9 s of generation
05:51:15.192 [DEBUG] [shoji-engine] turn 1 end (usage in=575 out=255 api=6736ms stop=end_turn resultLen=857)
But they carry no payload — the format is yield <type>/<subtype>, a bare presence marker. Run with
no category filter (i.e. all categories) at verbose level: content_block_delta = 0, text_delta =
0, content_block_start / message_start = 0. The only byte-exact text in the log is the
end-of-turn Stop hook payload ("last_assistant_message":"## Title\n\n**alpha bravo charlie**") —
transcript granularity. The log tells you when tokens arrive, never what they are. It is also
~2.7 MB per turn.
Also checked, also not the answer
| candidate | outcome |
|---|---|
--output-format stream-json (the one interface that emits text_delta) |
requires --print/-p → cc_entrypoint=sdk-cli → the metered credit pool, which is exactly what TUI mode exists to avoid. Reproduced live. |
--input-format stream-json |
Error: --input-format=stream-json requires output-format=stream-json → same gate. |
CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1 (undocumented) |
No stream-json sink in interactive mode → no partials. Banner stayed · Claude Max. |
sessionMirror (undocumented) |
Gated on outputFormat === "stream-json" → the -p family. |
--sdk-url (hidden) |
Forces stream-json + non-interactive → sdk-cli. (inferred from the minified bundle; not banner-tested) |
~/.claude/sessions/<pid>.json |
Registry metadata only ({pid, sessionId, cwd, status, version, entrypoint:"cli", kind:"interactive"}). No assistant text. (Its entrypoint:"cli" incidentally confirms the TUI path stays on the subscription pool.) |
~/.claude/history.jsonl |
User prompts only; the answer text is absent. |
| Asking the model to emit plain text (so the pane renders faithfully) | Would mean mutating the caller's prompt — a correctness violation for a proxy, and still not byte-faithful (wrapping + indent remain). Rejected. |
Value: what streaming actually buys (read before building)
Streaming is possible. Whether it is worth it depends on the consumer, and the honest answer is uncomfortable:
- Streaming never makes the answer arrive sooner. It moves the first byte, not the last. The final token lands at the same wall-clock moment either way.
- So a consumer that must have the complete answer before it can act — e.g. one parsing a structured JSON reply, which is exactly the 知音 AI use case that motivated this entire investigation — gains nothing at all. Only a progressively-rendering consumer (a chat UI) gains.
And the number the backlog attached to this item was wrong:
- The backlog's "~20 s" was inferred from an external 30–32 s report, never measured through OCP.
Measured through a real OCP instance (TUI mode,
claude-sonnet-4-6, ~1850-token prompt, n=5): median 11.30 s before #156, 9.55 s after. - Same-turn decomposition (baseline row
i=5): 11.563 s wall through OCP vsturn_duration: 7.319 sof CLI-internal time on that same turn → OCP's own overhead ≈ 4.2 s (n=1, baselineeffort=highconfig). Caveats: n=1; andturn_durationis the CLI's internal duration of an OCP-driven turn, not a separate "native" baseline. Do not subtract thiseffort=high7.3 s from theeffort=low9.55 s median — a low-effort turn generates faster, so mixing them understates the overhead. - So OCP's own overhead is single-digit seconds, not ~20 s. The rest of any large number is the model generating a long answer — which streaming hides but does not shorten.
Recommendation: build it — the contract is clean and the cost is small — but size the expectation
honestly. It is a perceived-latency feature for progressively-rendering consumers, not a throughput
win, and it does not move the ~6 s TTFT floor (README.md) that rules TUI mode out for
interactive-latency consumers regardless.