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
+12
View File
@@ -1,3 +1,5 @@
import { rmSync } from "node:fs";
// TUI warm pane pool (docs/plans/2026-07-13-tui-latency backlog #3).
//
// WHAT IT IS: a small set of PRE-BOOTED `claude` panes, each already sitting at its
@@ -304,6 +306,16 @@ export class TuiPanePool {
_drop(pane, reason) {
this.dropped++;
try { this._killPane(pane.name); } catch { /* already gone */ }
// F5: every drop path (expired / unhealthy / model_switch / drain / cancelled_late /
// stale_boot) ends up here, and the reap tick drains the WHOLE pool on every tick — so
// without this, every warm pane's sink orphans in streamDir with no GC path (killPane only
// reaches the tmux session, never the pane's OWN files). Best-effort: pane.streamFile is
// undefined for a still-booting identity (the sink path is only known once bootPane
// resolves) and rmSync(force:true) is already a no-op on a missing file, so this never
// throws into the reaper regardless of which drop path got here.
if (pane.streamFile) {
try { rmSync(pane.streamFile, { force: true }); } catch { /* best-effort GC */ }
}
this._log("info", "tui_pool_pane_dropped", { name: pane.name, reason });
}
}
+35 -1
View File
@@ -144,7 +144,35 @@ export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
// when the pool is off (the default). Reported as `pool: null` when off so the block's
// shape stays stable, and as the pool's stats (size / warm / hits / misses / …) when on —
// the operator's window onto both the hit rate and the standing idle-process cost.
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore, pool = null) {
//
// Streaming fields (backlog #2, OCP_TUI_STREAM) are ADDITIVE too:
// streamEnabled — is real (MessageDisplay-hook) SSE streaming on for TUI turns?
// streamTurns — streamed turns ATTEMPTED, counted before the truncation/auth-banner
// gates run (F6) — so a turn REFUSED by those gates still shows up
// here, which is exactly the turn an operator most wants visible.
// Counting only turns that survived the gates would silently exclude
// a turn's worst-case outcome from its own denominator.
// streamDeltas — MessageDisplay hook fires OBSERVED, including held-back ones (F6) —
// NOT only the ones forwarded to a client. This is what makes
// streamZeroDeltaTurns meaningful: a turn can have streamDeltas
// incrementing while still emitting nothing to the client (fully held
// back, e.g. a short answer), which is healthy, vs. a hook that fired
// zero times at all, which is not (see streamZeroDeltaTurns).
// streamTopUps — turns where the delta stream was a safe PREFIX of the transcript but
// not equal to it; OCP topped up from the transcript and served T.
// Benign but worth watching — a persistent rate means the hook is
// losing fires.
// streamDivergences — turns REFUSED because emitted bytes were not a prefix of the
// transcript. THE field to alert on for CORRECTNESS: it means the hook
// and the transcript disagreed and OCP chose to fail rather than serve
// unverifiable text.
// streamZeroDeltaTurns — streamed turns where the hook fired ZERO times (F7). THE field to
// alert on for AVAILABILITY: streamTopUps climbing is one fire dropped
// here and there (benign); this climbing means the hook is not firing
// AT ALL — e.g. `--settings` silently stopped registering it (a claude
// version bump), or F3's truncated-script failure mode — and every
// streamed turn is quietly degrading to fully-buffered with no error.
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent, streamEnabled = false }, tuiStats, semaphore, pool = null) {
return {
enabled,
entrypointMode, // cli | auto | off
@@ -154,5 +182,11 @@ export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent },
queued: semaphore.queued, // turns waiting for a slot
maxConcurrent,
pool: pool ? pool.stats() : null, // warm pane pool, or null when disabled
streamEnabled,
streamTurns: tuiStats.streamTurns ?? 0,
streamDeltas: tuiStats.streamDeltas ?? 0,
streamTopUps: tuiStats.streamTopUps ?? 0,
streamDivergences: tuiStats.streamDivergences ?? 0,
streamZeroDeltaTurns: tuiStats.streamZeroDeltaTurns ?? 0,
};
}
+135 -9
View File
@@ -14,6 +14,7 @@ import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { readTuiTranscript } from "./transcript.mjs";
import { prepareStreamHook, streamFilePath, parseDeltaChunk } from "./stream.mjs";
// F7 fix (audit finding, LOW): the prefix used to be a bare, host-wide constant
// ("ocp-tui-"), so a SECOND OCP instance on the same host (e.g. a temporary
@@ -167,6 +168,10 @@ const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
export const POOL_BOOT_MS = BOOT_MS * 5;
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
// Hook-sink drain interval when streaming. 100ms: the hook fires at BLOCK granularity
// (~5-7 fires per answer, seconds apart), so a finer poll buys nothing and a coarser one
// would add visible lag to the first delta. Cheap — one readFileSync of a small file.
const STREAM_POLL_MS = parseInt(process.env.OCP_TUI_STREAM_POLL_MS || "100", 10);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -348,7 +353,25 @@ export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false }
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
//
// `stream` (optional, OCP_TUI_STREAM): { file, settings } — when present, the pane gets
// (a) OCP_TUI_STREAM_FILE in its env — read by the static MessageDisplay hook script to
// decide WHERE to append this pane's deltas. Delivered as env (not baked into the
// settings file) so the settings file stays STATIC and a pre-booted warm pane works.
// Verified live: a claude hook inherits the pane's environment.
// (b) --settings <file> — registers the MessageDisplay hook.
// VERIFIED LIVE (claude 2.1.207, this host) before shipping, because both were spawn-level
// risks:
// - the startup banner is UNCHANGED with --settings: "Sonnet 4.6 with low effort ·
// Claude Max" (subscription pool). --settings is NOT a --bare-class flag — it does not
// silently drop the subscription pool. Transcript entrypoint stayed "cli".
// - --settings MERGES into the settings hierarchy, it does NOT clobber <HOME>/.claude/
// settings.json: with --settings passed, the user-level settings.json's `env` block was
// still applied to the hook's environment. So the isolated-HOME settings story the TUI
// already relies on (permissions / additionalDirectories — see prepareTuiHome and the
// OCP_TUI_FULL_TOOLS note above) survives intact.
// When absent, the argv is byte-for-byte the pre-streaming argv.
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode, stream = null) {
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
@@ -392,6 +415,8 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
}
// Streaming sink: the pane's own per-session delta file (see the `stream` note above).
if (stream && stream.file) sets.push(`OCP_TUI_STREAM_FILE=${shq(stream.file)}`);
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
@@ -448,6 +473,10 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
effortArgs = ["--effort", "low"];
}
// --settings registers the MessageDisplay hook. Omitted entirely when streaming is off,
// so the OFF argv is byte-for-byte the pre-streaming argv.
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
return [
envPrefix,
shq(claudeBin),
@@ -455,6 +484,7 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
"--session-id", sessionId,
...toolArgs,
...effortArgs,
...settingsArgs,
].join(" ");
}
@@ -504,9 +534,16 @@ export function poolPaneName(port, sessionId) {
// readiness wait returns, so a pool that only learned the name on resolve could neither spare
// the session from the reaper nor kill it on shutdown. Supplying BOTH also keeps the name's
// hex suffix equal to the session-id's, so `tmux ls` correlates to the transcript file.
// `streamDir` (optional, OCP_TUI_STREAM): install claude's MessageDisplay hook on this pane.
// Done HERE, at boot — not at turn time — and that is the whole reason streaming survives the
// WARM POOL: the hook script + settings file are STATIC (one pair per streamDir), and the only
// per-turn thing, the sink path, is derived from the pane's own --session-id, which is fixed
// right here. So a pre-booted pane already carries its hook and its own sink and streams exactly
// like a cold-booted one; nothing request-specific is ever baked into the spawn.
export async function bootTuiPane({
model, claudeBin, home, realHome, cwd, port, entrypointMode = "cli",
tmux = defaultTmux, sessionId = null, name = null, requireReady = false, bootMs = BOOT_MS,
streamDir = null,
}) {
const sid = sessionId || randomUUID();
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
@@ -528,6 +565,15 @@ export async function bootTuiPane({
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
// Streaming sink for THIS pane (see the streamDir note above). rmSync first so a
// re-used session-id can never replay a previous turn's deltas.
let streamFile = null, streamSettings = null;
if (streamDir) {
streamFile = streamFilePath(streamDir, sid);
streamSettings = prepareStreamHook(streamDir);
try { rmSync(streamFile, { force: true }); } catch { /* start from a fresh sink */ }
}
// Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
// from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
// spawning process's env to the pane, so the {env} here is intentionally minimal.
@@ -540,7 +586,8 @@ export async function bootTuiPane({
// session or issue a billing request without a verified interactive context.
const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode)],
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode,
streamFile ? { file: streamFile, settings: streamSettings } : null)],
{ env },
);
if (!spawnResult || spawnResult.status !== 0) {
@@ -559,7 +606,7 @@ export async function bootTuiPane({
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
console.error("[tui] input_not_ready", tmuxName);
}
return { name: tmuxName, sessionId: sid, model, ehome, bootedAt: Date.now() };
return { name: tmuxName, sessionId: sid, model, ehome, streamFile, bootedAt: Date.now() };
}
// Full per-request TUI lifecycle:
@@ -582,6 +629,33 @@ export async function bootTuiPane({
// pool refill so the next request finds a warm pane.
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
//
// STREAMING (OCP_TUI_STREAM, default off). Pass `onDelta` and `streamDir`, and the pane's
// MessageDisplay hook (installed by bootTuiPane; see lib/tui/stream.mjs) appends each raw
// delta payload to the pane's own sink. This driver polls that sink and invokes onDelta(payload)
// per fire while the turn is still generating. A WARM pane already carries its sink from boot
// (pane.streamFile), so the pooled and cold paths stream identically.
//
// `streamDir` IS PASSED TO THE COLD BOOT UNCONDITIONALLY (not gated on `onDelta`) — F4 fix. The
// spawn argv is this project's billing-classification surface: a caller with OCP_TUI_STREAM on
// but THIS particular request non-streaming (stream:false) must still get the SAME argv whether
// it lands on a pool HIT or a cold-boot MISS, because a pre-booted pool pane cannot know in
// advance whether the request it will eventually serve wants streaming — it installs the hook
// unconditionally whenever the pool is warming at all (see server.mjs's bootPane closure). Gating
// the cold boot's hook install on `onDelta` made a stream:false request's argv depend on whether
// it happened to hit the pool or miss it — the exact drift this surface cannot tolerate. Whether
// the hook is actually POLLED is a separate, correctly-scoped decision: see `streaming` below,
// gated on onDelta && streamFile, so a non-streaming turn never reads its own sink even though
// the hook is running.
//
// The transcript stays AUTHORITATIVE regardless: it is still the terminal-turn signal, still the
// source of the returned `text`, and still the input to the caller's honesty gates. The delta
// stream is a low-latency MIRROR of it, never a replacement, and the caller asserts the two
// agree. With onDelta AND streamDir both omitted, nothing here changes: no poll, no hook.
//
// `abortSignal` (optional): aborts the transcript wait, so a client that disconnects mid-turn
// tears the pane down NOW (the finally below) instead of holding the pane — and therefore the
// caller's semaphore slot — until the turn or the wallclock cap ends.
export async function runTuiTurn({
prompt,
model,
@@ -595,6 +669,10 @@ export async function runTuiTurn({
tmux = defaultTmux,
pool = null, // TuiPanePool | null — null (default) === today's cold-boot-only path
onPane = null, // optional observer: ({ warm }) => void, for logging/metrics
onDelta = null, // (payload) => void — invoked per MessageDisplay hook fire, mid-turn
streamDir = null, // hook sink dir, passed to the COLD boot UNCONDITIONALLY (F4 — see above);
// a warm pane brings its own, fixed at its own boot
abortSignal = null,
}) {
// 1. Warm pane, or cold boot. A MISS is never an error — it is exactly today's path.
let pane = pool ? pool.acquire(model) : null;
@@ -607,12 +685,36 @@ export async function runTuiTurn({
if (pool) pool.refill();
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
if (!pane) {
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux });
// streamDir passed AS-IS (not gated on onDelta) — F4: see the STREAMING comment above.
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux,
streamDir });
}
const tmuxName = pane.name;
const sessionId = pane.sessionId; // THIS pane's own session-id — one session, one turn
const ehome = pane.ehome || home || process.env.HOME;
// Streaming state is read off the PANE, not recomputed here — a warm pane fixed its sink at
// boot, and a cold one just did the same above. If the pool was booted WITHOUT a streamDir
// while onDelta is set, streamFile is null and the turn degrades to buffered: correct, just
// not fast. (server.mjs wires the same streamDir into both paths so that cannot happen.)
const streamFile = pane.streamFile || null;
const streaming = !!(onDelta && streamFile);
const streamCursor = { consumed: 0 };
let streamStopped = false;
let pollTimer = null;
// Drain every complete line appended since the last drain. Never throws into the turn: a
// malformed line is skipped by parseDeltaChunk, and an onDelta that throws is contained.
const drainDeltas = () => {
if (!streaming) return;
let text;
try { text = readFileSync(streamFile, "utf8"); } catch { return; } // absent until the first fire
const { deltas, consumed } = parseDeltaChunk(text, streamCursor.consumed);
streamCursor.consumed = consumed;
for (const d of deltas) {
try { onDelta(d); } catch { /* a sink error must never abort the turn */ }
}
};
// Write prompt to a temp file (mode 0600) so the content never touches argv.
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
@@ -644,13 +746,37 @@ export async function runTuiTurn({
// Submit (separate Enter key event).
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 5. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
// Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
// 5a. Streaming only: start polling the hook sink. Runs CONCURRENTLY with the
// transcript wait below — the deltas are what make the answer visible while the
// turn is still generating; the transcript is what makes it authoritative.
if (streaming) {
const loop = () => {
if (streamStopped) return;
drainDeltas();
pollTimer = setTimeout(loop, STREAM_POLL_MS);
};
pollTimer = setTimeout(loop, STREAM_POLL_MS);
}
// 5b. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
// Returns { text, entrypoint, truncated } from readTuiTranscript.
const result = await readTuiTranscript({ home: ehome, sessionId, wallclockMs, abortSignal });
// 5c. FINAL drain. The terminal marker can land between two poll ticks, so the last
// delta(s) may still be unread — without this the tail would be missing from the
// stream and every turn would need a transcript top-up.
streamStopped = true;
if (pollTimer) clearTimeout(pollTimer);
drainDeltas();
return result;
} finally {
// 6. Teardown — always, even on throw. A pooled pane is torn down here exactly like a
// cold-booted one: SINGLE-USE, never returned to the pool (see pool.mjs).
// 6. Teardown — always, even on throw (including an abortSignal disconnect, which is
// exactly why the pane cannot outlive a client that walked away). A pooled pane is
// torn down here exactly like a cold-booted one: SINGLE-USE, never returned (pool.mjs).
streamStopped = true;
if (pollTimer) clearTimeout(pollTimer);
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
if (streamFile) { try { rmSync(streamFile, { force: true }); } catch { /* best effort */ } }
}
}
+260
View File
@@ -0,0 +1,260 @@
// TUI-mode real SSE streaming — the `MessageDisplay` hook sink.
//
// WHAT THIS IS. `claude` fires a **MessageDisplay** hook per rendered block of the
// assistant's reply, handing the hook the RAW MARKDOWN SOURCE of an incremental
// `delta` on stdin. Registered via `--settings` on the ordinary interactive TUI spawn
// (NO -p, NO --bare — the billing pool is untouched), it is the only byte-faithful
// incremental source the interactive CLI exposes. Everything here consumes that hook
// surface AS EMITTED — forwarding, not inventing.
//
// ALIGNMENT.md: **Class B**. We consume claude's own hook payload and re-emit it in the
// OpenAI chat/completions streaming shapes OCP already speaks (ADR 0006). There is no
// `cli.js` citation because no `cli.js` function is being mirrored: the TUI spawn is
// OCP-owned surface (ADR 0007), and the hook payload is claude's own published contract.
//
// THE VERIFIED CONTRACT (docs/plans/2026-07-13-tui-latency/streaming-spike.md, and
// independently reproduced on claude 2.1.207 / sonnet-4-6 / banner `· Claude Max`):
//
// payload (stdin, one JSON object per fire):
// { hook_event_name:"MessageDisplay", session_id, transcript_path, prompt_id, cwd,
// turn_id, message_id, index, final, delta }
//
// - deltas carry the raw markdown source (`## `, `**`, ```javascript all present)
// - concat(deltas of one message) === T, byte-exactly (T = extractLatestAssistantText)
// - T.startsWith(concat(deltas[0..n])) at EVERY n (prefix-stable)
// - block-level granularity (~5-7 fires per answer), NOT token-level
// - only `text` blocks fire it — thinking blocks are excluded (what OCP wants)
//
// ⚠️ THE HOOK IS SYNCHRONOUS. The hook's source sets `forceSyncExecution: true` —
// `claude` BLOCKS on every fire. The hook script must therefore write and exit, doing
// NO work inline. Measured cost of the script below: p50 7.2 ms / p90 14.7 ms per fire,
// i.e. ~50 ms added blocking across a whole ~7-delta turn against a 6-10 s turn. That is
// noise, so a plain append is the right sink — a FIFO would be faster on paper but a FIFO
// blocks its writer until a reader attaches, which would hand `claude` a way to hang.
//
// WARM-POOL COMPATIBILITY (load-bearing — a warm pane pool is a separate in-flight PR).
// The hook script and the settings file are BOTH STATIC: one copy per stream dir, written
// once, never per-request. The per-turn destination is carried in the PANE'S OWN ENV as
// `OCP_TUI_STREAM_FILE` (verified live: a hook inherits the pane's environment), and the
// path is derived from the session-id — which for a pre-booted pane is fixed at BOOT.
// Nothing about a request is baked into the settings file at spawn time, so a pane booted
// before its request arrives streams exactly the same way.
import { writeFileSync, mkdirSync, renameSync } from "node:fs";
import { detectTuiUpstreamError } from "./transcript.mjs";
// Default holdback before the first byte is released to the client. See TuiDeltaAssembler.
export const DEFAULT_HOLDBACK_CHARS = 100;
// The hook script. POSIX sh, no interpreter startup beyond /bin/sh, one fork (`cat`).
//
// - `printf` is a shell BUILTIN in sh/dash/bash, so the newline costs no fork.
// - the `{ cat; printf '\n'; } >>` group opens the file ONCE and appends both writes
// through the same O_APPEND fd, so a payload and its terminator can never be split
// by another writer. (They never race anyway: one file per pane, and MessageDisplay
// is synchronous within a pane.)
// - a payload JSON can never contain a literal newline — JSON.stringify escapes them —
// so "one line == one payload" holds, and a torn write is always a trailing partial
// line, which parseDeltaChunk() leaves unconsumed until it completes.
// - NO OCP_TUI_STREAM_FILE (e.g. a pane booted with streaming off, or any other claude
// session that happens to load this settings file) => swallow stdin and exit 0. The
// hook must NEVER fail or block: claude is waiting on it.
export const HOOK_SCRIPT = `#!/bin/sh
# OCP TUI streaming sink — claude fires this per MessageDisplay block and BLOCKS on it.
# Write and exit. Never do work here.
[ -n "\$OCP_TUI_STREAM_FILE" ] || exec cat >/dev/null
{ cat; printf '\\n'; } >> "\$OCP_TUI_STREAM_FILE"
`;
// The --settings payload registering the hook. Static: no per-request data.
export function buildStreamSettings(hookScriptPath) {
return { hooks: { MessageDisplay: [{ hooks: [{ type: "command", command: hookScriptPath }] }] } };
}
export const hookScriptPath = (streamDir) => `${streamDir}/md-hook.sh`;
export const streamSettingsPath = (streamDir) => `${streamDir}/settings.json`;
// One file per session-id. For a pre-booted (warm) pane the session-id is fixed at boot,
// so this path is knowable at boot — which is what keeps the pool compatible.
export const streamFilePath = (streamDir, sessionId) => `${streamDir}/${sessionId}.jsonl`;
// Atomic write: temp file + rename (same-directory, same-filesystem, so rename is atomic on
// POSIX). A process killed mid-`writeFileSync` leaves the TEMP file half-written, never the
// real path — `path` always names either the old complete content or the new complete
// content, never a torn one. That matters specifically for md-hook.sh: it is SYNCHRONOUS
// (claude blocks on every fire), so a truncated script would still pass `existsSync`, still
// get exec'd, and fail/hang on every single MessageDisplay fire with no operator-visible
// symptom short of streaming going silently dead (F7's streamZeroDeltaTurns is the backstop
// for exactly that). Mirrors ensureTuiCwdTrusted's tmp+renameSync pattern in session.mjs.
function writeFileAtomic(path, content, mode) {
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, content, { mode });
renameSync(tmp, path);
}
// Write the static hook script + settings file into `streamDir`. UNCONDITIONAL, not
// write-if-missing: these files persist across OCP restarts at `streamDir`, so a host that
// booted once under an older version and never had its stream dir cleared would otherwise be
// silently stuck on a stale HOOK_SCRIPT / buildStreamSettings() forever — no future OCP
// upgrade could ever reach it. Safe to call every boot: the content is static (no per-request
// data), so a same-content rewrite is the overwhelmingly common case and costs two tiny
// atomic writes, not a per-turn expense. Returns the settings path to hand to `claude
// --settings`.
export function prepareStreamHook(streamDir) {
mkdirSync(streamDir, { recursive: true });
const script = hookScriptPath(streamDir);
const settings = streamSettingsPath(streamDir);
writeFileAtomic(script, HOOK_SCRIPT, 0o700);
writeFileAtomic(settings, JSON.stringify(buildStreamSettings(script), null, 2), 0o600);
return settings;
}
// Parse newly-appended sink lines. `consumed` is the number of COMPLETE lines already
// taken; only lines terminated by "\n" are complete, so a payload caught mid-write stays
// unconsumed until its terminator lands. Returns the fresh MessageDisplay payloads plus
// the new consumed count. Pure — the caller owns the cursor.
export function parseDeltaChunk(text, consumed = 0) {
const lines = String(text ?? "").split("\n");
const complete = lines.slice(0, -1); // the tail after the last "\n" is a partial line
const deltas = [];
for (const line of complete.slice(consumed)) {
const t = line.trim();
if (!t) continue;
try {
const o = JSON.parse(t);
if (o && o.hook_event_name === "MessageDisplay" && typeof o.delta === "string") deltas.push(o);
} catch { /* not ours / not parseable — skip, never throw into the request path */ }
}
return { deltas, consumed: complete.length };
}
// ── The assembler: hook deltas → client bytes, with the honesty gates intact ──
//
// Two jobs, both load-bearing.
//
// 1. THE AUTH-BANNER HOLDBACK (C-1 / issue #133 must survive streaming).
// The interactive CLI renders an auth failure as ordinary assistant TEXT — so an
// expired-credential turn fires MessageDisplay with the BANNER as its delta, and a
// naive forwarder would stream "Please run /login · API Error: 401 …" to the client as
// a normal answer, exactly the silent-error case C-1 exists to prevent.
// detectTuiUpstreamError() classifies a WHOLE message, so it cannot be run per-delta.
// Instead we HOLD BACK the first `holdbackChars` characters. The default detector only
// ever fires on a message of <= 100 chars (TUI_ERR_MAX_LEN — real banners are 69 and 73),
// so once the TRIMMED accumulation EXCEEDS 100 chars the final text cannot be a banner by
// that detector's own length rule, and releasing is safe. An answer that never exceeds the
// holdback is simply delivered whole at terminal — i.e. exactly today's buffered
// behaviour, gates and all.
// THE GUARANTEE HAS TWO HALVES, both required — neither alone is sufficient:
// (i) Nothing is emitted for a message until its trimmed accumulation exceeds the
// detector's max banner length. This is what keeps the FIRST message of a turn
// safe: a banner-length message can never clear the holdback.
// (ii) Once a message boundary follows an emit (`restartedAfterEmit`), push() stops
// emitting ENTIRELY for the rest of the turn — a SECOND message (e.g. an
// auth-failure banner rendered mid-turn, after tool-using prose already streamed)
// gets zero bytes forwarded, not just a fresh holdback of its own. finalize() then
// refuses the whole turn (SSE error frame, no cache) precisely because the first
// message's bytes are unretractable and unverifiable against T. Without this half,
// (i) alone only protects the FIRST message per turn — see F1.
// ⚠️ Soundness is w.r.t. the DEFAULT detector. An operator who REPLACES it via
// CLAUDE_TUI_ERROR_PATTERNS with a pattern that can match a longer message must raise
// OCP_TUI_STREAM_HOLDBACK past their longest banner; server.mjs warns at boot. That is the
// one case (i) does not cover — (ii) still applies regardless. Even past both, the
// terminal gate still refuses to cache a banner and still ends the stream on an SSE error
// frame rather than finish_reason:"stop" — the holdback is the first of two layers, not
// the only one.
//
// 2. MESSAGE SCOPING (keeps `concat === T` the RIGHT assertion).
// The transcript's T is extractLatestAssistantText() — the LAST text-bearing assistant
// entry, not every assistant entry. A tool-using turn therefore has TWO messages
// (prose → tool_use → answer) and T is only the second. So the assembler scopes to the
// CURRENT message_id: when a new message_id appears and NOTHING has been emitted yet,
// the held text is DISCARDED — the transcript is about to discard it too, so this keeps
// us byte-identical to the buffered path instead of streaming prose the buffered path
// would have dropped. When a new message_id appears AFTER we have already emitted, the
// bytes are gone and cannot be retracted: finalize() then reports !ok and the caller
// fails the turn loudly (SSE error frame, no cache, counted on /health). Fail-loud is
// the correct posture — a proxy that silently serves text the transcript disagrees with
// is the exact class of bug ALIGNMENT.md exists to prevent.
export class TuiDeltaAssembler {
constructor({ holdbackChars = DEFAULT_HOLDBACK_CHARS, detectError = detectTuiUpstreamError } = {}) {
this.holdbackChars = holdbackChars;
this.detectError = detectError;
this.emitted = ""; // bytes ALREADY written to the client — unretractable
this.pending = ""; // held back, not yet written
this.released = false;
this.messageId = null;
this.deltas = 0; // hook fires seen
this.messages = 0; // distinct message_ids seen
this.restartedAfterEmit = false;
}
// All hook bytes for the CURRENT message (emitted + still held).
get full() { return this.emitted + this.pending; }
// Feed one MessageDisplay payload. Returns the text to emit NOW, or null (held back).
push(payload) {
const delta = payload && typeof payload.delta === "string" ? payload.delta : "";
const mid = payload ? payload.message_id : null;
if (mid !== this.messageId) {
this.messageId = mid;
this.messages++;
if (this.emitted === "") {
this.pending = ""; // safe: the transcript will drop this message too
} else if (this.messages > 1) {
this.restartedAfterEmit = true; // unrecoverable — finalize() will refuse the turn
}
}
this.deltas++;
// F1: once a message boundary has followed an emit, the turn is ALREADY unrecoverable —
// finalize() will refuse it (see restartedAfterEmit above). `this.released` stays true
// from the FIRST message's release and, uncorrected, lets every later message's deltas
// stream straight through unfiltered — exactly the auth-banner-mid-turn leak this class
// exists to prevent. Stop emitting HERE, permanently, for the rest of the turn: there is
// nothing left to gain from continuing to forward bytes for a turn that will be refused,
// and every byte forwarded now is one more the client cannot be told to un-see.
if (this.restartedAfterEmit) return null;
if (!delta) return null;
if (this.released) {
this.emitted += delta;
return delta;
}
this.pending += delta;
// Release only once the TRIMMED accumulation is past the banner detector's reach.
// detectTuiUpstreamError() trims before measuring length (TUI_ERR_MAX_LEN is a trimmed-
// length bound), so gating release on the UNTRIMMED pending.length let a run of >
// holdbackChars whitespace trim down to "" — detectError("") sees nothing to classify,
// returns null, and release fires with the holdback never having actually screened
// anything. Trimming here keeps both sides of the check talking about the same string.
if (this.pending.trim().length > this.holdbackChars && this.detectError(this.pending) == null) {
const out = this.pending;
this.pending = "";
this.released = true;
this.emitted += out;
return out;
}
return null;
}
// Reconcile against the AUTHORITATIVE transcript text T. Call only AFTER the truncation
// and auth-banner gates have passed. Returns:
// { ok:true, tail, exact } — tail is the remaining text to emit (may be ""). `exact`
// is concat(deltas) === T; when false we still serve exactly
// T, having topped up from the transcript, and the caller
// counts a topUp.
// { ok:false, ... } — what we already emitted is NOT a prefix of T. The client
// holds bytes the transcript disagrees with; the caller must
// NOT cache and must end the stream on an SSE error frame.
finalize(T) {
const text = typeof T === "string" ? T : "";
const full = this.full;
if (!text.startsWith(this.emitted)) {
return { ok: false, tail: null, exact: false, emitted: this.emitted.length, transcript: text.length };
}
return {
ok: true,
tail: text.slice(this.emitted.length),
exact: full === text,
emitted: this.emitted.length,
transcript: text.length,
};
}
}
+11 -1
View File
@@ -267,11 +267,21 @@ export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TU
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
// file does not exist until the turn starts, so resolution happens inside the loop.
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
// `abortSignal` (optional): when it fires, stop waiting and throw TuiAbortError. The one
// caller that passes it is the STREAMING TUI path, which ties it to the client's socket:
// a client that disconnects mid-turn should not leave the pane running (and the caller's
// concurrency slot held) until the turn or the 120s cap ends. runTuiTurn's finally does the
// teardown. Omitted => the loop is byte-for-byte the pre-streaming loop.
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250, abortSignal = null }) {
const deadline = Date.now() + wallclockMs;
let lastText = "";
let lastEntrypoint = null;
while (Date.now() < deadline) {
if (abortSignal && abortSignal.aborted) {
const err = new Error("tui_aborted: client disconnected before the turn completed");
err.name = "TuiAbortError";
throw err;
}
const resolved = p || findTranscriptPath(home, sessionId);
if (resolved && existsSync(resolved)) {
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));