feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off) (#159)

* feat(tui): real SSE streaming via claude's MessageDisplay hook (OCP_TUI_STREAM, default off)

Backlog #2. TUI-mode `stream:true` turns can now emit real SSE `delta.content` chunks as
`claude` generates them, instead of buffering the turn and replaying it with
streamStringAsSSE. Opt-in: with OCP_TUI_STREAM unset/0 the spawn argv, the SSE bytes and
the cache behaviour are byte-for-byte unchanged (asserted by test).

This PR does NOT mirror any cli.js function, so no `cli.js:NNNN` citation applies, and per
CLAUDE.md's hard requirement #1 that is stated explicitly here rather than left implicit:

  - We consume claude's OWN `MessageDisplay` hook surface AS EMITTED — forwarding, not
    inventing. No new endpoint, no fabricated protocol, no new field.
  - The TUI spawn is OCP-owned surface: ADR 0007 owns it, not cli.js.
  - The SSE wire shapes are the OpenAI chat/completions streaming spec, adopted by ADR 0006.
    Every frame emitted here (role chunk, content-delta chunk, stop chunk, `[DONE]`, and the
    post-header {error:{message,type}} frame) is COPIED from callClaudeStreaming, the -p path.

/health gains additive fields only (streamEnabled + 4 counters) — same grandfathered B.2
rationale as the existing tui block (ADR 0006). Existing keys are untouched.

`claude` fires MessageDisplay per rendered block, handing the hook the RAW MARKDOWN SOURCE
of an incremental delta on stdin. The hook is registered with `--settings` on the ordinary
interactive spawn (no -p, no --bare) — verified to leave the billing pool alone.

Sink: a static sh hook script appends each payload to `<streamDir>/<session_id>.jsonl`; OCP
polls that file and forwards deltas as SSE. The per-session-id keying is MANDATORY, not an
optimization — OCP_TUI_MAX_CONCURRENT defaults to 2, so two claude panes already run at
once and a shared sink would splice one client's deltas into another's stream.

Warm-pool compatible (a separate in-flight PR depends on this): the hook script AND the
settings file are static — nothing request-specific is baked in at spawn time. The sink path
reaches the pane through its own env (OCP_TUI_STREAM_FILE) and derives from the session-id,
which a pre-booted pane fixes at boot.

The hook is SYNCHRONOUS (forceSyncExecution: claude blocks on it), so the script writes and
exits: one `cat` append, nothing else. Measured p50 7.2ms / p90 14.7ms per fire, ~50ms across
a whole turn — noise against a 6-10s turn.

It remains the terminal-turn signal, the source of the returned/cached text T, and the input
to the honesty gates. The delta stream is a low-latency MIRROR, never a replacement:

  - the truncation gate (C-2) and auth-banner gate (C-1, issue #133) run BEFORE anything is
    committed or flushed, unchanged;
  - at end of turn the streamed bytes are asserted against T. Equal -> serve. A strict PREFIX
    of T -> top up from the transcript so the client still receives exactly T (counted).
    NOT a prefix -> REFUSE the turn: SSE error frame, no cache, no success, streamDivergences++.
    Serving text the transcript disagrees with is the failure class ALIGNMENT.md exists to
    prevent, so this fails loud rather than degrading quietly;
  - only T is ever cached — never the concatenated deltas.

The auth banner needs prevention, not just detection (SSE deltas cannot be un-sent), so the
first OCP_TUI_STREAM_HOLDBACK (100) chars are withheld: the default banner detector cannot
match a message longer than 100 chars, so releasing past that provably cannot leak a banner.
A custom CLAUDE_TUI_ERROR_PATTERNS has no such bound — OCP warns at boot.

  - BANNER, before/after the spawn change: `Sonnet 4.6 with low effort · Claude Max` both,
    including on the pane the server itself spawns. Never `API Usage Billing`. Transcript
    entrypoint stays "cli". --settings is not a --bare-class flag.
  - --settings MERGES with <HOME>/.claude/settings.json rather than clobbering it (the
    user-level settings' `env` block still reached the hook), so the isolated-HOME settings
    story (permissions / additionalDirectories) survives.
  - EXACTNESS: 8/8 varied prompts (short, long, markdown, code fence, multilingual, JSON,
    table, unicode) byte-exact vs transcript T, streamed AND buffered. 0 top-ups,
    0 divergences over 15 streamed turns.
  - TTFT: buffered delivers NOTHING until the turn ends (TTFB == total, 7.5-15.8s). Streamed
    sends headers at ~25ms (heartbeat covers the pre-first-delta silence) and first content
    mid-generation, e.g. markdown 7.9s first chunk / 12.8s total; long 9.7s / 17.4s.
  - CONCURRENCY DEMUX: two concurrent streamed turns (ALPHA/BRAVO), tui.inflight peaked at 2,
    each read its own session-keyed transcript, ZERO cross-contamination.
  - AUTH-BANNER GATE under streaming, both layers: a short banner-like turn reached the client
    as 0 content chunks + an SSE error frame (never emitted); a long one was streamed but still
    ended on an error frame, not finish_reason:"stop", and was not cached.
  - DISCONNECT mid-turn: pane torn down and semaphore slot released within 1s (info-logged,
    not booked as a model error).
  - THINKING: not leaked. Opus 4.8 + xhigh turns carry a thinking block with a signature but
    `thinking:""` (the reasoning text is not persisted in interactive mode), both
    MessageDisplay text-extraction sites in the 2.1.207 bundle filter type==="text", and no
    reasoning prose appeared in any delta; concat===T held exactly on the single-message turn.
  - npm test: 282 passed, 0 failed (was 267 on main; +15).

The transcript keeps only the model's LAST assistant message. A turn where the model narrates
before calling a tool therefore has two messages, and T is only the second. If the narration
exceeds the holdback it has already been streamed and cannot be retracted -> the turn is
REFUSED. Reproduced live: Opus narrated 475 chars before a Bash call. The assembler discards
a prior message's text when nothing has been emitted yet (so short narration is handled
correctly and stays exact), and raising OCP_TUI_STREAM_HOLDBACK above the narration length
rescues the turn — verified on that exact transcript: holdback>=500 -> served, exact=true.
Documented in README and ADR 0007; this is why streaming is opt-in and off by default.

ADR 0007 line 59 ("no real token streaming — deliberate") is amended, not silently
contradicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tui): re-integrate streaming onto the warm-pane pool (#158) — install the hook at BOOT

Rebasing backlog #2 (streaming) onto #158 (warm pane pool) is not a textual merge: #158
split the monolithic runTuiTurn into bootTuiPane + runTuiTurn, and streaming had patched
the monolith. Re-integrating it in the OLD shape would have compiled, passed every existing
test, and been WRONG.

The bug that shape would have shipped: the sink was derived at TURN time from a streamDir
argument. But a POOLED pane is pre-booted long before any request exists — so on a pool HIT
runTuiTurn never cold-boots, no hook was ever registered on that pane, and the turn would
silently serve BUFFERED. Every miss streams, every hit does not; no error, no failing test.
The operator sees "streaming does nothing in production" and has nothing to grep for.

Fix — install the hook where the pane is born:
  - bootTuiPane({ streamDir }) registers the MessageDisplay hook at spawn and returns the
    pane's own sink (pane.streamFile), keyed by the pane's own --session-id. The hook script
    and settings file are STATIC (one pair per streamDir); the only per-turn thing is the
    sink path, and it is fixed at boot. So nothing request-specific is baked into a spawn.
  - runTuiTurn reads pane.streamFile — never recomputes it — so a warm pane and a cold pane
    stream through byte-for-byte the same path.
  - server.mjs threads the same streamDir into the pool's bootPane closure, so pre-booted
    panes carry the hook too. TUI_STREAM/TUI_STREAM_DIR now declare before the pool needs them.

Three regression guards added (test-features.mjs), and the third was MUTATION-TESTED: with
the fix reverted to the turn-time shape it fails ("the pooled pane's deltas must reach the
client"), with the fix in place it passes. A guard nobody has watched fail is not a guard.

/health: pool + stream* fields are now a union — the shape assertion asserts CONTAINMENT of
the seven grandfathered keys plus an exact added-set, so a future field that silently
REPLACED an original key cannot pass.

Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.
npm test: 313 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

* fix(tui): close the streaming auth-banner leak + 6 further review findings (PR #159)

Independent review (Iron Rule 10) found a HIGH bug by EXECUTING the code, not reading it.
All seven findings fixed. F1 and F3 were merge-blocking.

F1 (HIGH) — the auth-banner holdback was bypassed after the first release.
  TuiDeltaAssembler.released was set once and never reset at a message_id boundary, so the
  holdback + detectError predicate guarded only the FIRST message of a turn. In production's
  own configuration (OCP_TUI_FULL_TOOLS=1, where multi-message tool-using turns are the norm):
  the model narrates past the holdback before a tool call -> released; credentials expire
  mid-turn -> claude renders the 401 as ordinary assistant TEXT as a NEW message -> push()
  took the `if (this.released)` branch and handed the banner verbatim to the client. That is
  precisely the silent-error case the C-1 gate exists to prevent. Detection survived (the
  turn was still refused at finalize) but PREVENTION did not.
  Fix: once a message boundary follows an emit, the turn is already unrecoverable — finalize()
  will refuse it — so push() now emits NOTHING further for the rest of the turn.
  Second hole in the same predicate: detectTuiUpstreamError() trims before applying its
  <=100-char rule, so 101 whitespace chars trimmed to "" -> detector had nothing to classify
  -> returned null -> release fired having screened nothing. Release now gates on the TRIMMED
  length, so both sides of the check talk about the same string.

F2 — the "provably safe" claim in stream.mjs, ADR 0007 and README was unsound as written.
  Restated with both required halves: (i) nothing is emitted until the trimmed accumulation
  exceeds the detector's max banner length, AND (ii) no emission at all once a message
  boundary follows an emit. Half (i) alone only ever covered a turn's first message.

F3 (blocker) — prepareStreamHook was write-if-missing, so md-hook.sh could never be updated
  OR repaired: a host that booted once under an older version was stuck on that HOOK_SCRIPT
  forever, and a non-atomic write interrupted mid-flight left a TRUNCATED script that
  existsSync() called fine — on a hook claude BLOCKS on synchronously. Now written
  unconditionally via tmp+renameSync (the pattern already used by ensureTuiCwdTrusted).

F4 — the two spawn paths differed for non-streaming requests: the pool installed the hook
  whenever OCP_TUI_STREAM was on (correct — a pre-booted pane cannot know what request it will
  serve), but the cold path gated it on this turn's onDelta. So one stream:false request got
  --settings on a pool HIT and not on a MISS: two spawn argvs for the identical request, on
  this project's billing-classification surface. Both paths now gate on TUI_STREAM alone;
  whether the sink is POLLED remains correctly gated on onDelta.

F5 — pool._drop() killed the pane but orphaned its sink file; the reap tick drains the whole
  pool, so sinks accumulated with no GC path. Now removed best-effort on every drop path.

F6 — /health counters did not measure what they documented: streamTurns was incremented only
  AFTER the honesty gates, hiding exactly the turns an operator most wants to see (and making
  streamDivergences/streamTurns a meaningless ratio); streamDeltas counted every fire while
  claiming to count forwarded ones. Counters and docs now agree.

F7 — total hook failure was silent: zero fires per turn still yields ok:true/exact:false and
  a normal, fully-buffered answer. Only streamTopUps moved, which the code itself calls
  benign. Added streamZeroDeltaTurns (+ a tui_stream_zero_deltas warning) to separate "the
  hook is dead" from "one fire was dropped".

Tests: 316 passed, 0 failed (was 313). Every new guard MUTATION-TESTED — with each fix
reverted the guard named for it fails, and passes with the fix restored:
  - drop the restartedAfterEmit guard   -> 2 failed (incl. the strengthened old test)
  - revert trim() in the release gate   -> 1 failed
  - revert F3 to write-if-missing       -> 1 failed
The pre-existing test "new message_id AFTER an emit" asserted finalize().ok === false but
never checked what push() RETURNED — so it passed while F1 was live, documenting the leak
instead of catching it. Strengthened to assert the emission, not just the verdict.

Class B (ADR 0007, OCP-owned TUI spawn) — cli.js does NOT perform this operation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-07-14 08:19:24 +10:00
committed by GitHub
co-authored by taodeng Claude Fable 5
parent 9f5bc3264a
commit 1b324968f4
9 changed files with 1120 additions and 21 deletions
+5 -1
View File
@@ -959,6 +959,10 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
| `OCP_TUI_EFFORT` | `low` | (TUI-mode) Effort level passed to the interactive `claude` as an explicit `--effort` flag: `low` (default), `medium`, `high`, `xhigh`, `max`, or `inherit` to omit the flag (the pre-flag behaviour: the pane inherits a HOME-dependent effort — the operator's `~/.claude/settings.json` `effortLevel` in real-home mode, claude's built-in default in env-token scratch mode). Explicit `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh` (see `docs/plans/2026-07-13-tui-latency/`); proxied requests rarely benefit from extended thinking. Banner-verified to stay on the subscription pool (`· Claude Max`). An invalid value logs a warning and falls back to `low`. |
| `OCP_TUI_STREAM` | `0` (off) | (TUI-mode) When `=1`, `stream:true` requests emit **real SSE `delta.content` chunks as `claude` generates them**, instead of buffering the turn and replaying it. Deltas come from `claude`'s own `MessageDisplay` hook (registered with `--settings` on the ordinary interactive spawn — banner-verified to stay on the subscription pool, `· Claude Max`). Granularity is **block-level**, not token-level. The transcript remains authoritative: the streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and only the transcript text is cached. A turn whose stream cannot be reconciled with the transcript is **refused** (SSE error frame, not cached) and counted as `tui.streamDivergences` on `/health`. A total hook failure (e.g. `--settings` stops registering it after a `claude` version bump) is a *different, silent* failure mode — every streamed turn still succeeds, fully buffered, with no divergence and no error — so it is counted separately as `tui.streamZeroDeltaTurns` (streamed turns where the hook fired **zero** times) and logged as `tui_stream_zero_deltas`; watch it alongside `streamDivergences`. Default off — the buffered path is unchanged and remains the stable default. ⚠️ **Tool-using turns:** the transcript keeps only the model's **last** assistant message, so if the model narrates before calling a tool ("I'll check that file…") and that narration exceeds `OCP_TUI_STREAM_HOLDBACK`, it has already been streamed and cannot be retracted — the turn is then **refused** rather than served (measured live: Opus narrated 475 chars before a `Bash` call). If your deployment lets the model use tools (the TUI default, and anything with `OCP_TUI_FULL_TOOLS=1`), either raise `OCP_TUI_STREAM_HOLDBACK` above the typical narration length — the narration then stays held back and is correctly discarded, at the cost of a later first chunk — or leave streaming off. Streaming is best suited to tool-light chat proxying. See ADR 0007 (2026-07-13 amendment). |
| `OCP_TUI_STREAM_HOLDBACK` | `100` | (TUI-mode, streaming) Characters withheld before the first chunk reaches the client. Two jobs. (1) It keeps the **auth-banner gate** alive under streaming, via a guarantee with two required halves: (i) nothing is emitted for a message until its trimmed accumulation exceeds 100 chars — past the default banner detector's reach, since real banners are ≤100 chars — and (ii) once a message boundary follows an emit, nothing further is ever emitted for the rest of the turn, and the turn is refused outright. Half (i) alone only covers a turn's first message; half (ii) is what covers an error banner rendered as a *later* message (e.g. after tool-using prose). Raise the holdback if you replace the detector via `CLAUDE_TUI_ERROR_PATTERNS` with patterns that can match longer messages — that only affects half (i); OCP warns at boot if you do. (2) It is the knob for **tool-using turns** — see the `OCP_TUI_STREAM` caveat below. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does. |
| `OCP_TUI_STREAM_DIR` | `$HOME/.ocp-tui/stream` | (TUI-mode, streaming) Directory holding the static `MessageDisplay` hook script + settings file, and the per-session delta sink (`<session-id>.jsonl`, removed at turn teardown). One sink **per session-id** — this is what keeps concurrent TUI turns (`OCP_TUI_MAX_CONCURRENT` ≥ 2) from interleaving one client's deltas into another's stream. |
| `OCP_TUI_STREAM_POLL_MS` | `100` | (TUI-mode, streaming) Interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing. |
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
| `OCP_TUI_POOL_SIZE` | `0` (off) | (TUI-mode) Number of **pre-booted warm `claude` panes** kept ready, so a request does not pay the cold boot. `0` disables the pool entirely — the request path is then exactly the cold-boot path. Max `4`; an unparseable value disables it rather than guessing. **Measured on a Mac mini (Sonnet 4.6, `--effort low`): end-to-end p50 `10.17s` (n=6, pool off) → `6.00s` (n=12 warm hits) — 4.2 s / 41%** — the pool recovers both the ~1.2 s boot *and* ~2.9 s of post-input-bar init that a pane which has been idle a moment has already finished. **Cost:** each warm pane is a *live idle `claude` process* held whether or not a request ever arrives (peak processes ≈ pool size + `OCP_TUI_MAX_CONCURRENT` + 1 booting replacement) — which is why it is opt-in. Panes are **single-use**: one turn, then killed and replaced in the background. The **first request after start (and after any model switch) is always a cold miss** — the pool warms the most recently requested model, since OCP cannot know which model the next caller wants. See `docs/plans/2026-07-13-tui-latency/`. |
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
@@ -1034,7 +1038,7 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
### What changes / what doesn't
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **No real token streaming *today* — but it is achievable, and planned.** TUI-mode currently buffers the full response then replays it as chunked SSE: you see a delay, then the complete response. This is a limitation of the current implementation, **not** of the path — `claude` fires a `MessageDisplay` hook carrying incremental, byte-faithful `delta`s of the raw reply (they concatenate exactly to the final text, and stay prefix-stable), on the subscription pool, without `-p`. Wiring it into OCP's SSE is tracked as backlog item #2. What is *not* available is token-by-token granularity (the hook fires once per rendered block — roughly one per paragraph, list item, or code block, so the count scales with answer length) — which is plenty for SSE. Evidence: [`docs/plans/2026-07-13-tui-latency/streaming-spike.md`](docs/plans/2026-07-13-tui-latency/streaming-spike.md).
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`docs/plans/2026-07-13-tui-latency/streaming-spike.md`](docs/plans/2026-07-13-tui-latency/streaming-spike.md).
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~2035K context floor of interactive mode); MCP is hard-disabled.
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.