Compare commits

..
Author SHA1 Message Date
taodengandClaude Fable 5 551d4e7db6 docs: fold in re-review — the two caveats that would have bitten the implementer
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
2026-07-13 16:30:14 +10:00
taodengandClaude Fable 5 ddaea4df17 docs: REVERSE the streaming verdict — MessageDisplay hook makes it achievable
The previous commit on this branch concluded TUI streaming was impossible. That was
WRONG, and this corrects it before it could be merged.

The adversarial reviewer commissioned to refute the claim found, on a second pass while
verifying the fold-in, that its OWN first-pass hook enumeration had been truncated by a
400-char grep cap: it reported 21 hook events; the shipped 2.1.207 bundle has 30.
Event #30 is MessageDisplay.

Independently reproduced before acting on it (30 events confirmed via `strings` on the
binary; payload shape `hook_event_name:"MessageDisplay",turn_id,message_id,index,final,
delta`), then live-tested with a MessageDisplay command hook registered via --settings on
a PLAIN INTERACTIVE TUI spawn (no -p, no --bare), claude-sonnet-4-6, --effort low:

  banner: "Sonnet 4.6 with low effort · Claude Max"   ← subscription pool, verified

  7 fires, mid-turn, spread across generation:
    index=0 final=false  '## Mutex\n\n'
    index=1 final=false  'A **mutual exclusion lock** prevents concurrent access to a shar…'
    index=4 final=false  'let counter = 0;\n\nasync function increment() {\n  const release =…'
    index=6 final=true   '```'

  concat(deltas) === T (transcript-authoritative)  ->  TRUE  (579 == 579 bytes)
  T.startsWith(S) at EVERY step                    ->  TRUE  (prefix-stable)
  '## ' / '**' / '```javascript' present in deltas ->  raw markdown SOURCE, not rendered

This satisfies every invariant the previous version declared unobtainable: byte-faithful,
incremental, prefix-stable, no -p, subscription pool. Granularity is block-level (~5-7
chunks/answer), not token-level — which is all an SSE delta.content needs.

Backlog #2 REOPENS and should be built. Implementer caveat recorded: the hook's source
sets forceSyncExecution -> claude BLOCKS on it, so the hook must write and exit
immediately (FIFO/socket), never work inline. Only text blocks fire it (thinking excluded).
ALIGNMENT: consumes claude's OWN hook surface as emitted — forwarding, not inventing
(Class B / ADR 0007; no cli.js citation applies).

Everything still true is kept, and the dead ends are kept as dead ends (they document what
NOT to build): the pane is a rendered view whose source markers are irrecoverable
(capture-pane -e emits IDENTICAL SGR 1 for an H2 and a bold span — a provably non-unique
inverse); the transcript is event-granular; --debug-file carries timing but no payload;
--output-format stream-json requires -p (the metered pool). Also kept: the ~4s (n=1
same-turn) overhead correction, backlog #4's null result with its mechanistic single-user
reason, and the honest value framing — streaming moves the FIRST byte, not the last, so
the complete-answer consumer that motivated this work gains nothing from it.

The wrong conclusion and its refutation are both preserved in the doc. "We checked, it's
impossible" is the most expensive claim to get wrong: it closes a door nobody re-opens.

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
2026-07-13 16:16:12 +10:00
taodengandClaude Fable 5 fc63b8a49a docs: fold in adversarial review — correct the debug-log reasoning + the overhead number
Independent adversarial reviewer (tasked with REFUTING this doc) confirmed the central
claim — no byte-faithful incremental source exists on the TUI path — but found four
factual defects in the prose. A negative claim that will be cited for years has to be
right in its reasoning, not just its conclusion.

1. --debug-file: the "written at end-of-turn" reasoning was WRONG. The default log level
   is `debug`, which suppresses every `verbose` site; the original probe therefore ran
   with the stream logging OFF. At CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose there ARE 16
   mid-turn `[shoji-engine] yield stream_event/-` lines spread over ~3.9s of generation.
   The conclusion survives because those lines carry TIMING ONLY, no text payload
   (content_block_delta / text_delta / content_block_start / message_start = 0 at any
   verbosity or category filter). Reasoning rewritten: "logs when tokens arrive, never
   what they are" — as written before, the doc was falsifiable in 30 seconds.

2. The 7.319s `turn_duration` is NOT a "native" (non-OCP) baseline: it comes from an
   OCP-driven turn (cwd .ocp-tui/work, same 7451-char prompt, same 204-char answer as
   pr1 baseline row i=5, elapsed 11563ms). Reframed as what it actually is — a SAME-TURN
   decomposition, 11.563s wall - 7.319s CLI-internal = ~4.2s OCP overhead (n=1), which is
   a cleaner comparison than the doc originally claimed.

3. Dropped the "~2-4s" range: its low end mixed an effort-HIGH turn_duration with the
   effort-LOW wall-clock median, which understates overhead (a low-effort turn generates
   faster, so its own turn_duration would be lower). No turn_duration sample exists for
   the effort-low config. Now stated as ~4s (n=1, baseline config), with both caveats.

4. Softened "ZERO marginal benefit" (backlog #4) to "no benefit detectable at n=5" — n=5
   cannot prove zero — and added the mechanistic reason the reviewer supplied, which is
   far stronger than the empirical null: `--help` says the flag improves cross-user
   prompt-cache REUSE, and OCP is single-user, so there is no cross-user cache to share.

Also folded in the reviewer's independent sweep, which closes the search space rather
than sampling it: the hook registry was enumerated from the shipped binary (no per-chunk
/ streaming hook exists among the 21 events); `capture-pane -e` was tested and shown to
be a provably non-unique inverse (an H2 and a bold span emit IDENTICAL SGR 1); and
sessions/<pid>.json, history.jsonl, CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES (undocumented),
sessionMirror, --sdk-url and --input-format stream-json were each checked and each dies
(contentless, or gated behind --output-format stream-json -> --print -> the metered
sdk-cli pool). Prompt-mutation (asking the model for plain text) is named and rejected
on ALIGNMENT grounds so it is not re-litigated later.

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
2026-07-13 16:04:12 +10:00
taodengandClaude Fable 5 6848f9751c docs(plans): TUI streaming is not achievable — prereq spike result + honest README constraints
Backlog #2 of docs/plans/2026-07-13-tui-latency demanded a prereq spike before any
streaming design: does the transcript JSONL grow during a turn, or only at the end?
The spike was run. All three candidate sources are dead:

  (a) transcript JSONL — grows at EVENT granularity; the assistant's text event is
      written as ONE complete line, ~0.3s before the terminal turn_duration event
      (observed: turn_duration 7319ms; text event at t+7.0s, terminal at t+7.3s).
  (b) tmux capture-pane — the pane is a RENDERED view, not the text. Same turn,
      transcript T = '## Semaphore\n\nA **semaphore** is a synchronization…'
      pane        = '⏺ Semaphore' / '  A semaphore is a synchronization…'
      '## ', '**' and ```-fences are absent from the pane entirely (rendered to ANSI,
      then stripped by capture-pane -p). T.startsWith(paneText) is FALSE both raw and
      indent-stripped — not on redraw, but on essentially every markdown answer.
      capture-pane -e recovers styling, never source spelling: no unique inverse.
  (c) --debug-file — byte-exact ('last_assistant_message':'## Title\n\n**alpha…'),
      but only inside end-of-turn Stop-hook payloads; zero content_block_delta /
      text_delta events; ~2.7MB per turn.

--output-format stream-json, the only interface emitting token deltas, requires -p —
the metered-billing path TUI mode exists to avoid (cc_entrypoint=sdk-cli). The
constraint is structural. OCP's TUI SSE is, and remains, replay-only.

Also corrects this plan's own "~20s waiting for the whole turn" decomposition, which
was inferred from an external 30-32s report and never measured through OCP. Measured
through a real OCP instance (TUI, claude-sonnet-4-6, n=5): median 11.30s before #156,
9.55s after, vs a native turn_duration of ~7.3s → OCP's own overhead is ~2-4s, not
~20s. The remainder is generation time, which streaming would not shorten (it moves
the first byte, not the last) — so a consumer needing the COMPLETE answer, which is
the JSON-card case that motivated this work, would have gained nothing from streaming.

Backlog #4 measured while here: --exclude-dynamic-system-prompt-sections gives ZERO
marginal benefit (TTFT median 6.39s vs 6.17s for --effort low alone, n=5, one worse
outlier). Do not adopt. Banner stayed on Claude Max.

README: documents the ~6s TTFT floor plainly (TUI mode cannot serve interactive-latency
consumers) and states that no-token-streaming is structural rather than a missing feature.

No code change. No version bump (docs-only). Not endpoint-touching: no server.mjs diff,
so no cli.js citation applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
2026-07-13 15:32:41 +10:00
20 changed files with 140 additions and 2979 deletions
-27
View File
@@ -1,32 +1,5 @@
# Changelog # Changelog
## v3.22.1 — 2026-07-17
Minor release: TUI-mode latency and streaming features — **all opt-in and off by default**, so the default request path (`-p` / `--output-format stream-json`) is byte-for-byte unchanged — plus hardening from an independent (Codex) re-review of the streaming work, Windows `claude.exe` startup resolution, and the Claude Sonnet 5 model entry. No new `cli.js` wire behavior and no new endpoint; the new surface is entirely OCP-owned TUI-mode configuration (env vars), startup binary discovery, model metadata, and `/health` observation. Every code PR carried a fresh-context reviewer (Iron Rule 10). (Version note: v3.22.0 was prepared but never tagged; its contents ship here as v3.22.1 together with the additions below.)
### Added
- **Claude Sonnet 5 in the model SPOT (#152, contributed by @vvlasy-openclaw)** — `claude-sonnet-5` added to `models.json` (`contextWindow` 200000 / `maxTokens` 16384 / `reasoning` true, consistent with existing entries), exposed via `/v1/models` and the OpenClaw sync. Purely additive: the `sonnet` alias still resolves to `claude-sonnet-4-6` (the repoint is tracked separately in #168). `ocp-connect`'s model classifier now matches on the model *family* prefix (`claude-sonnet`/`claude-opus`/`claude-haiku`) instead of version-pinned prefixes, so current and future versioned IDs register with correct `reasoning`/`maxTokens` metadata. New referential-integrity tests guard that every alias target exists in `models[]`.
- **Windows `claude.exe` startup resolution (#161, contributed by @nyxst4ck, diagnosis credit #147 @Justinsato)** — on Windows, `resolveClaude()` now discovers a native `claude.exe` (`%USERPROFILE%\.local\bin`, WinGet Links, WindowsApps, then `where.exe`) and rejects npm `.cmd`/`.bat`/`.ps1` shims, which cannot be spawned without a shell — previously startup resolved a shim and failed. A non-`.exe` `CLAUDE_BIN` on Windows is a fatal error with an actionable hint. The macOS/Linux path is byte-for-byte unchanged. Note: this is startup binary resolution only — full Windows support is not yet claimed (snapshot-path portability is tracked in #167).
### Added — TUI mode (all opt-in, default off)
- **Spawn effort control — `OCP_TUI_EFFORT` (default `low`) (#156)** — the interactive `claude` is now spawned with an explicit `--effort` flag. `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh`; proxied requests rarely benefit from extended thinking. Set `inherit` to omit the flag and restore the pre-flag HOME-dependent behaviour. Banner-verified to stay on the subscription pool (`· Claude Max`); an invalid value warns and falls back to `low`. README § "Environment Variables".
- **Warm pane pool — `OCP_TUI_POOL_SIZE` (default `0` / off) (#158)** — pre-boots up to 4 single-use `claude` panes so a request skips the cold boot: measured end-to-end p50 `10.17s``6.00s` (41%) on a Mac mini (Sonnet 4.6, `--effort low`). Opt-in because each warm pane is a live idle process held whether or not a request ever arrives. Panes are single-use (one turn, then killed and replaced in the background), port-scoped (`ocp-tui-<port>-p<hex>`), and coexist with the zombie reaper by a synchronous drain→reap→resume sweep. README §§ "Environment Variables" + "How It Works".
- **Real SSE streaming — `OCP_TUI_STREAM` (default `0` / off) (#159, #160)** — `stream:true` turns emit real `delta.content` chunks as `claude` generates them, sourced from `claude`'s own `MessageDisplay` hook (registered via `--settings` on the ordinary interactive spawn — banner-verified on the subscription pool). Granularity is block-level, and it moves the *first* byte, not the last. The transcript stays authoritative: streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and a turn whose stream cannot be reconciled is **refused** (SSE error frame, not cached) and counted on `/health` (`tui.streamDivergences`; a silent total-hook-failure is counted separately as `tui.streamZeroDeltaTurns`). Tunables: `OCP_TUI_STREAM_HOLDBACK` (default `100`), `OCP_TUI_STREAM_DIR`, `OCP_TUI_STREAM_POLL_MS`. See ADR 0007 (2026-07-13 amendment). README §§ "Environment Variables" + "How It Works".
### Fixed
- **Streaming auth-banner guard: a null `message_id` on the first hook fire (#160)** — a first `MessageDisplay` fire with a null `message_id` could disarm the auth-banner guard; re-landed after a #159 squash dropped it (`lib/tui/stream.mjs`).
- **Test suite wrote live, unrevoked API keys into the operator's real key store (#163)** — `npm test` had been opening `~/.ocp/ocp.db` (the running server's DB) and writing two junk `api_keys` rows per run (737 accumulated on the maintainer's host), because the isolation the comments claimed was never wired (ESM import hoisting). `keys.mjs` now honors `OCP_DIR_OVERRIDE` under `NODE_ENV=test` and the suite points at a scratch dir; a child-process probe verifies a production process (no `NODE_ENV`) cannot be redirected.
- **Streaming holdback floor + billing-pool observation on failed turns (#164)** — (A1) `OCP_TUI_STREAM_HOLDBACK` now clamps up to the safe floor (`100`) with a boot warning, closing a latent auth-banner leak when an operator set a sub-floor value. (A3) the `cc_entrypoint` (billing-pool) observation is now recorded before the honesty gates that throw, so `/health` no longer goes blind to exactly the failed turns most likely to signal a silent degrade to the metered Agent SDK pool.
- **Test-only key-store redirection vars can no longer reach a server OCP launches (#165)** — (A4) `NODE_ENV`/`OCP_DIR_OVERRIDE` are stripped from every service unit `setup.mjs` writes (`plist-merge`'s `NEVER_PRESERVE`) and from the `ocp restart` manual nohup fallback (`env -u`); #163's overstated "a prod server can NEVER be redirected" comments were softened to name the one residual hand-launch path and the loud `getDb()` "NOT the default" backstop.
### Docs
- **README billing honesty (#162, closes #136)** — removed a feature bullet that promised what the § "honest limits" section forbids.
- **TUI latency plans + streaming-achievability spike (#155, #157)** — measured latency decomposition, backlog, and the `MessageDisplay`-hook streaming prereq spike under `docs/plans/2026-07-13-tui-latency/`.
## v3.21.1 — 2026-07-07 ## v3.21.1 — 2026-07-07
Patch release: three bug fixes from an independent concurrency/session-lifecycle audit, each its own PR with a fresh-context reviewer (Iron Rule 10). No new `cli.js` wire behavior, no new endpoint, header, or env var; the `/health` field set is unchanged (only value truthfulness improved). Patch release: three bug fixes from an independent concurrency/session-lifecycle audit, each its own PR with a fresh-context reviewer (Iron Rule 10). No new `cli.js` wire behavior, no new endpoint, header, or env var; the `/health` field set is unchanged (only value truthfulness improved).
+5 -26
View File
@@ -24,7 +24,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get: There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
- **LAN multi-user keys** (v3.7.0) — reach one Claude Pro/Max subscription from your own devices across the LAN. Each device gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. Pro/Max are **per-user** accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other **people**. - **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times. - **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66)) - **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18)) - **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
@@ -49,7 +49,7 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
| GitHub stars / ecosystem size | small | large | mid | | GitHub stars / ecosystem size | small | large | mid |
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a | | Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift. **Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
### Related: OLP — Open LLM Proxy ### Related: OLP — Open LLM Proxy
@@ -215,7 +215,7 @@ After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1 export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
``` ```
**LAN mode** — reach OCP from your own devices on the network (Claude Pro/Max are per-user accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other people): **LAN mode** — share with other devices on your network:
```bash ```bash
# Enable LAN access with per-user auth (recommended) # Enable LAN access with per-user auth (recommended)
node setup.mjs --bind 0.0.0.0 --auth-mode multi node setup.mjs --bind 0.0.0.0 --auth-mode multi
@@ -716,7 +716,6 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
| `claude-opus-4-8` | Most capable (default for `opus` alias) | | `claude-opus-4-8` | Most capable (default for `opus` alias) |
| `claude-opus-4-7` | Previous Opus, retained for pinning | | `claude-opus-4-7` | Previous Opus, retained for pinning |
| `claude-opus-4-6` | Older Opus, retained for pinning | | `claude-opus-4-6` | Older Opus, retained for pinning |
| `claude-sonnet-5` | Latest Sonnet (available by full ID; `sonnet` alias repoint tracked separately) |
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) | | `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) | | `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
@@ -960,12 +959,7 @@ 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_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_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_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_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. | | `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. |
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json` `additionalDirectories` instead. See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. | | `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json` `additionalDirectories` instead. See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
@@ -1039,14 +1033,13 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
### What changes / what doesn't ### What changes / what doesn't
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format. - **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **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). - **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).
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely. - **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. - **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. - **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.
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment. - **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today. - **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once. - **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
- **Optional warm pane pool (`OCP_TUI_POOL_SIZE`, default off).** Pre-boots panes so a request skips the cold boot — measured p50 `10.17s` → `6.00s` (41%). Pooled panes are **single-use** (one turn, then killed and replaced in the background), each carrying its own fresh `--session-id`, so one session still means one exchange and no earlier-turn text can leak into a later answer. They are named `ocp-tui-<port>-p<hex>` and coexist with the reaper by design: the sweep **drains the pool first**, then reaps (so `kill-server` still flushes `<defunct>` zombies), then the pool refills in the background. Drain→reap→resume is synchronous, so no request can land mid-sweep; a request arriving while the pool is still re-booting simply misses it and cold-boots. A live pooled pane is never reaped — **including one that is still booting**, whose tmux session already exists — while an *orphaned* one (left by a previous process generation) still is.
### ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable ### ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
@@ -1089,26 +1082,12 @@ a sub-5-second budget. Full measurements and methodology:
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs "entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
"inflight": 1, // TUI turns running right now "inflight": 1, // TUI turns running right now
"queued": 0, // TUI turns waiting for a concurrency slot "queued": 0, // TUI turns waiting for a concurrency slot
"maxConcurrent": 2, // OCP_TUI_MAX_CONCURRENT "maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
"pool": { // warm pane pool — null when OCP_TUI_POOL_SIZE=0 (the default)
"size": 2, // target warm panes (OCP_TUI_POOL_SIZE)
"warm": 2, // panes ready right now — each is a LIVE idle claude process
"booting": 0, // replacement panes currently pre-booting
"model": "claude-sonnet-4-6", // the model being warmed (the most recently requested one)
"hits": 12, // requests served by a warm pane
"misses": 1, // requests that fell back to the cold boot (the 1st is always one)
"boots": 14, // panes successfully pre-booted
"bootFailures": 0, // pre-boots that genuinely never reached the input bar — WATCH this
"cancelled": 4, // in-flight boots OCP killed on purpose (drain / model switch) — not faults
"dropped": 8 // panes discarded unused (drain sweep / expired / unhealthy)
}
} }
``` ```
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap. Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
With the pool on, `hits` / `misses` is the hit rate (a steady single-model consumer should sit near 100% after the first request), and `warm` is your standing idle-process cost. A climbing `bootFailures` means panes are not reaching their input bar — the pool then degrades safely to the cold path, but latency reverts to the un-pooled numbers. `cancelled` counts boots OCP killed *on purpose* (a drain, a model switch) and is **not** a fault signal — do not alert on it. A steadily climbing `dropped` is likewise normal: the 15-min reap sweep drains and re-boots the pool on every tick so `kill-server` can still flush `<defunct>` zombies.
### Kill-switch ### Kill-switch
```bash ```bash
+1 -56
View File
@@ -56,7 +56,7 @@ Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event. 3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s). 4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**. 5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features"). **Superseded for `stream:true` when `OCP_TUI_STREAM=1` — see the 2026-07-13 amendment below. The buffered path remains the default and is unchanged.** 6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4) ### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
@@ -333,61 +333,6 @@ The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned
--- ---
## Amendment (2026-07-13) — real SSE streaming via the `MessageDisplay` hook (`OCP_TUI_STREAM`)
**Supersedes**: Request-flow step 6 above ("no real token streaming — deliberate"), for `stream:true`
requests when `OCP_TUI_STREAM=1`. The buffered path stays the default and is byte-for-byte unchanged.
**Context.** Step 6 was written when the interactive CLI appeared to expose no byte-faithful
incremental source. A prereq spike (`docs/plans/2026-07-13-tui-latency/streaming-spike.md`) confirmed
three obvious sources are dead ends — the transcript JSONL grows one *whole event* at a time (the
answer lands as a single line ~0.3 s before the terminal marker); `tmux capture-pane` yields a
*rendered* view whose markdown source is unrecoverable (an H2 and a bold span produce identical ANSI);
`--debug-file` logs stream *timing*, never stream *content*. Every interface that does emit
`text_delta` (`--output-format stream-json`) requires `-p`, which moves the request to the **metered**
`sdk-cli` pool — precisely what TUI-mode exists to avoid.
**Decision.** Consume `claude`'s own **`MessageDisplay`** hook, registered via `--settings` on the
ordinary interactive spawn (no `-p`, no `--bare`). Each fire delivers the **raw markdown source** of an
incremental `delta` on the hook's stdin. Verified live (claude 2.1.207, sonnet-4-6): banner stays
`· Claude Max` and the transcript `entrypoint` stays `cli` (subscription pool); `concat(deltas) === T`
byte-exactly; `T.startsWith(concat(deltas[0..n]))` at every *n*. This is **forwarding, not inventing**
— ALIGNMENT.md **Class B**. No `cli.js` citation applies: the TUI spawn is OCP-owned surface (this
ADR), the hook payload is claude's own published contract, and the SSE wire shapes are the OpenAI
chat/completions streaming spec adopted by **ADR 0006** (the emitters are literally the `-p` path's).
**The transcript remains authoritative.** It is still the terminal-turn signal, still the source of the
returned/cached text `T`, and still the input to the honesty gates (auth-banner detection C-1,
`truncated` C-2). The delta stream is a low-latency **mirror**, never a replacement. At end of turn OCP
asserts the streamed bytes against `T`: equal → serve; a strict *prefix* of `T` → top up from the
transcript (client still receives exactly `T`); **not** a prefix → **refuse the turn** (SSE error frame,
no cache, `tui.streamDivergences++`). Serving text the transcript disagrees with is the failure class
ALIGNMENT.md exists to prevent, so streaming fails loud rather than degrading quietly.
**Consequences / constraints recorded for future authors:**
- **Opt-in, default OFF.** The buffered path is stable production; streaming does not change it.
- **Per-`session_id` sink is mandatory, not an optimization.** `OCP_TUI_MAX_CONCURRENT` defaults to
**2** — two `claude` panes already run concurrently. A single shared sink would interleave one
client's deltas into another's stream. The hook writes to `<dir>/<session_id>.jsonl`, the path
delivered through the *pane's own env* (`OCP_TUI_STREAM_FILE`); OCP reads only its own turn's file.
Verified with two concurrent streamed turns (ALPHA/BRAVO): zero cross-contamination.
- **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 derives from
the session-id, which for a pre-booted pane is fixed at boot.
- **The hook is synchronous** (`forceSyncExecution: true``claude` *blocks* on it). The hook script
must write and exit; it does one `cat` append and nothing else. Measured: p50 **7.2 ms** per fire,
~50 ms across a whole turn — noise against a 610 s turn. Do not add work to it.
- **Thinking blocks do not fire the hook** — verified on a substantive Opus/`xhigh` reasoning turn (see
the PR evidence), not merely inferred from the `final:true` call site. This must be **re-verified** if
the hook is ever pointed at a new model/effort tier: a thinking delta reaching a client would be
unretractable, and the `concat === T` assertion can only *detect* that after the fact, never prevent
it. The first-bytes **holdback** (`OCP_TUI_STREAM_HOLDBACK`, default 100 chars) is the same
prevention-not-detection reasoning applied to the auth-banner gate.
- **Block-level granularity**, scaling with answer length — not token-level. Do not promise otherwise.
- **It moves the first byte, not the last.** Only a progressively-rendering consumer benefits; it does
not move TUI-mode's ~6 s TTFT floor.
## Provenance ## Provenance
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1S6 / T1T6 were validated live on the test host against `claude v2.1.158`. TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1S6 / T1T6 were validated live on the test host against `claude v2.1.158`.
-208
View File
@@ -1,208 +0,0 @@
# ADR 0008 — TUI Warm Pane Pool
**Date:** 2026-07-13
**Status:** Proposed
**Extends:** [ADR 0007](0007-tui-interactive-mode.md) (TUI interactive mode). This ADR does not
change ADR 0007's billing-pool argument, security posture, or kill-switch — it adds a latency
optimization *inside* the TUI spawn machinery ADR 0007 owns.
---
## Context
TUI mode (ADR 0007) serves every request by cold-booting a fresh `tmux` session running an
interactive `claude`, submitting one prompt, reading the native transcript, and killing the
session. That cold boot is paid on **every** request.
[`docs/plans/2026-07-13-tui-latency/`](../plans/2026-07-13-tui-latency/README.md) measured the
TUI path and listed a warm pane pool as backlog item #3, costed at "**~1.0 s**" (the observed
boot-to-input-bar time). Instrumenting the real request path showed that estimate is **~4×
too low**. Phase decomposition of the cold path (n=6 medians, Sonnet 4.6, `--effort low`,
through a real OCP instance):
| Phase | Median |
|---|---|
| prep (trust cwd, write prompt file) | 2 ms |
| `tmux new-session` | 27 ms |
| **boot → input bar ready** | **1232 ms** |
| paste (`load-buffer` + `paste-buffer`) | 8 ms |
| paste-verify poll | 426 ms |
| **submit → transcript terminal** | **8458 ms** |
| teardown | 8 ms |
| **total** | **10162 ms** |
| *claude's own reported `turn_duration`* | *5539 ms* |
| **OCP-side overhead** | **4490 ms** |
The `submit → terminal` phase exceeds claude's own `turn_duration` by **~2.9 s**. That gap is
**post-input-bar initialization inside `claude`** — work that a pane which has merely *sat idle
for a few seconds* has already completed. A direct spike confirmed it: an identical pane, idle
12 s before receiving the same prompt, completed its turn in a median 5537 ms versus 7980 ms
cold.
So a warm pane recovers **~1.26 s of boot *and* ~2.9 s of in-`claude` cold start** — not the
~1.0 s the plan predicted.
The reason this was worth a pool rather than a "keep one session and reuse it" cache is a
hazard already flagged in the code. `lib/tui/transcript.mjs` returns the **last text-bearing
assistant entry in the whole transcript file**, which is correct *only* under OCP's
one-session-per-request model, and it says so:
> *"If a future warm-pool ever reuses a session WITHOUT a fresh session-id / clear, earlier-turn
> text could leak — that author must add user-line scoping here."*
Reusing a pane for a second turn puts two exchanges in one transcript and would leak the earlier
turn's text into the later turn's answer — a **cross-request data leak**, not merely a bug.
---
## Decision
Add an **opt-in pool of pre-booted, single-use `claude` panes**, `OCP_TUI_POOL_SIZE` (default
`0` = off, max `4`). Implementation: `lib/tui/pool.mjs`.
### 1. Panes are SINGLE-USE. This is the load-bearing rule.
A pooled pane serves **exactly one turn**, then is killed and replaced in the background. Each
pane is booted with its **own fresh `--session-id`**, fixed at spawn, and the turn locates its
transcript by that id.
This preserves one-session-per-request exactly, so the `transcript.mjs` hazard above **does not
arise** and no user-line scoping was needed. The warning in `transcript.mjs` is deliberately
left standing, now annotated: it still binds anyone who later wants a pane to serve a second
turn, or to reset a session with `/clear` and reuse it. **Neither is permitted without first
adding user-line scoping to the transcript reader.**
Rejected alternative — *reuse a pane for N turns, `/clear` between* — is strictly cheaper
(no re-boot per request) and was rejected on exactly this basis. The latency win is not worth a
cross-request text-leak surface guarded only by a `/clear` that we cannot verify landed.
### 2. The pool is keyed by model, and a MISS is always safe.
`--model` is fixed at spawn, so a pane can only serve the model it booted with. A pool miss
falls back to the existing cold-boot path with **zero behavioural difference**. There is no
boot-time pre-warm and no configured model: OCP cannot know which model the next caller wants,
so the pool warms the **most recently requested** model. Consequence, stated plainly: **the
first request after start, and the first after any model switch, is always a cold miss.**
### 3. The pool and the session reaper coexist by an explicit invariant.
This is the subtle part. `reapStaleTuiSessions()` kills every session matching this instance's
`ocp-tui-<port>-` prefix, and issues `tmux kill-server` when no foreign session remains (the
only mechanism that can reap `<defunct>` `claude` zombies — the pane's `claude` is a child of
the tmux *server*, not of node). A warm pooled pane **is** one of our own sessions, alive and
idle **by design** — and the periodic sweep runs precisely **when the instance is idle**, i.e.
exactly when the pool is full.
The invariant, stated in a comment above `reapStaleTuiSessions` and pinned by tests:
1. **A live pooled pane is never reaped — including one that is still BOOTING.** The reaper
takes a `spare` set of **exact session names** supplied by the pool's live registry.
2. **An orphaned pooled pane IS still reaped.** Membership is by **exact name from a live
in-memory registry, never by name shape**. A pane the pool no longer owns — handed out,
dropped, cancelled, or left behind by a previous process generation (whose registry died with
it) — is absent from `spare` and is killed like any other stale session. **Fail-safe:
omitting `spare` reaps *more*, never less.** Pool panes are named `ocp-tui-<port>-p<hex>`
purely for operator legibility; that shape is *not* the exemption mechanism.
3. **`kill-server` is suppressed while any pane is spared** (it would kill a live child of the
tmux server). Therefore **the pool is DRAINED immediately before every sweep**, so `spare` is
empty on the normal tick and `kill-server` still fires. Without the drain, a permanently-full
pool would **permanently disable zombie reaping** — the pool would silently break the thing
the sweep exists to do. The drain costs one pane re-boot per tick (15 min).
The `spare` mechanism is belt-and-braces given the drain: it makes it impossible for a reap call
site that *forgets* to drain to kill a live pane.
### 4. The pool tracks its in-flight boot BY NAME, not as a count.
`bootTuiPane` creates the tmux session **synchronously** and only *then* waits (up to
`POOL_BOOT_MS`, 20 s) for the input bar. So **a pooled tmux session can be live for ~20 s before
its boot resolves.** A pool that tracked in-flight boots as a *count* could not name that
session, and this produced two real bugs (both caught in review, both now regression-tested):
- the periodic sweep **killed the booting pane** (it could not be spared), then left the pool
empty with nothing scheduled, and logged the exact `tui_pool_boot_failed` warning operators are
told to alert on — for a completely healthy drain;
- graceful shutdown **orphaned a live, authenticated, idle `claude`**: `gracefulShutdown` calls
`process.exit(0)` in the same tick as the drain (TUI panes are tmux children, so node's
`activeProcesses` set is empty and the "wait for children" path exits immediately), so any
cleanup deferred to a `.then()` never ran.
The pool therefore **mints each pane's identity up front** (`{sessionId, name}`, name derived
from the session-id so `tmux ls` correlates to the transcript file) and holds it in
`_bootingPane`. `liveNames()` includes it; `drain()` kills it **synchronously**. A generation
counter distinguishes *"cancelled by us"* from *"genuinely failed"*, so a drain never inflates
`bootFailures` and `resume()` reliably starts a fresh boot.
### 5. Refills take no concurrency slot, and are serialized.
A refill boot deliberately does **not** take a `TuiSemaphore` slot: those slots bound concurrent
*turns* and belong to real requests, and charging a background pre-boot against them would let
the pool starve the traffic it exists to speed up. It cannot leak a slot either, since it never
holds one. Boots are **serialized** (one at a time): two cold boots racing an in-flight turn were
observed to overrun even the generous pool readiness cap. A genuinely failed boot does **not**
re-kick the chain (backoff — a broken `claude` must not respawn forever).
Background boots get a more generous readiness cap (`POOL_BOOT_MS` = 5 × `BOOT_MS`): `BOOT_MS` is
tight because a *client* is blocked on it, which is not true of a pre-boot. Slow ≠ broken.
---
## Consequences
### Cost — standing processes, paid whether or not a request arrives
**A warm pane is a live idle `claude` process.** Peak process count is
`OCP_TUI_POOL_SIZE` + `OCP_TUI_MAX_CONCURRENT` + 1 (booting replacement). This is the whole
reason the pool is **default-off**: an operator must opt into holding processes for traffic that
may never come. Size is clamped to `POOL_MAX_SIZE` = 4; an unparseable value **disables** the
pool rather than guessing.
Panes carry a 10-minute TTL and are health-checked at hand-out; a dead or degraded pane becomes
a **miss** (cold path), never a hung turn.
### Benefit
Measured end-to-end through a real OCP instance (Sonnet 4.6, `--effort low`):
**p50 10.17 s (n=6, pool off) → 6.00 s (n=12 warm hits) — 4.2 s / 41%.**
### The floor is unchanged
The pool does not touch the **~6 s TTFT floor** documented in the latency plan (claude always
prefills the full Claude Code system prompt). TUI mode remains unsuitable for interactive /
real-time consumers; it is for batch and background work. This ADR does not change that
conclusion.
### Observability
`/health`'s `tui` block gains a `pool` sub-object (`null` when off): `size`, `warm`, `booting`,
`model`, `hits`, `misses`, `boots`, `bootFailures`, `cancelled`, `dropped`. A climbing
`bootFailures` means panes are not reaching their input bar — the pool then degrades safely to
the cold path, but latency reverts to the un-pooled numbers. A steadily climbing `dropped` is
**normal** (the 15-min sweep drains and re-boots the pool on every tick, by design — see
Decision 3).
### ALIGNMENT authorization
- **Class B / OCP-owned.** The warm pool is process management around the `claude` CLI — the
same category as the existing tmux session lifecycle and the defunct-session reaper it extends.
**`cli.js` does not perform this operation, and no `cli.js` citation applies**; the authority
is ADR 0007 (which owns the TUI spawn machinery) plus this ADR. This is `ALIGNMENT.md` Rule 2's
Class B citation requirement, discharged explicitly rather than by silence.
- **The `/health` extension** adds sub-fields to the `tui` block. That block is **owned by ADR
0007** and post-dates ADR 0006's v3.16.4 grandfather snapshot, so it is not part of the frozen
B.2 inventory. The change is additive — every pre-existing `/health` field keeps a
byte-identical value, and `pool` is `null` unless the operator opts in — which is the
behaviour-preserving bar ADR 0006 sets. This ADR records that authorization.
- **No spawn argument changed.** `buildTuiCmd` is byte-identical; the pool calls it with the same
arguments. Banner-verified on live pooled panes: `· Claude Max`, never `API Usage Billing`
(the `--bare` trap documented in the latency plan).
### What a future contributor must not undo
- **Do not let a pane serve a second turn** (or `/clear`-and-reuse one) without first adding
user-line scoping to `lib/tui/transcript.mjs`. That is a cross-request text leak, not a perf
tweak. See Decision 1.
- **Do not remove the drain-before-sweep.** It is what keeps `kill-server` zombie reaping alive.
See Decision 3.
- **Do not go back to counting in-flight boots.** The pool must be able to *name* a session that
exists but has not finished booting. See Decision 4.
-2
View File
@@ -23,8 +23,6 @@ New ADRs increment from the highest existing number. Filenames are
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. | | [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. | | [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 15 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. | | [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 15 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
| [0007](0007-tui-interactive-mode.md) | TUI Interactive Mode | Why TUI-mode spawns an interactive `claude` in a tmux pane (no `-p`) to reach the **subscription** billing pool (`cc_entrypoint=cli`) rather than the metered Agent SDK pool. Owns the TUI spawn machinery: entrypoint labeling, credential-isolated home, MCP hard-disable, session namespace + defunct-session reaping, the independent concurrency bound, and the `/health` `tui` block. **Single-user only** — hard FATAL on multi-user configs. |
| [0008](0008-tui-warm-pane-pool.md) | TUI Warm Pane Pool | Why `OCP_TUI_POOL_SIZE` pre-boots **single-use** `claude` panes (one turn each, own `--session-id`) — and why reuse is forbidden (`transcript.mjs` returns the last assistant entry in the file, so a reused session leaks the earlier turn's text). Measured 41% end-to-end. Defines the pool↔reaper invariant (exemption by exact name from a live registry; drain before every sweep so `kill-server` zombie reaping survives) and the standing idle-process cost. Extends ADR 0007. |
## When to write a new ADR ## When to write a new ADR
+7 -55
View File
@@ -6,74 +6,26 @@ import { join } from "node:path";
import { mkdirSync, chmodSync } from "node:fs"; import { mkdirSync, chmodSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
// Resolved LAZILY, on first getDb() — not at module top-level. Two reasons, and the second is const OCP_DIR = join(homedir(), ".ocp");
// the bug this fixes: mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
//
// 1. Merely IMPORTING keys.mjs should not, as a side effect, create directories in the
// operator's home.
// 2. OCP_DIR_OVERRIDE exists so the test suite can point the key store at a scratch dir — and
// because ESM hoists imports, a top-level `const OCP_DIR = ...` here would be evaluated
// BEFORE an importing module's body could set the env var. Eager resolution made the
// override unsettable in the one place that needs it. (test-features.mjs carried a comment
// claiming it could "set env before the first getDb() call" — it could not, because nothing
// here ever read an env var. So `npm test` wrote real, UNREVOKED api_keys rows into the
// operator's live ~/.ocp/ocp.db: two per run, unbounded — 737 junk keys against 12 real ones
// on the maintainer's host — and two concurrent runs raced one file, which is the ~1-in-6
// flake in `listKeys includes quota fields`.)
//
// The override is gated on NODE_ENV === "test", and that gate is the ACTUAL guard. An earlier
// cut of this fix relied on the variable merely having an awkward name — i.e. a naming convention
// plus a comment — which is precisely the failure mode this whole change exists to indict (a
// comment describing an intention that nothing enforces). The two-key gate means NEITHER var
// alone does anything: a stray OCP_DIR_OVERRIDE with no NODE_ENV is inert, and NODE_ENV=test with
// no override just resolves the default dir.
//
// This gate does NOT, by itself, prove a production daemon can't be redirected — an earlier
// version of this comment overclaimed that ("a production server runs without NODE_ENV, so it
// CANNOT honor the override no matter how the variable got in"). That is only true while the
// daemon's env actually lacks NODE_ENV=test, which is an assumption, not something this file can
// enforce. What makes it hold in the shipped configuration is defense-in-depth in OCP's launchers:
// the plist/systemd units strip both vars on every (re)install (scripts/lib/plist-merge.mjs
// NEVER_PRESERVE), and `ocp` restart's manual nohup fallback strips them (`env -u`). So a server
// OCP itself started cannot carry the test-only redirection. The one residual path is an operator
// who hand-launches `node server.mjs` with BOTH vars explicitly exported, bypassing every
// launcher — a case no library-level gate can catch. The loud getDb() log below ("NOT the default
// ~/.ocp/ocp.db") is the backstop there: a wrong key store is at least never silent (in
// AUTH_MODE=multi that would otherwise be a total auth outage with nothing on /health to show it).
function resolveOcpDir() {
const override = process.env.NODE_ENV === "test" ? process.env.OCP_DIR_OVERRIDE : null;
const dir = override || join(homedir(), ".ocp");
mkdirSync(dir, { recursive: true, mode: 0o700 });
// Tighten the directory mode in case it already existed with broader permissions. // Tighten the directory mode in case it already existed with broader permissions.
try { chmodSync(dir, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ } try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
return dir; const DB_PATH = join(OCP_DIR, "ocp.db");
}
let db; let db;
let dbPath; // resolved on first open, alongside the db handle
export function getDb() { export function getDb() {
if (!db) { if (!db) {
dbPath = join(resolveOcpDir(), "ocp.db"); db = new DatabaseSync(DB_PATH);
// Say which store we opened. Silence was the other half of the bug: a server on the wrong
// key store looks exactly like a server on the right one until every request 401s.
if (dbPath !== join(homedir(), ".ocp", "ocp.db")) {
console.error(`[keys] key store: ${dbPath} (NOT the default ~/.ocp/ocp.db)`);
}
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON"); db.exec("PRAGMA foreign_keys = ON");
initSchema(); initSchema();
// Tighten mode on the DB file (0600) after creation / first open. // Tighten mode on the DB file (0600) after creation / first open.
try { chmodSync(dbPath, 0o600); } catch { /* ignore — same-user access still works */ } try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
} }
return db; return db;
} }
// Which file the key store actually opened. Exported so a test can ASSERT it is not the
// operator's real db — the bug this replaced was invisible precisely because nothing checked.
export function getDbPath() { return dbPath; }
function initSchema() { function initSchema() {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
@@ -474,5 +426,5 @@ export function findKey(idOrName) {
} }
export function closeDb() { export function closeDb() {
if (db) { db.close(); db = null; dbPath = undefined; } // clear both — a path to a closed db is a footgun if (db) { db.close(); db = null; }
} }
-321
View File
@@ -1,321 +0,0 @@
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
// input bar, so a request does not pay the cold boot. Opt-in: OCP_TUI_POOL_SIZE=0
// (default) disables it entirely and the request path is byte-for-byte today's.
//
// ── SINGLE-USE IS THE LOAD-BEARING RULE ─────────────────────────────────────
// A pooled pane serves EXACTLY ONE turn and is then killed and replaced in the
// background. Each pane carries its OWN fresh `--session-id`, fixed at boot, and the
// turn locates its transcript by that id. So OCP's one-session-per-request model is
// preserved: a session's transcript still holds exactly one logical exchange.
// That is what keeps lib/tui/transcript.mjs's extractLatestAssistantText (which returns
// the LAST text-bearing assistant entry in the whole file, not "text since the matching
// user line") correct — see the scoping note there. A pane MUST NEVER serve a second
// turn, and a session MUST NEVER be reset with /clear and reused: either would put two
// exchanges in one transcript and leak the earlier turn's text into the later turn's
// answer. Nothing here reuses a pane; keep it that way.
//
// ── WHY IT'S WORTH MORE THAN THE BOOT TIME ──────────────────────────────────
// Measured on this host (n=6 through OCP, Sonnet 4.6, --effort low): the cold path
// spends ~1.23 s reaching the input bar, but ALSO ~2.9 s inside the first turn beyond
// what claude itself reports as the turn duration — post-input-bar init that a pane
// which has been idle for a few seconds has already finished. A warm pane recovers both.
//
// ── COST (bounded, and paid whether or not a request arrives) ───────────────
// Each warm pane is a LIVE `claude` process (plus its tmux pane) sitting idle. Peak
// process count is (pool size) + (OCP_TUI_MAX_CONCURRENT in-flight turns) + (panes
// currently booting as replacements). Pool size is clamped to POOL_MAX_SIZE.
//
// Pure + injectable (bootPane / killPane / paneHealthy / now) so test-features.mjs can
// assert acquire / miss / refill / TTL / reaper-exemption with no tmux and no claude.
// Hard cap on OCP_TUI_POOL_SIZE. Each pane is an idle claude process; 4 is already a
// lot of resident memory on a small host (a Pi serving a family) for zero in-flight work.
export const POOL_MAX_SIZE = 4;
// A warm pane older than this is dropped on acquire rather than handed out. The periodic
// reap tick (server.mjs) drains the pool every 15 min anyway, so this only bites when
// that tick kept getting skipped because the TUI path was never idle. Guards against
// handing out a pane whose `claude` has been sitting so long it may have drifted
// (auto-compaction prompts, an idle-disconnect banner, an expired in-pane token).
export const POOL_MAX_AGE_MS = 10 * 60 * 1000;
// Clamp the operator-supplied size into [0, POOL_MAX_SIZE]. A garbage value disables the
// pool rather than guessing — an unparseable size must never silently boot 4 processes.
export function resolvePoolSize(raw) {
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.min(n, POOL_MAX_SIZE);
}
export class TuiPanePool {
// size: target number of warm panes (0 = disabled).
// maxAgeMs: per-pane TTL (see POOL_MAX_AGE_MS).
// mintPane: () => ({ sessionId, name }) — mints the identity of the NEXT pane. The POOL,
// not the boot function, owns this: the tmux session springs into existence the
// instant bootPane starts, so the pool must already know its NAME (see
// _bootingPane below). Deriving the name from the sessionId also makes `tmux ls`
// correlate to the transcript file.
// bootPane: async (model, {sessionId, name}) => { name, sessionId, model, bootedAt } —
// boots ONE pane under exactly that identity and resolves only once it is
// input-ready; throws if it never becomes ready.
// killPane: (name) => void — tmux kill-session. MUST be synchronous (see drain).
// paneHealthy:(name) => bool — pane still exists AND is still at its input bar.
constructor({ size, maxAgeMs = POOL_MAX_AGE_MS, mintPane, bootPane, killPane, paneHealthy, now = Date.now, log = () => {} }) {
this.size = Math.max(0, Math.min(parseInt(size, 10) || 0, POOL_MAX_SIZE));
// Fail fast at CONSTRUCTION, not at request time. refill() is called synchronously from
// the request path (runTuiTurn), so a missing collaborator would otherwise surface as a
// 500 on a live request instead of a loud error at boot.
if (this.size > 0) {
for (const [k, fn] of [["mintPane", mintPane], ["bootPane", bootPane], ["killPane", killPane], ["paneHealthy", paneHealthy]]) {
if (typeof fn !== "function") throw new TypeError(`TuiPanePool: ${k} must be a function`);
}
}
this.maxAgeMs = maxAgeMs;
this._mintPane = mintPane;
this._bootPane = bootPane;
this._killPane = killPane;
this._paneHealthy = paneHealthy;
this._now = now;
this._log = log;
this._panes = []; // warm, available panes: { name, sessionId, model, bootedAt }
// The pane currently BOOTING, BY NAME ({sessionId, name, model}) — or null.
//
// WHY A NAME AND NOT A COUNT (this is a fixed bug, don't regress it): bootTuiPane creates
// the tmux session SYNCHRONOUSLY and only THEN waits up to POOL_BOOT_MS (20 s) for the
// input bar. So for up to 20 s there is a LIVE pooled tmux session. When the pool tracked
// only a count, it could not NAME that session, so:
// - liveNames() could not spare it and the periodic reap sweep KILLED it (and
// kill-server'd on top), leaving the pool empty with nothing scheduled and firing the
// very tui_pool_boot_failed WARN operators are told to alert on; and
// - drain() could not kill it, so on shutdown it ORPHANED a live authenticated `claude`
// (the boot's .then that was supposed to clean up never runs — gracefulShutdown calls
// process.exit in the same tick).
// Both are fixed by holding the identity here, before the session exists.
this._bootingPane = null;
// Generation counter. Bumped whenever an in-flight boot is CANCELLED (drain / model
// switch). A boot compares the generation it started under against the current one:
// if they differ, its pane was already killed by us and its settle is inert — in
// particular a rejection is a CANCELLATION, not an operator-visible boot failure.
this._gen = 0;
this._paused = false; // true while drained; refill() is a no-op until resume()
this.warmModel = null; // the model the pool currently warms — learned from traffic (see acquire)
this.hits = 0; // requests served by a warm pane
this.misses = 0; // requests that fell back to the cold path
this.boots = 0; // panes successfully pre-booted
this.bootFailures = 0; // pre-boots that genuinely never reached the input bar
this.cancelled = 0; // in-flight boots WE killed (drain / model switch) — not failures
this.dropped = 0; // panes discarded unused (unhealthy / expired / wrong model / drained /
// cancelled — a cancelled in-flight boot also lands here via _drop)
}
get enabled() { return this.size > 0; }
get warm() { return this._panes.length; }
get booting() { return this._bootingPane ? 1 : 0; }
// The reaper's spare set: the EXACT names of every pane the pool currently owns and has NOT
// handed out — the warm ones AND the one currently booting (whose tmux session is already
// live; see _bootingPane). See the POOL/REAPER INVARIANT in lib/tui/session.mjs.
// Fail-safe by construction: a pane leaves this set the instant it is acquired, dropped, or
// cancelled, and if the pool is empty (or the process restarted) the set is empty — so an
// orphaned pooled pane looks exactly like any other stale session and IS reaped.
liveNames() {
const names = new Set(this._panes.map((p) => p.name));
if (this._bootingPane) names.add(this._bootingPane.name);
return names;
}
// Take a warm pane for `model`, or null (caller must fall back to the cold path — a MISS
// is always safe, never an error). Synchronous: paneHealthy is a cheap tmux capture.
//
// The pool warms the MOST RECENTLY REQUESTED model (`warmModel`). There is no boot-time
// pre-warm and no configured model: OCP cannot know which model the next caller wants, and
// pre-booting a process for a model nobody asks for is pure waste. Consequence, stated
// plainly: the FIRST request after start (and the first after a model switch) is always a
// MISS. The pool pays off for the steady repeat traffic it exists to serve.
acquire(model) {
if (!this.enabled) return null;
// Retarget on a model switch: --model is fixed at spawn, so panes for another model are
// useless. Drop them now (they are replaced by the next refill) rather than holding
// processes for a model that is no longer being asked for. This includes any pane
// currently BOOTING for the old model — its tmux session already exists, so leaving it to
// die on resolve would both hold a useless process and block the next refill (one boot at
// a time) for up to POOL_BOOT_MS.
if (model !== this.warmModel) {
for (const p of this._panes) { this._drop(p, "model_switch"); }
this._panes = [];
this._cancelBooting("model_switch");
this.warmModel = model;
}
while (this._panes.length) {
const p = this._panes.shift();
if (this._now() - p.bootedAt > this.maxAgeMs) { this._drop(p, "expired"); continue; }
if (!this._paneHealthy(p.name)) { this._drop(p, "unhealthy"); continue; }
this.hits++;
return p; // caller OWNS it now: it is out of the registry (so out of the spare set),
// and the caller's finally MUST kill it. Single-use — never returned here.
}
this.misses++;
return null;
}
// Bring the pool back up to `size` warm panes for `warmModel`. Fire-and-forget: never
// awaited on the request path and never throws into it.
//
// SLOT ACCOUNTING: a refill boot deliberately does NOT take a TuiSemaphore slot. Those
// slots bound concurrent *turns* (each up to the 120 s wallclock) and belong to real
// requests; charging a background pre-boot against them would let the pool starve the
// traffic it exists to speed up. It cannot leak a slot either, because it never holds one.
//
// SERIALIZED, ONE BOOT AT A TIME (and re-kicked on success until the pool is at target).
// An earlier version launched all `want` boots at once; live at size=2 that put two cold
// `claude` boots plus an in-flight turn on the CPU together, and a refill overran even the
// generous pool readiness cap (tui_pool_boot_failed). Booting sequentially keeps each boot
// near its uncontended ~1.2 s, bounds the CPU burst the pool can cause, and still has the
// replacement pane warm long before the next request arrives.
//
// A genuinely FAILED boot deliberately does NOT re-kick the chain — that is the backoff. A
// persistently failing boot (bad claude binary, no auth) would otherwise spin, respawning
// forever. The next natural trigger (the following request's refill, or the reap tick's
// resume) retries it. A CANCELLED boot is different: we killed it on purpose, nothing is
// wrong, and resume() is expected to start a fresh one immediately.
refill() {
if (!this.enabled || this._paused || !this.warmModel) return;
if (this._bootingPane) return; // one boot in flight at a time
if (this._panes.length >= this.size) return; // already at target
const model = this.warmModel;
const gen = this._gen;
// Mint the identity BEFORE booting: bootPane creates the tmux session synchronously, so
// the pool must be able to name (and therefore spare, and kill) it from this moment on.
const ident = this._mintPane();
this._bootingPane = { ...ident, model };
let enlisted = false;
Promise.resolve()
.then(() => this._bootPane(model, ident))
.then((pane) => {
// The world may have moved while we booted. If our generation was cancelled, kill the
// pane here rather than ASSUMING _cancelBooting already did.
//
// Why not just `return`: _cancelBooting kills by name, but the tmux session only EXISTS
// once _bootPane has actually run — and _bootPane is queued on a microtask (above). A
// caller that does refill() and then drain() in the SAME synchronous block would have
// _cancelBooting find nothing to kill (a no-op), bump the generation, and then this
// microtask would create the session, boot it fine, and — under a bare `return` — walk
// away from a LIVE authenticated `claude` that nothing owns. That is M1b in a new costume.
// No current call site does that, so this is defense-in-depth, not a live bug — but ADR
// 0008 and the reap-tick comment in server.mjs both explicitly contemplate a boot-time
// pre-warm, which is exactly the shape that would reach it.
//
// Killing an already-dead session is a harmless no-op (_drop swallows it), so this is
// idempotent whether or not _cancelBooting got there first.
if (gen !== this._gen) { this._drop(pane, "cancelled_late"); return; }
// Otherwise: still possible the pool filled or retargeted without a cancellation.
if (this._paused || model !== this.warmModel || this._panes.length >= this.size) {
this._drop(pane, "stale_boot");
return;
}
this._panes.push(pane);
this.boots++;
enlisted = true;
})
.catch((e) => {
// A rejection from a CANCELLED generation is not a fault: it is almost always
// "tui_pane_not_ready", thrown because WE killed the pane out from under the boot.
// Counting it as a bootFailure would fire the exact WARN operators are told to alert
// on, for a completely healthy drain. Stay silent — _cancelBooting already counted
// this as a cancellation, so do NOT count it again here.
if (gen !== this._gen) return;
this.bootFailures++;
this._log("warn", "tui_pool_boot_failed", { model, error: e && e.message });
})
.finally(() => {
// ONLY the current generation's boot owns the booting slot. A stale settle must not
// clear a slot that a newer boot (started by resume()) already holds.
if (gen === this._gen) this._bootingPane = null;
if (enlisted) this.refill(); // continue toward target, still one at a time
});
}
// Kill the in-flight boot's pane, SYNCHRONOUSLY, and invalidate its generation. Returns 1
// if there was one, else 0. The tmux session already exists (bootPane created it before it
// started waiting for readiness), so this is a real kill, not a cancellation flag.
_cancelBooting(reason) {
if (!this._bootingPane) return 0;
this._gen++; // the in-flight boot's settle is now inert
this._drop(this._bootingPane, reason); // synchronous kill-session
this._bootingPane = null;
this.cancelled++;
return 1;
}
// Kill every pane the pool owns — warm AND currently booting — and stop refilling. Returns
// how many were killed.
//
// Called (a) before the periodic reap sweep — reapStaleTuiSessions can only reap defunct
// `claude` zombies via kill-server, and kill-server is suppressed while any live pooled pane
// exists (including a booting one), so without this drain the pool would permanently disable
// zombie reaping; and (b) on graceful shutdown, so no pane outlives the process as an orphan.
//
// EVERY KILL HERE IS SYNCHRONOUS, and that is load-bearing. It is NOT safe to leave the
// booting pane to clean itself up on resolve: gracefulShutdown calls process.exit() in the
// same tick as this drain (TUI panes are children of the tmux SERVER, not of node, so
// node's activeProcesses set is empty on a TUI host and the "wait for children" path exits
// immediately). A .then()/.catch() scheduled here would never run, and the pane would
// survive as an orphaned, authenticated, idle `claude`.
drain() {
this._paused = true;
let n = this._panes.length;
for (const p of this._panes) this._drop(p, "drain");
this._panes = [];
n += this._cancelBooting("drain_booting");
return n;
}
// Undo drain() and start refilling again. Because drain() CANCELLED the in-flight boot
// (rather than leaving it pending), the booting slot is free and this really does start a
// fresh boot — the pool is never left empty with nothing scheduled.
resume() {
this._paused = false;
this.refill();
}
// /health surface (additive).
stats() {
return {
size: this.size,
warm: this._panes.length,
booting: this.booting,
model: this.warmModel,
hits: this.hits,
misses: this.misses,
boots: this.boots,
bootFailures: this.bootFailures,
cancelled: this.cancelled,
dropped: this.dropped,
};
}
_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 });
}
}
+1 -41
View File
@@ -139,40 +139,7 @@ export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the // Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
// config + live counters, returns the exact object embedded in /health. New fields only — // config + live counters, returns the exact object embedded in /health. New fields only —
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006). // behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
// export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
// `pool` (optional, warm pane pool — lib/tui/pool.mjs): a TuiPanePool, or null/undefined
// 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.
//
// 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 { return {
enabled, enabled,
entrypointMode, // cli | auto | off entrypointMode, // cli | auto | off
@@ -181,12 +148,5 @@ export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent, st
inflight: semaphore.inflight, // current concurrent TUI turns inflight: semaphore.inflight, // current concurrent TUI turns
queued: semaphore.queued, // turns waiting for a slot queued: semaphore.queued, // turns waiting for a slot
maxConcurrent, 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,
}; };
} }
+52 -302
View File
@@ -14,7 +14,6 @@ import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readTuiTranscript } from "./transcript.mjs"; 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 // 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 // ("ocp-tui-"), so a SECOND OCP instance on the same host (e.g. a temporary
@@ -74,36 +73,6 @@ const defaultTmux = (args, opts = {}) =>
// `port` (required) is this instance's own listen port (server.mjs's PORT / lib/constants.mjs // `port` (required) is this instance's own listen port (server.mjs's PORT / lib/constants.mjs
// DEFAULT_PORT resolution) — the SPOT for "which sessions are ours." // DEFAULT_PORT resolution) — the SPOT for "which sessions are ours."
// //
// ── POOL/REAPER INVARIANT (warm pane pool — lib/tui/pool.mjs) ───────────────────────────
// A warm pooled pane is one of OUR OWN `ocp-tui-<port>-*` sessions that is ALIVE AND IDLE
// BY DESIGN — and the periodic sweep runs precisely when the instance is idle, i.e. exactly
// when the pool is full. Without an exemption the sweep would kill every warm pane on every
// tick (and kill-server on top). The exemption is `spare`: a set of EXACT session names the
// caller declares live. Three properties, all load-bearing:
//
// 1. A LIVE POOLED PANE IS NEVER REAPED — INCLUDING ONE THAT IS STILL BOOTING. It is in
// `spare` (the pool's live registry), so it is skipped by name. The booting case is not
// a footnote, it is the one that bit us: bootTuiPane creates the tmux session
// SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS for the input bar, so a pooled
// session can be live for ~20 s before its boot resolves. The pool therefore mints the
// pane's NAME up front and holds it in `_bootingPane`, so liveNames() can name — and
// spare — a session whose boot has not finished. (An earlier version tracked only a
// COUNT of in-flight boots; the sweep could not name that session and killed it.)
// 2. A LEAKED/ORPHANED POOLED PANE IS STILL REAPED. Membership is by EXACT NAME from a
// live in-memory registry — NOT by "looks pooled" (name shape). A pane the pool no
// longer owns (handed out, dropped, cancelled, or left behind by a previous process
// generation — whose registry died with it) is absent from `spare` and is killed like
// any other stale session. Fail-safe: forgetting to pass `spare` reaps MORE, never less.
// 3. KILL-SERVER NEVER KILLS A LIVE POOL PANE. A spared session suppresses kill-server
// exactly as a foreign session does (it is a live child of the tmux server). The
// consequence — that a permanently-full pool would permanently disable the defunct-
// zombie reaping that ONLY kill-server can do — is resolved in server.mjs by DRAINING
// the pool immediately before the sweep, so `spare` is empty on the normal tick and
// kill-server still fires. `spare` is the belt-and-braces: a reap call site that
// forgets to drain still cannot kill a live pane.
//
// `spare` (default: none) — iterable of session names, or a Set. Ignored when the pool is off.
//
// `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix // `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix
// shape (LEGACY_SESSION_NAME_RE) are ALSO treated as ours for kill-session purposes. This is // shape (LEGACY_SESSION_NAME_RE) are ALSO treated as ours for kill-session purposes. This is
// the boot-time legacy migration: an operator upgrading past this fix could otherwise be left // the boot-time legacy migration: an operator upgrading past this fix could otherwise be left
@@ -119,20 +88,14 @@ const defaultTmux = (args, opts = {}) =>
// same class of residual risk the audit finding itself accepts ("no live instance of the new // same class of residual risk the audit finding itself accepts ("no live instance of the new
// version creates them"); this PR does not regress that scenario, it only removes the far // version creates them"); this PR does not regress that scenario, it only removes the far
// more common same-version collision (the actual F7 finding). // more common same-version collision (the actual F7 finding).
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false, spare = null } = {}) { export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false } = {}) {
const r = tmux(["list-sessions", "-F", "#{session_name}"]); const r = tmux(["list-sessions", "-F", "#{session_name}"]);
if (!r || r.status !== 0) return 0; // no tmux server / no sessions if (!r || r.status !== 0) return 0; // no tmux server / no sessions
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean); const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
const ownPrefix = sessionPrefixForPort(port); const ownPrefix = sessionPrefixForPort(port);
const spared = spare instanceof Set ? spare : new Set(spare || []);
let killed = 0; let killed = 0;
let othersRemain = false; let othersRemain = false;
let sparedLive = 0;
for (const name of names) { for (const name of names) {
// Property 1+2: exemption is by EXACT NAME from the pool's live registry. A pooled-
// LOOKING name that is not in the registry is an orphan and falls through to the
// normal kill path below.
if (spared.has(name)) { sparedLive++; continue; }
const isOwn = name.startsWith(ownPrefix); const isOwn = name.startsWith(ownPrefix);
const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name); const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name);
if (isOwn || isLegacyOwn) { if (isOwn || isLegacyOwn) {
@@ -146,11 +109,7 @@ export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy =
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty. // Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
// kill-server is what actually reaps (server exit reparents survivors to init); a // kill-server is what actually reaps (server exit reparents survivors to init); a
// per-session kill cannot, since node is not the zombies' parent. // per-session kill cannot, since node is not the zombies' parent.
// if (!othersRemain) {
// Property 3: a SPARED session is a live child of this tmux server, so kill-server would
// kill it — it therefore suppresses kill-server exactly as a foreign session does. On the
// normal sweep the pool is drained first, so sparedLive is 0 and kill-server still fires.
if (!othersRemain && sparedLive === 0) {
tmux(["kill-server"]); tmux(["kill-server"]);
} }
return killed; return killed;
@@ -160,18 +119,8 @@ export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy =
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable. // Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
// Readiness cap for a POOL pre-boot. Deliberately far more generous than BOOT_MS: BOOT_MS is
// tight because a client is blocked on it, whereas a warm-pane boot happens in the background
// with nobody waiting. Observed live at size=2: a refill booting alongside an in-flight turn
// exceeded 4000 ms and was discarded (tui_pool_boot_failed), quietly costing hit rate for a
// pane that was merely slow, not broken. Scales with OCP_TUI_BOOT_MS if an operator raises it.
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 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 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)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -353,25 +302,7 @@ export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false }
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B // 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 // (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. // 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 // 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 // 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: // host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
@@ -415,8 +346,6 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(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"]; const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli"); if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
@@ -473,10 +402,6 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
effortArgs = ["--effort", "low"]; 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 [ return [
envPrefix, envPrefix,
shq(claudeBin), shq(claudeBin),
@@ -484,71 +409,41 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
"--session-id", sessionId, "--session-id", sessionId,
...toolArgs, ...toolArgs,
...effortArgs, ...effortArgs,
...settingsArgs,
].join(" "); ].join(" ");
} }
// Is a pane alive AND still sitting at its input bar? Used by the warm pool to decide, // Full per-request TUI lifecycle:
// at hand-out time, whether a pre-booted pane is still usable (a dead/degraded pane must // 1. Pre-trust the scratch cwd (no trust dialog will appear).
// become a MISS → cold path, never a hung turn). capture-pane exits non-zero when the // 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
// session no longer exists, so this covers "pane gone" and "pane not ready" in one call. // 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
export function tuiPaneHealthy(tmux, tmuxName) { // capture-pane until the `? for shortcuts` input bar appears (readiness-poll
const r = tmux(["capture-pane", "-p", "-t", tmuxName]); // replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
if (!r || r.status !== 0 || typeof r.stdout !== "string") return false; // 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
return tuiInputReady(r.stdout); // reliable for large multi-line prompts where send-keys -l is not (issue #130).
} // Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
// Pool pane names carry a "p" marker after the port-scoped prefix: // wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
// turn pane: ocp-tui-<port>-<8hex> (unchanged) // 5. Block on the native JSONL transcript (located by session-id) until terminal
// pool pane: ocp-tui-<port>-p<8hex> // marker or wall-clock cap.
// Purely for operator legibility (`tmux ls` shows which panes are warm). It is NOT the // 6. Always teardown: kill session + rm temp dir (even on throw).
// reaper's exemption mechanism — that is the exact-name spare set (see the POOL/REAPER // Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// INVARIANT above), so a pooled-LOOKING orphan is still reaped. Both shapes start with // classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
// sessionPrefixForPort(port), so both remain reapable as "ours", and neither can match export async function runTuiTurn({
// LEGACY_SESSION_NAME_RE. prompt,
export function poolPaneName(port, sessionId) { model,
return sessionPrefixForPort(port) + "p" + sessionId.slice(0, 8); claudeBin,
} home,
realHome,
// Boot ONE interactive `claude` pane and wait for its input bar. Shared by the cold cwd,
// request path (runTuiTurn) and the warm pool (lib/tui/pool.mjs) so a pooled pane is port,
// spawned with byte-for-byte the same argv, HOME, cwd and trust preparation as a wallclockMs = 120000,
// cold-booted one — the pool must not become a second, drifting spawn path. entrypointMode = "cli",
// tmux = defaultTmux,
// Each pane gets its OWN fresh randomUUID() --session-id, fixed at boot. That is what
// keeps a pooled pane single-use-safe: its transcript holds exactly one exchange.
//
// requireReady: the cold path tolerates a readiness timeout (it falls through and lets
// the paste-verify decide — pre-existing behaviour, unchanged). The POOL sets it, because
// a pane that never reached its input bar is worthless as a warm pane and must not be
// enlisted: throw, let the pool count a bootFailure, and leave the request path to
// cold-boot as usual.
// bootMs: max wait for the input bar. Defaults to BOOT_MS (the REQUEST path's cap, which is
// deliberately tight — a client is blocked on it). The POOL passes POOL_BOOT_MS instead: a
// background pre-boot has nobody waiting on it, and capping it at the request-path's 4 s
// made real refills fail (observed live: a refill booting alongside an in-flight turn took
// >4 s and was discarded, silently lowering the hit rate). Slow != broken for a pre-boot.
// `sessionId` / `name` (both optional): the caller may supply the pane's identity instead of
// letting bootTuiPane mint it. The POOL does, because it must know the tmux session's NAME
// before this function runs — the session is created synchronously below, well before the
// 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(); const sessionId = randomUUID();
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions // Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
// for why this instance's own listen port is the namespace discriminator. // for why this instance's own listen port is the namespace discriminator.
const tmuxName = name || (sessionPrefixForPort(port) + sid.slice(0, 8)); const tmuxName = sessionPrefixForPort(port) + sessionId.slice(0, 8);
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real) const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source) const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
@@ -565,14 +460,10 @@ export async function bootTuiPane({
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true }); if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
prepareTuiHome(rhome, ehome, cwd, { envTokenMode }); prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
// Streaming sink for THIS pane (see the streamDir note above). rmSync first so a // Write prompt to a temp file (mode 0600) so the content never touches argv.
// re-used session-id can never replay a previous turn's deltas. const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
let streamFile = null, streamSettings = null; const promptFile = `${tmpDir}/prompt.txt`;
if (streamDir) { writeFileSync(promptFile, prompt, { mode: 0o600 });
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 // 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 // from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
@@ -580,147 +471,31 @@ export async function bootTuiPane({
const env = { ...process.env }; const env = { ...process.env };
env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix
// Boot the interactive session inside tmux, rooted at the scratch cwd. try {
// Capture the result: if tmux new-session fails (status !== 0) there is no PTY, no // 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
// interactive spawn — abort BEFORE the boot wait rather than paste into a non-existent // Capture the result: if tmux new-session fails (status !== 0) there is no
// session or issue a billing request without a verified interactive context. // PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
// into a non-existent session or issue a billing request without a verified
// interactive context. The finally teardown is still harmless (kill-session
// is a no-op when the session never existed).
const spawnResult = tmux( const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd, ["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode, buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
streamFile ? { file: streamFile, settings: streamSettings } : null)],
{ env }, { env },
); );
if (!spawnResult || spawnResult.status !== 0) { if (!spawnResult || spawnResult.status !== 0) {
throw new Error("tui_spawn_failed: tmux session not created"); throw new Error("tui_spawn_failed: tmux session not created");
} }
// Wait until claude's input bar is actually ready (not a blind sleep). // 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
// bootMs is the MAX readiness wait, not a fixed delay. // BOOT_MS is now the MAX readiness wait, not a fixed delay.
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)), const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
{ timeoutMs: bootMs, intervalMs: READY_POLL_MS }); { timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
if (!ready) { if (!ready) {
if (requireReady) { // (readiness timed out; relying on paste-verify)
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
throw new Error("tui_pane_not_ready: input bar did not appear within " + bootMs + "ms");
}
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
console.error("[tui] input_not_ready", tmuxName); console.error("[tui] input_not_ready", tmuxName);
} }
return { name: tmuxName, sessionId: sid, model, ehome, streamFile, bootedAt: Date.now() };
}
// Full per-request TUI lifecycle:
// 1. Take a WARM pane from the pool if one is available for this model (opt-in;
// OCP_TUI_POOL_SIZE=0 => always null => steps 2-3 below are exactly today's path).
// A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it
// serves this one turn, and it is killed in the finally like any other pane.
// 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux
// session in the scratch cwd, poll capture-pane until the `? for shortcuts` input bar
// appears (bootTuiPane). BOOT_MS is the max wait, not a fixed delay.
// 3. Write prompt to a 0600 temp file (no shell injection from prompt content).
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
// 5. Block on the native JSONL transcript (located by THIS pane's session-id) until
// terminal marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw), and kick a background
// 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,
claudeBin,
home,
realHome,
cwd,
port,
wallclockMs = 120000,
entrypointMode = "cli",
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;
const warm = !!pane;
// Kick the refill IMMEDIATELY (not after the turn): the replacement pane then boots
// CONCURRENTLY with this turn and is warm by the time the next request arrives. Also
// runs on a MISS — acquire() has just retargeted the pool to this model, so the miss
// that cold-boots today warms the pool for the next caller. Fire-and-forget; it takes
// no TuiSemaphore slot (see pool.refill's SLOT ACCOUNTING note).
if (pool) pool.refill();
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
if (!pane) {
// 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`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
try {
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT // 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the // `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
// embedded newlines arrive as separate key events (effectively repeated Enter), // embedded newlines arrive as separate key events (effectively repeated Enter),
@@ -746,37 +521,12 @@ export async function runTuiTurn({
// Submit (separate Enter key event). // Submit (separate Enter key event).
tmux(["send-keys", "-t", tmuxName, "Enter"]); tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 5a. Streaming only: start polling the hook sink. Runs CONCURRENTLY with the // 4. Block on the native transcript (resolved by session-id) until terminal.
// transcript wait below — the deltas are what make the answer visible while the // Returns { text, entrypoint } from readTuiTranscript.
// turn is still generating; the transcript is what makes it authoritative. return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
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 { } finally {
// 6. Teardown — always, even on throw (including an abortSignal disconnect, which is // 5. Teardown — always, even on throw.
// 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 { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
if (streamFile) { try { rmSync(streamFile, { force: true }); } catch { /* best effort */ } }
} }
} }
-288
View File
@@ -1,288 +0,0 @@
// 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;
// Resolve OCP_TUI_STREAM_HOLDBACK to a SAFE value. The whole C-1 auth-banner guarantee rests
// on the holdback being at least the default banner detector's max message length — which is
// exactly DEFAULT_HOLDBACK_CHARS. So this is a FLOOR, not a hint: a smaller value (or a NaN
// typo like "unlimited"/"5MB") would let a real banner fragment release before the terminal
// detector could classify the whole message, silently reopening the leak the assembler exists
// to prevent. The env var's own doc says "Only raise it"; this enforces that instead of trusting
// it. Returns { value, clamped } so the caller can warn when it had to clamp — a silent floor is
// less honest than a noticed one.
export function resolveStreamHoldback(raw, floor = DEFAULT_HOLDBACK_CHARS) {
const parsed = parseInt(raw ?? "", 10);
if (!Number.isFinite(parsed)) return { value: floor, clamped: raw != null && String(raw).trim() !== "" };
if (parsed < floor) return { value: floor, clamped: true };
return { value: parsed, clamped: false };
}
// 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.
// Sentinel for "no message seen yet". Deliberately not null/undefined — see the constructor.
const NO_MESSAGE_YET = Symbol("no-message-yet");
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;
// NOT null: a payload may legitimately carry message_id === null, and if the sentinel were
// also null the FIRST such payload would compare equal to it, register no boundary, and
// leave `messages` at 0 — which used to disarm the restartedAfterEmit guard below entirely.
// A unique object is === to nothing a JSON payload can produce, so the first fire ALWAYS
// registers as message 1, whatever its message_id is (or isn't).
this.messageId = NO_MESSAGE_YET;
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 {
// A boundary while bytes are ALREADY out is unrecoverable, full stop — the count of
// messages seen so far is irrelevant. The old `else if (this.messages > 1)` guard was
// the sole reason a null-message_id first payload could disarm F1: it left `messages`
// at 0, so the real boundary evaluated 1 > 1 === false and never armed. The invariant
// is "a boundary occurred while emitted !== ''", and that is exactly what this says.
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,
};
}
}
+1 -22
View File
@@ -73,17 +73,6 @@ export function isTerminalLine(obj) {
// transcript holding one logical exchange). If a future warm-pool ever reuses a // transcript holding one logical exchange). If a future warm-pool ever reuses a
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that // session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
// author must add user-line scoping here. See spec §7.2. // author must add user-line scoping here. See spec §7.2.
//
// STATUS (warm pool, lib/tui/pool.mjs — the "future warm-pool" this note anticipated):
// the pool does NOT reuse sessions, so the precondition above still holds and no
// user-line scoping was added. Each pooled pane is booted with its OWN fresh
// randomUUID() --session-id (bootTuiPane) and is SINGLE-USE: it serves exactly one turn
// and is then killed and replaced. One session still means one logical exchange, so the
// last assistant entry is still that request's answer.
// The warning therefore stands UNCHANGED for anyone who later wants a pane to serve a
// SECOND turn (or to reset one with /clear and reuse it): that is a leak, and it needs
// user-line scoping HERE before it can be safe. Do not relax pool.mjs's single-use rule
// without doing that work first.
export function extractLatestAssistantText(events) { export function extractLatestAssistantText(events) {
let text = ""; let text = "";
for (const ev of events) { for (const ev of events) {
@@ -267,21 +256,11 @@ export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TU
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass // Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript // `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. // file does not exist until the turn starts, so resolution happens inside the loop.
// `abortSignal` (optional): when it fires, stop waiting and throw TuiAbortError. The one export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
// 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; const deadline = Date.now() + wallclockMs;
let lastText = ""; let lastText = "";
let lastEntrypoint = null; let lastEntrypoint = null;
while (Date.now() < deadline) { 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); const resolved = p || findTranscriptPath(home, sessionId);
if (resolved && existsSync(resolved)) { if (resolved && existsSync(resolved)) {
const events = parseTranscriptLines(readFileSync(resolved, "utf8")); const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
-8
View File
@@ -26,14 +26,6 @@
"contextWindow": 200000, "contextWindow": 200000,
"maxTokens": 16384 "maxTokens": 16384
}, },
{
"id": "claude-sonnet-5",
"displayName": "Claude Sonnet 5",
"openclawName": "Claude Sonnet 5 (via CLI)",
"reasoning": true,
"contextWindow": 200000,
"maxTokens": 16384
},
{ {
"id": "claude-sonnet-4-6", "id": "claude-sonnet-4-6",
"displayName": "Claude Sonnet 4.6", "displayName": "Claude Sonnet 4.6",
+1 -6
View File
@@ -622,12 +622,7 @@ cmd_restart() {
self_r="${BASH_SOURCE[0]}" self_r="${BASH_SOURCE[0]}"
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
script_dir="$(cd "$(dirname "$self_r")" && pwd)" script_dir="$(cd "$(dirname "$self_r")" && pwd)"
# env -u strips test-only key-store redirection vars (A4): if the invoking shell had DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
# NODE_ENV=test + OCP_DIR_OVERRIDE exported (e.g. from a debugging session), this manual
# fallback would otherwise inherit them and start the daemon against a scratch/empty key
# store — a silent auth outage in AUTH_MODE=multi. The plist/systemd paths strip these via
# plist-merge's NEVER_PRESERVE; this covers the one direct-launch path OCP controls.
DISABLE_AUTOUPDATER=1 env -u NODE_ENV -u OCP_DIR_OVERRIDE nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
fi fi
sleep 3 sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
+8 -14
View File
@@ -122,17 +122,11 @@ provider = {
"models": [] "models": []
} }
# Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet / # Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
# -haiku), not a pinned version. A version-pinned prefix like "claude-sonnet-4"
# silently misses "claude-sonnet-5" and falls through to the non-reasoning /
# 8k-output default (PR #152 review) — every future Sonnet/Opus/Haiku bump would
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
# expose reasoning/maxTokens, so family classification stays here.)
model_meta = { model_meta = {
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384}, "claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192}, "claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
} }
def get_model_meta(mid): def get_model_meta(mid):
@@ -184,11 +178,11 @@ config.setdefault("agents", {})
config["agents"].setdefault("defaults", {}) config["agents"].setdefault("defaults", {})
config["agents"]["defaults"].setdefault("models", {}) config["agents"]["defaults"].setdefault("models", {})
# Build alias map (family prefix match — version-agnostic, see model_meta note) # Build alias map (prefix match)
alias_prefixes = { alias_prefixes = {
"claude-opus": "Claude Opus", "claude-opus-4": "Claude Opus",
"claude-sonnet": "Claude Sonnet", "claude-sonnet-4": "Claude Sonnet",
"claude-haiku": "Claude Haiku", "claude-haiku-4": "Claude Haiku",
} }
for mid in model_ids: for mid in model_ids:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "name": "open-claude-proxy",
"version": "3.22.1", "version": "3.21.1",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.", "description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module", "type": "module",
"bin": { "bin": {
+2 -15
View File
@@ -8,19 +8,6 @@
// //
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape // No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
// is stable enough for our hand-written templates in setup.mjs. // is stable enough for our hand-written templates in setup.mjs.
//
// SECURITY DENYLIST (A4): keys that must NEVER be carried into a service unit, even when a
// prior unit already contained them. OCP's key store honors OCP_DIR_OVERRIDE only when
// NODE_ENV === "test" (keys.mjs). If BOTH somehow reached a daemon's environment, the server
// would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi a silent
// total auth outage. The preservation rule below ("keys only in EXISTING are kept verbatim")
// is exactly a vector for that: a unit that once carried these test-only vars would otherwise
// survive every setup re-run. So we strip them from the preserved set unconditionally. This is
// defense-in-depth: setup.mjs's own template never injects them, so the only way they enter is
// preservation, and this closes it. (The residual path — a hand-rolled `node server.mjs` with
// both vars exported — is out of any launcher's reach; keys.mjs's loud "NOT the default" log is
// the backstop there.)
export const NEVER_PRESERVE = new Set(["NODE_ENV", "OCP_DIR_OVERRIDE"]);
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()), // Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe. // so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
@@ -49,7 +36,7 @@ export function mergePlistEnv(existing, template) {
const preserved = {}; const preserved = {};
for (const [k, v] of Object.entries(existingEnv)) { for (const [k, v] of Object.entries(existingEnv)) {
if (!KNOWN.has(k) && !NEVER_PRESERVE.has(k)) preserved[k] = v; if (!KNOWN.has(k)) preserved[k] = v;
} }
if (Object.keys(preserved).length === 0) return template; if (Object.keys(preserved).length === 0) return template;
@@ -85,7 +72,7 @@ export function mergeSystemdEnv(existing, template) {
const KNOWN = new Set(Object.keys(templateEnv)); const KNOWN = new Set(Object.keys(templateEnv));
const preservedLines = Object.entries(existingEnv) const preservedLines = Object.entries(existingEnv)
.filter(([k]) => !KNOWN.has(k) && !NEVER_PRESERVE.has(k)) .filter(([k]) => !KNOWN.has(k))
.map(([k, v]) => `Environment=${k}=${v}`); .map(([k, v]) => `Environment=${k}=${v}`);
if (preservedLines.length === 0) return template; if (preservedLines.length === 0) return template;
+23 -455
View File
@@ -22,8 +22,6 @@
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8) * CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
* CLAUDE_MAX_QUEUE — max requests waiting for a -p slot before HTTP 429 (default: 16) * CLAUDE_MAX_QUEUE — max requests waiting for a -p slot before HTTP 429 (default: 16)
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2) * OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
* OCP_TUI_POOL_SIZE — pre-booted warm `claude` panes held for TUI-mode (default: 0 = off;
* max 4). Each is a live idle process; cuts ~3-4s per request.
* OCP_SPAWN_REAL_HOME — "1" forces the -p spawn to use the real HOME (disables the * OCP_SPAWN_REAL_HOME — "1" forces the -p spawn to use the real HOME (disables the
* latency spawn-home isolation; default: isolated when a token exists) * latency spawn-home isolation; default: isolated when a token exists)
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6) * CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
@@ -34,7 +32,7 @@
* CLAUDE_HEARTBEAT_INTERVAL — SSE heartbeat interval in ms on streaming path (default: 0 = disabled) * CLAUDE_HEARTBEAT_INTERVAL — SSE heartbeat interval in ms on streaming path (default: 0 = disabled)
*/ */
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn, execFileSync, spawnSync } from "node:child_process"; import { spawn, execFileSync } from "node:child_process";
import { randomUUID, timingSafeEqual } from "node:crypto"; import { randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -43,11 +41,9 @@ import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { DEFAULT_PORT } from "./lib/constants.mjs"; import { DEFAULT_PORT } from "./lib/constants.mjs";
import { isLoopbackBind } from "./lib/net.mjs"; import { isLoopbackBind } from "./lib/net.mjs";
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs"; import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome } from "./lib/tui/session.mjs";
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs"; import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs"; import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs"; import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -97,40 +93,8 @@ function _collectNodeManagerCandidates(home) {
return out; return out;
} }
function _joinIfBase(base, ...parts) {
return base ? join(base, ...parts) : null;
}
function _collectWindowsClaudeCandidates() {
const userProfile = process.env.USERPROFILE || process.env.HOME || "";
const localAppData = process.env.LOCALAPPDATA || "";
return [
_joinIfBase(userProfile, ".local", "bin", "claude.exe"),
_joinIfBase(localAppData, "Microsoft", "WinGet", "Links", "claude.exe"),
_joinIfBase(localAppData, "Microsoft", "WindowsApps", "claude.exe"),
].filter(Boolean);
}
function _isWindowsSpawnableBinary(path) {
return /\.exe$/i.test(path);
}
function _lookupLines(out) {
return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
}
function _warnUnspawnableWindowsMatches(lines) {
const unspawnable = lines.filter(p => !/\.exe$/i.test(p));
if (unspawnable.length > 0) {
console.warn(`[init] Ignoring non-exe Windows claude command(s): ${unspawnable.join(", ")}`);
}
}
function resolveClaude() { function resolveClaude() {
const isWin = process.platform === "win32";
if (process.env.CLAUDE_BIN) { if (process.env.CLAUDE_BIN) {
if (isWin && !_isWindowsSpawnableBinary(process.env.CLAUDE_BIN)) {
console.error(
`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is not a native Windows executable.\n` +
" Set CLAUDE_BIN to claude.exe; shell shims cannot be spawned without a shell."
);
process.exit(1);
}
try { try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK); accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN; return process.env.CLAUDE_BIN;
@@ -140,10 +104,8 @@ function resolveClaude() {
} }
} }
const home = process.env.HOME || process.env.USERPROFILE || ""; const home = process.env.HOME || "";
const candidates = isWin const candidates = [
? _collectWindowsClaudeCandidates()
: [
"/opt/homebrew/bin/claude", "/opt/homebrew/bin/claude",
"/usr/local/bin/claude", "/usr/local/bin/claude",
"/usr/bin/claude", "/usr/bin/claude",
@@ -154,29 +116,16 @@ function resolveClaude() {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {} try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
} }
if (isWin) {
try {
const lines = _lookupLines(execFileSync("where.exe", ["claude"], { encoding: "utf8", timeout: 5000 }));
const resolved = lines.find(_isWindowsSpawnableBinary);
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via where.exe: ${resolved}`); return resolved; }
_warnUnspawnableWindowsMatches(lines);
} catch {}
} else {
try { try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim(); const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; } if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {} } catch {}
}
console.error( console.error(
"FATAL: claude binary not found.\n" + "FATAL: claude binary not found.\n" +
(isWin " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
? " Set CLAUDE_BIN to the absolute path of claude.exe or ensure claude.exe is in PATH.\n" +
" Hint: npm .cmd/.bat/.ps1 shims cannot be spawned without a shell.\n" +
" The .exe requirement is an intentional allow-list for shell-less spawning.\n"
: " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" + " Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
" shown by `which claude` in your interactive shell.\n") + " shown by `which claude` in your interactive shell.\n" +
" Checked: " + candidates.join(", ") " Checked: " + candidates.join(", ")
); );
process.exit(1); process.exit(1);
@@ -400,111 +349,8 @@ const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
const tuiStats = { const tuiStats = {
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null) lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
entrypointMismatches: 0, // count of cli-expected-but-got-other turns entrypointMismatches: 0, // count of cli-expected-but-got-other turns
streamTurns: 0, // streamed TUI turns ATTEMPTED (counted before the honesty gates — F6)
streamDeltas: 0, // MessageDisplay hook fires OBSERVED (forwarded + held-back — F6)
streamTopUps: 0, // turns where the delta stream != T but was a safe PREFIX of it
streamDivergences: 0, // turns REFUSED: emitted bytes were not a prefix of T
streamZeroDeltaTurns: 0, // streamed turns where the hook fired ZERO times (F7 — the hook is
// dead, not just one fire dropped; distinct from streamTopUps)
}; };
// ── TUI real streaming (backlog #2) — opt-in; default OFF ────────────────
// When ON *and* TUI_MODE is on *and* the client asked for stream:true, the turn is emitted
// as real SSE delta.content chunks as claude renders them, sourced from claude's own
// MessageDisplay hook (lib/tui/stream.mjs). When OFF, the buffered
// callClaudeTui → streamStringAsSSE path below is byte-for-byte unchanged — the spawn does
// not even get --settings. Opt-in is deliberate: the buffered path is stable production.
//
// Honest expectation (docs/plans/2026-07-13-tui-latency/streaming-spike.md): this moves the
// FIRST byte, not the last. A consumer that must parse a complete reply gains nothing; a
// progressively-rendering chat UI gains the ~4s between first delta and last. It does not
// move the ~6s TTFT floor of TUI mode.
const TUI_STREAM = process.env.OCP_TUI_STREAM === "1";
const TUI_STREAM_DIR = process.env.OCP_TUI_STREAM_DIR || `${process.env.HOME}/.ocp-tui/stream`;
// First-bytes holdback — the auth-banner gate's (C-1) survival mechanism under streaming.
// See TuiDeltaAssembler: nothing is emitted for a message until its TRIMMED accumulation
// exceeds this, which puts it out of the default banner detector's <=100-char reach — the
// FIRST of the two halves of the guarantee (see the assembler's class comment for the second:
// no further emission at all once a message boundary follows an emit). Only raise it.
// resolveStreamHoldback enforces the DEFAULT_HOLDBACK_CHARS floor: the "Only raise it" comment
// above is now load-bearing, not advisory. A sub-floor value (or garbage) is clamped UP to the
// floor and reported via `_holdback.clamped`, because a holdback below the default banner
// detector's 100-char reach would let the first chars of a real auth banner stream before the
// end-of-turn gate rejects the turn (the A1 leak). We can only ever raise the guarantee, never
// weaken it below the detector's bound.
const _holdback = resolveStreamHoldback(process.env.OCP_TUI_STREAM_HOLDBACK);
const TUI_STREAM_HOLDBACK = _holdback.value;
if (TUI_MODE && TUI_STREAM && _holdback.clamped) {
console.error(
`[tui] WARNING: OCP_TUI_STREAM_HOLDBACK=${JSON.stringify(process.env.OCP_TUI_STREAM_HOLDBACK)} is below the\n` +
` safe floor (${DEFAULT_HOLDBACK_CHARS}) or not a number; clamped up to ${DEFAULT_HOLDBACK_CHARS}. The holdback can only be raised.`
);
}
if (TUI_MODE && TUI_STREAM && process.env.CLAUDE_TUI_ERROR_PATTERNS != null && TUI_STREAM_HOLDBACK <= DEFAULT_HOLDBACK_CHARS) {
// The holdback's FIRST-MESSAGE half (see TuiDeltaAssembler) is sound for the DEFAULT
// auth-banner detector (which cannot match a message longer than 100 chars). An
// operator-supplied pattern set has no such bound, so a banner longer than the holdback
// could reach the client before the terminal gate rejects the turn. (The second half — no
// further emission once a message boundary follows an emit — holds regardless of the
// detector; this warning is only about the first-message case.)
console.error(
`[tui] WARNING: OCP_TUI_STREAM=1 with a custom CLAUDE_TUI_ERROR_PATTERNS and holdback=${TUI_STREAM_HOLDBACK}.\n` +
" The streaming holdback's first-message coverage is sound only against the DEFAULT banner\n" +
" detector (<=100 chars). Raise OCP_TUI_STREAM_HOLDBACK above your longest custom banner, or\n" +
" the first chars of one could be streamed before the end-of-turn gate refuses the turn."
);
}
// ── Warm pane pool (docs/plans/2026-07-13-tui-latency #3) — opt-in; default OFF ─────────
// OCP_TUI_POOL_SIZE=0 (default) => tuiPool is null => runTuiTurn's cold-boot path is
// byte-for-byte unchanged. Set it to N (clamped to POOL_MAX_SIZE) to keep N pre-booted
// `claude` panes warm, each SINGLE-USE (see lib/tui/pool.mjs for why single-use is the
// load-bearing rule, and lib/tui/session.mjs for the POOL/REAPER INVARIANT).
//
// Default-off is deliberate on a stable production path: a warm pane is a LIVE idle
// `claude` process held whether or not a request ever arrives, so the operator must opt
// in to that standing cost. Measured saving when on (this host, Sonnet 4.6, --effort low):
// end-to-end p50 10.17 s (n=6, pool off) -> 6.00 s (n=12 warm hits), i.e. -41%.
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
const TUI_POOL_SIZE = TUI_MODE ? resolvePoolSize(process.env.OCP_TUI_POOL_SIZE) : 0;
const tuiPool = TUI_POOL_SIZE > 0
? new TuiPanePool({
size: TUI_POOL_SIZE,
// The POOL mints the pane's identity, not bootTuiPane: the tmux session exists the
// instant the boot starts, so the pool must be able to name (hence spare, hence kill)
// it before then. Name is derived from the session-id, so `tmux ls` correlates to the
// transcript file <HOME>/.claude/projects/*/<sessionId>.jsonl.
mintPane: () => {
const sessionId = randomUUID();
return { sessionId, name: poolPaneName(PORT, sessionId) };
},
bootPane: (model, ident) => bootTuiPane({
model,
claudeBin: CLAUDE,
home: TUI_HOME,
realHome: process.env.HOME,
cwd: TUI_CWD,
port: PORT,
entrypointMode: TUI_ENTRYPOINT,
sessionId: ident.sessionId,
name: ident.name,
requireReady: true, // a pane that never reached its input bar must not be enlisted
bootMs: POOL_BOOT_MS, // background pre-boot — no client is blocked, so be patient
// Warm panes must carry the MessageDisplay hook too, or every pool HIT would
// silently fall back to buffered while every MISS streamed — the two paths have to
// spawn identically (F4). Gated on TUI_STREAM, the deployment-wide switch — NOT on any
// particular request's stream:true/false, which does not exist yet at pre-boot time.
// The runTuiTurn cold-boot call site (callClaudeTui, below) mirrors this exact gate for
// the same reason. bootTuiPane derives the sink from the pane's own session-id, which is
// minted above, so nothing request-specific is baked in at pre-boot time.
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
}),
killPane: (name) => { try { spawnSync(process.env.OCP_TUI_TMUX_BIN || "tmux", ["kill-session", "-t", name]); } catch { /* already gone */ } },
paneHealthy: (name) => tuiPaneHealthy((args) => spawnSync(process.env.OCP_TUI_TMUX_BIN || "tmux", args, { encoding: "utf8" }), name),
log: (level, event, data) => logEvent(level, event, data),
})
: null;
// ── FIX ③ (latency): default-path (-p / stream-json) spawn-home isolation ────────────── // ── FIX ③ (latency): default-path (-p / stream-json) spawn-home isolation ──────────────
// PROBLEM (measured, not theoretical): OCP's default spawn inherits the operator's real HOME // PROBLEM (measured, not theoretical): OCP's default spawn inherits the operator's real HOME
// (loading the global ~/.claude — plugins, skills, hooks) and runs with cwd=~/ocp (loading the // (loading the global ~/.claude — plugins, skills, hooks) and runs with cwd=~/ocp (loading the
@@ -534,12 +380,7 @@ const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
// erroring loudly — never a silent auth/credential corruption (there are no credentials here). // erroring loudly — never a silent auth/credential corruption (there are no credentials here).
function prepareSpawnHome(dir = SPAWN_HOME_DIR) { function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
try { try {
// mode 0700, and it matters for the PARENT: with `recursive`, this call can create ~/.ocp mkdirSync(`${dir}/.claude`, { recursive: true });
// itself on a fresh install (spawn homes live under it), and without an explicit mode that
// parent lands at the umask default — world-listable 0755. keys.mjs used to pre-create it
// 0700 as an import side effect; it no longer does (it resolves its dir lazily), so the
// 0700 guarantee has to be stated here rather than inherited by luck.
mkdirSync(`${dir}/.claude`, { recursive: true, mode: 0o700 });
// Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours). // Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours).
for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) { for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) {
try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ } try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ }
@@ -929,45 +770,19 @@ const cacheCleanupInterval = setInterval(() => {
// mechanism and the 15-min cadence makes the window negligible). // mechanism and the 15-min cadence makes the window negligible).
// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off. // Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off.
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007. // cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
//
// WARM POOL INTERACTION (the crux — see the POOL/REAPER INVARIANT in lib/tui/session.mjs).
// A warm pooled pane is one of OUR OWN ocp-tui-<port>-* sessions that is alive and idle BY
// DESIGN, and this sweep fires precisely when the instance is idle — i.e. exactly when the
// pool is full. Two things are therefore required, and both are done here:
// (a) DRAIN the pool BEFORE the sweep. Zombie reaping is possible ONLY via kill-server,
// and a live pooled pane suppresses kill-server (it is a live child of the tmux
// server). A permanently-full pool would otherwise permanently disable the very
// thing this tick exists to do. Draining costs one pane re-boot per tick (~1.2 s of
// background work every 15 min) and is invisible to callers: a request landing in the
// drain→refill gap simply MISSES the pool and takes today's cold path.
// (b) Pass the pool's live registry as `spare` anyway. After (a) it is empty, so this is
// belt-and-braces — it makes it impossible for THIS call site (or a future one) to
// kill a live pooled pane even if the drain were ever removed or reordered.
// RESIDUAL (unchanged in kind from the pre-pool code, and explicitly accepted there): a
// request arriving in the narrow window between the idle-check and kill-server has its pane
// torn down and fails cleanly via runTuiTurn's honesty gates. The drain widens that window
// by the cost of N kill-session calls (single-digit ms), not materially.
const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000; const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000;
const tuiReapInterval = TUI_MODE ? setInterval(() => { const tuiReapInterval = TUI_MODE ? setInterval(() => {
if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer
try { try {
const drained = tuiPool ? tuiPool.drain() : 0;
// F7 fix: scope to THIS instance's own port; a sibling ocp-tui-<otherPort>-* session // F7 fix: scope to THIS instance's own port; a sibling ocp-tui-<otherPort>-* session
// (a second OCP instance on the same host) is treated as foreign, same as olp-tui-*. // (a second OCP instance on the same host) is treated as foreign, same as olp-tui-*.
// includeLegacy is NOT set here — see reapStaleTuiSessions' comment: the periodic sweep // includeLegacy is NOT set here — see reapStaleTuiSessions' comment: the periodic sweep
// conservatively treats any lingering bare-prefix legacy session as foreign so it can // conservatively treats any lingering bare-prefix legacy session as foreign so it can
// never trigger kill-server on a steady-state tick; only the one-time boot reap below // never trigger kill-server on a steady-state tick; only the one-time boot reap below
// claims legacy-shaped zombies. // claims legacy-shaped zombies.
const n = reapStaleTuiSessions({ port: PORT, spare: tuiPool ? tuiPool.liveNames() : null }); const n = reapStaleTuiSessions({ port: PORT });
if (n || drained) { if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" });
logEvent("info", "tui_reaped_stale_sessions", { count: n, poolDrained: drained, trigger: "periodic" });
}
} catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); } } catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); }
finally {
// Refill in the background regardless of how the sweep went — a throw mid-sweep must not
// leave the pool permanently paused (it would silently degrade to the cold path forever).
if (tuiPool) { try { tuiPool.resume(); } catch { /* best effort */ } }
}
}, TUI_REAP_INTERVAL_MS) : null; }, TUI_REAP_INTERVAL_MS) : null;
if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref(); if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref();
@@ -1468,14 +1283,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli). // Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007). // SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
// `res` (optional, F2) is the client's http.ServerResponse — see closeSignalFor. // `res` (optional, F2) is the client's http.ServerResponse — see closeSignalFor.
// async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
// `streamCtx` (optional, OCP_TUI_STREAM): { emit(text), signal } — when present the turn is
// ALSO streamed live via claude's MessageDisplay hook. The contract is unchanged: this still
// returns the TRANSCRIPT's text (T), the honesty gates still run on T before anything is
// committed, and the cache still stores T — never the concatenated deltas. streamCtx.emit is
// the SSE sink; streamCtx.signal is the client's disconnect signal, which tears the pane down
// mid-turn instead of holding the semaphore slot for a dead socket.
async function callClaudeTui(model, messages, _conversationId, _keyName, res, streamCtx = null) {
const cliModel = MODEL_MAP[model] || model; const cliModel = MODEL_MAP[model] || model;
const prompt = messagesToPrompt(messages); // includes system as [System] inline const prompt = messagesToPrompt(messages); // includes system as [System] inline
recordModelRequest(cliModel, prompt.length); recordModelRequest(cliModel, prompt.length);
@@ -1503,23 +1311,6 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
// release() runs in a finally so any throw from runTuiTurn (tmux spawn failure, // release() runs in a finally so any throw from runTuiTurn (tmux spawn failure,
// paste-not-landed) OR from the honesty gates below (truncation / error banner) can NEVER // paste-not-landed) OR from the honesty gates below (truncation / error banner) can NEVER
// leak a slot. tuiSemaphore.inflight feeds /health. // leak a slot. tuiSemaphore.inflight feeds /health.
// Streaming assembler (null when OCP_TUI_STREAM is off — then runTuiTurn gets no onDelta,
// spawns no hook, and behaves byte-for-byte as before). It owns the auth-banner holdback
// and the message scoping; see lib/tui/stream.mjs.
const assembler = streamCtx ? new TuiDeltaAssembler({ holdbackChars: TUI_STREAM_HOLDBACK }) : null;
// F6: counted here — the moment a streamed turn is ATTEMPTED — not after the honesty gates
// below. A turn refused by the truncation or auth-banner gate is exactly the turn an operator
// most wants visible in streamTurns; counting only turns that reached the gates made
// streamDivergences/streamTurns silently exclude its own worst cases from the denominator.
if (assembler) tuiStats.streamTurns++;
const onDelta = assembler
? (payload) => {
const out = assembler.push(payload);
tuiStats.streamDeltas++; // every hook fire OBSERVED, not just forwarded ones — see the
// /health field doc in lib/tui/semaphore.mjs (F6)
if (out) streamCtx.emit(out); // released past the holdback — safe to show the client
}
: null;
try { try {
const { text, entrypoint, truncated } = await runTuiTurn({ const { text, entrypoint, truncated } = await runTuiTurn({
prompt, prompt,
@@ -1532,39 +1323,7 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
// different port never collides with this instance's reap/kill-server logic. // different port never collides with this instance's reap/kill-server logic.
wallclockMs: TUI_WALLCLOCK_MS, wallclockMs: TUI_WALLCLOCK_MS,
entrypointMode: TUI_ENTRYPOINT, entrypointMode: TUI_ENTRYPOINT,
// Warm pane pool (null unless OCP_TUI_POOL_SIZE > 0 → today's cold path exactly).
// A pooled pane is single-use: runTuiTurn kills it in its finally like any other.
pool: tuiPool,
// Only observe when the pool is ON — with it off (the default) no new log line is
// emitted, so the disabled path stays byte-for-byte today's, logs included.
onPane: tuiPool
? ({ warm }) => logEvent("info", warm ? "tui_pool_hit" : "tui_pool_miss",
{ model: cliModel, warmRemaining: tuiPool.warm })
: null,
onDelta,
// Gated on TUI_STREAM (the deployment-wide switch), NOT on `assembler` (this REQUEST's
// stream:true/false) — F4 fix. The pool's bootPane closure above installs the hook on
// every warm pane whenever TUI_STREAM is on, regardless of what any given future request
// asks for (a pre-booted pane cannot know that yet); the cold path must match, or a
// stream:false request gets --settings on a pool HIT and not on a pool MISS — two
// different spawn argvs for the identical request, which this project's alignment/billing
// posture cannot tolerate. Whether the hook's OUTPUT is actually consumed for THIS turn is
// decided downstream by `onDelta` (null when assembler is null), so a non-streaming
// request still never polls or emits — it just spawns identically either way.
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
abortSignal: streamCtx ? streamCtx.signal : null,
}); });
// ── Billing-pool observation (issue #115, #133) — A3 fix: record the entrypoint the moment
// runTuiTurn returns, BEFORE the honesty gates below that can throw. The entrypoint (cli vs
// sdk-cli) is which BILLING POOL the turn consumed; a turn that then fails a gate (wall-clock
// truncation, auth banner, stream divergence) STILL spent that pool — and those failed turns
// are exactly the ones most likely to signal a silent degrade to the metered Agent SDK pool.
// Recording only on the success path (the old placement) blinded /health's entrypointMismatches
// and lastEntrypoint to every failed turn. recordModelSuccess still runs later, only on success.
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
}
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back. // ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
// A throw here propagates to the catch below (recordModelError + reject), so the // A throw here propagates to the catch below (recordModelError + reject), so the
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path. // result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
@@ -1588,73 +1347,20 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer"); throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
} }
// ── Streaming safety net — the transcript is the authority, the deltas are the mirror.
// Runs AFTER the two gates above (so a truncated turn or an auth banner is never
// reconciled, let alone flushed) and BEFORE recordModelSuccess / the caller's cache
// write. Three outcomes:
// exact — concat(deltas) === T. The invariant held; emit whatever is still held
// back (a short answer never passes the holdback, so this is its whole text).
// top-up — what we emitted is a strict PREFIX of T but the deltas did not add up to
// it (a dropped/late fire). We serve exactly T by emitting the missing tail;
// the client still gets the right answer. Counted, and visible on /health.
// divergence— we already emitted bytes that are NOT a prefix of T. The client is holding
// text the transcript disagrees with and it cannot be retracted. REFUSE the
// turn: throw → SSE error frame, no cache, no success. Serving on would be
// exactly the "silently serve wrong text" failure this gate exists to stop.
// (Known trigger: a tool-using turn whose pre-tool prose exceeded the
// holdback — the transcript keeps only the LAST assistant message, so the
// prose we streamed is text T does not contain.)
if (assembler) {
// F7: a total hook failure (a claude version bump stops honoring --settings, or a
// truncated md-hook.sh per F3) produces zero fires for every turn, finalize() still
// reports ok:true/exact:false (the transcript alone carries the whole answer), and the
// turn succeeds NORMALLY — degrading to buffered with no error, no divergence, nothing
// but streamTopUps climbing (which the comment above calls "benign"). That is
// indistinguishable from one late fire dropped unless it is counted separately.
if (assembler.deltas === 0) {
tuiStats.streamZeroDeltaTurns++;
logEvent("warn", "tui_stream_zero_deltas", { model: cliModel });
}
const rec = assembler.finalize(text);
if (!rec.ok) {
tuiStats.streamDivergences++;
logEvent("error", "tui_stream_divergence", {
model: cliModel,
// The dominant cause in practice: a TOOL-USING turn whose pre-tool prose exceeded the
// holdback and was already streamed. The transcript keeps only the LAST assistant
// message, so that prose is text T does not contain. Remedy for such a deployment:
// raise OCP_TUI_STREAM_HOLDBACK above the model's typical narration length (later first
// chunk, but the prose stays held back and is then correctly discarded), or leave
// OCP_TUI_STREAM off. See README + ADR 0007 (2026-07-13 amendment).
reason: assembler.restartedAfterEmit ? "multi_message_after_emit (tool-use turn?)" : "delta_transcript_mismatch",
emittedChars: rec.emitted, transcriptChars: rec.transcript,
deltas: assembler.deltas, messages: assembler.messages,
});
throw new Error("tui_stream_divergence: streamed text is not a prefix of the transcript; refusing to serve it");
}
if (!rec.exact) {
tuiStats.streamTopUps++;
logEvent("warn", "tui_stream_topup", {
model: cliModel, emittedChars: rec.emitted, transcriptChars: rec.transcript, deltas: assembler.deltas,
});
}
if (rec.tail) streamCtx.emit(rec.tail);
}
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
// Entrypoint/billing-pool observation was already recorded above, right after runTuiTurn // Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
// returned — see the A3-fix comment there (it must cover failed turns too, so it cannot live // (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
// on this success-only path). // return text but cost money — warn loudly so it's visible. (issue #115)
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
// unconditionally (operators can poll it to confirm cli) and increments
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
// journald warning already covers — so a silent metered-pool drift is visible on /health
// without tailing logs.
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
}
return text; return text;
} catch (err) { } catch (err) {
// A mid-turn client disconnect (streaming path only — abortSignal) is NOT an upstream
// failure: runTuiTurn's finally already tore the pane down, and this finally releases the
// slot. Mirror the queued-disconnect handling above (info, no recordModelError, no
// response) rather than booking a phantom model error against the socket going away.
if (err && err.name === "TuiAbortError") {
logEvent("info", "tui_turn_aborted", { reason: "client_disconnected", model: cliModel });
throw new RequestDisconnectedError("client disconnected mid-turn; TUI pane torn down");
}
recordModelError(cliModel, false); recordModelError(cliModel, false);
throw err; throw err;
} finally { } finally {
@@ -1662,102 +1368,6 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
} }
} }
// ── TUI-mode REAL streaming (OCP_TUI_STREAM=1) ──────────────────────────
// The stream:true + TUI_MODE + OCP_TUI_STREAM=1 path. Emits the turn as it is generated,
// from claude's own MessageDisplay hook, instead of buffering it and replaying it with
// streamStringAsSSE.
//
// WIRE SHAPES: every frame below is COPIED from callClaudeStreaming (the -p path) — the role
// chunk, the content-delta chunk, the stop chunk, `[DONE]`, and the post-header
// {error:{message,type}} frame. No new fields, no new shapes. (ALIGNMENT.md Rule 2 / Class B:
// the authority for the wire format is the OpenAI chat/completions streaming spec, adopted by
// ADR 0006; the authority for the TUI spawn is ADR 0007. No cli.js citation applies — see the
// commit body.)
//
// HEADERS ARE SENT EAGERLY, exactly as the -p path does, so the existing heartbeat
// (CLAUDE_HEARTBEAT_INTERVAL) covers the ~6s of silence before the first delta. The cost is
// the same one the -p path already pays: after the headers are out, an upstream failure can
// no longer be a JSON 500, so it is surfaced as the SSE error frame instead (issue #110).
async function callClaudeTuiStreaming(model, messages, conversationId, res, authInfo = {}) {
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
const t0 = Date.now();
const promptChars = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
let headersSent = false;
function ensureHeaders() {
if (res.writableEnded || res.destroyed) return false;
if (headersSent) return true;
headersSent = true;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
});
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
return true;
}
ensureHeaders();
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, conversationId);
// Held for the WHOLE turn (not just the queue wait): a disconnect must abort the transcript
// wait so runTuiTurn tears the pane down and callClaudeTui's finally frees the slot.
const { signal, detach } = closeSignalFor(res);
const streamCtx = {
signal,
emit(text) {
if (!text) return;
if (!ensureHeaders()) return; // client vanished — drop the write, the turn still unwinds
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
}, hb);
},
};
try {
// callClaudeTui returns the TRANSCRIPT text T after its honesty gates + the streaming
// reconciliation. Everything the client should see has been emitted by then.
const content = await callClaudeTui(model, messages, conversationId, authInfo.keyName, res, streamCtx);
// Cache T — never the concatenated deltas (mirrors the buffered TUI path).
if (CACHE_TTL > 0 && authInfo.cacheHash) {
try { setCachedResponse(authInfo.cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
if (!res.writableEnded && !res.destroyed) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}, hb);
res.write("data: [DONE]\n\n");
res.end();
}
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
} catch (err) {
// Client walked away (queued OR mid-turn): nothing to write to, nothing to record —
// same quiet outcome as every other disconnect path (L1 / F2).
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
console.error(`[proxy] error: ${err.message}`);
// Headers are already out (eager, above), so — exactly like the -p path — the failure is
// surfaced as an SSE error frame, NOT a success-looking finish_reason:"stop". This is what
// keeps a truncated turn, an auth banner, or a stream divergence from being served as an
// answer: the client sees an error, and nothing was cached.
if (!res.writableEnded && !res.destroyed) {
sendSSE(res, { error: { message: sanitizeError(err.message), type: "provider_error" } }, hb);
res.write("data: [DONE]\n\n");
res.end();
}
} finally {
hb.stop();
detach();
}
}
// ── SSE heartbeat (opt-in idle watchdog) ──────────────────────────────── // ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
// Emits `: keepalive\n\n` SSE comment frames during silent windows on the // Emits `: keepalive\n\n` SSE comment frames during silent windows on the
// streaming response. Design: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md // streaming response. Design: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md
@@ -2650,11 +2260,6 @@ async function handleChatCompletions(req, res) {
} }
if (stream) { if (stream) {
if (TUI_MODE && TUI_STREAM) {
// TUI-mode REAL streaming (opt-in): emit delta.content chunks as claude renders them,
// via its MessageDisplay hook. The transcript remains authoritative (gates + cache).
return callClaudeTuiStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
}
if (TUI_MODE) { if (TUI_MODE) {
// TUI-mode: no real token stream — buffer the full turn via callClaudeTui, // TUI-mode: no real token stream — buffer the full turn via callClaudeTui,
// optionally write-back to cache, then replay as chunked SSE. // optionally write-back to cache, then replay as chunked SSE.
@@ -2953,17 +2558,9 @@ const server = createServer(async (req, res) => {
// still appears with enabled:false (cheap, harmless) so the shape is stable. // still appears with enabled:false (cheap, harmless) so the shape is stable.
// entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a // entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a
// silent metered-pool drift (the audit's top risk after the 6/15 billing flip). // silent metered-pool drift (the audit's top risk after the 6/15 billing flip).
// `pool` is a NEW nested field inside the (already additive) tui block: null when the
// warm pool is off (the default), so the disabled shape is unchanged apart from one
// explicit null. Lets the operator confirm hit rate + standing process cost.
//
// streamEnabled + the stream* counters are likewise ADDITIVE (new fields only, same
// grandfathered B.2 rationale — ADR 0006). streamDivergences is the one an operator
// must watch: a non-zero value means a streamed turn was REFUSED because the deltas
// disagreed with the transcript, which is the streaming path's only correctness risk.
tui: buildTuiHealthBlock( tui: buildTuiHealthBlock(
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT, streamEnabled: TUI_MODE && TUI_STREAM }, { enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
tuiStats, tuiSemaphore, tuiPool, tuiStats, tuiSemaphore,
), ),
}); });
} }
@@ -3200,27 +2797,6 @@ function gracefulShutdown(signal) {
if (tuiReapInterval) clearInterval(tuiReapInterval); if (tuiReapInterval) clearInterval(tuiReapInterval);
closeDb(); closeDb();
// 2b. Drain the warm pane pool. A pooled `claude` is a child of the tmux SERVER, not of
// this node process, so it is NOT in activeProcesses and step 3 below cannot reach it —
// without this explicit drain every warm pane would outlive OCP as an orphan (and the
// pool's in-memory registry dies with the process, so nothing would remember it owned them).
//
// drain() kills the pane that is currently BOOTING too, and it does so SYNCHRONOUSLY. That
// is required, not incidental: step 4 below calls process.exit(0) in THIS SAME TICK whenever
// activeProcesses is empty — which on a TUI host it always is — so any cleanup a boot
// deferred to a .then()/.catch() would simply never run. (That was a real bug: the pool used
// to track in-flight boots as a count, could not name the booting session, and orphaned a
// live authenticated `claude` on every shutdown that landed mid-boot.)
//
// Orphans that survive anyway (SIGKILL, power loss) are still caught by the next instance's
// boot reap — this makes the graceful path clean, it is not the only safety net.
if (tuiPool) {
try {
const drained = tuiPool.drain();
if (drained) logEvent("info", "tui_pool_drained", { count: drained, trigger: "shutdown" });
} catch (e) { logEvent("error", "tui_pool_drain_failed", { error: e.message }); }
}
// 3. Kill all active child processes // 3. Kill all active child processes
for (const proc of activeProcesses) { for (const proc of activeProcesses) {
try { proc.kill("SIGTERM"); } catch {} try { proc.kill("SIGTERM"); } catch {}
@@ -3290,19 +2866,11 @@ server.listen(PORT, BIND_ADDRESS, () => {
? (TUI_HOME === process.env.HOME ? "env-token (real home — unset OCP_TUI_HOME for credential isolation)" : "env-token (credential-isolated home — no credentials.json)") ? (TUI_HOME === process.env.HOME ? "env-token (real home — unset OCP_TUI_HOME for credential isolation)" : "env-token (credential-isolated home — no credentials.json)")
: "credentials.json (no CLAUDE_CODE_OAUTH_TOKEN — see Troubleshooting #401)"; : "credentials.json (no CLAUDE_CODE_OAUTH_TOKEN — see Troubleshooting #401)";
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} auth=${tuiAuth} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`); console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} auth=${tuiAuth} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
console.log(TUI_POOL_SIZE > 0
? ` TUI warm pool: ON size=${TUI_POOL_SIZE}${TUI_POOL_SIZE} idle \`claude\` process(es) held warm; first request per model is still a cold MISS`
: ` TUI warm pool: OFF (set OCP_TUI_POOL_SIZE=1..${POOL_MAX_SIZE} to pre-boot panes and cut ~3-4s per request)`);
try { try {
// F7 fix: scope to THIS instance's own port (see reapStaleTuiSessions). includeLegacy: // F7 fix: scope to THIS instance's own port (see reapStaleTuiSessions). includeLegacy:
// true ONLY here — the one-time boot reap is the designated point to claim orphaned // true ONLY here — the one-time boot reap is the designated point to claim orphaned
// bare-prefix ("ocp-tui-<uuid8>") zombie sessions left by a PRE-fix process generation // bare-prefix ("ocp-tui-<uuid8>") zombie sessions left by a PRE-fix process generation
// of this same instance (no live post-fix instance ever creates that shape again). // of this same instance (no live post-fix instance ever creates that shape again).
// No `spare`: the warm pool is EMPTY at boot (there is no boot-time pre-warm — the pool
// learns its model from the first request), so this reap has no live pane to protect and
// it is exactly what SHOULD claim any ocp-tui-<port>-p* pool orphans left by a previous
// process generation of this instance (POOL/REAPER INVARIANT property 2). If a future
// change ever pre-warms at boot, this call MUST start passing tuiPool.liveNames().
const n = reapStaleTuiSessions({ port: PORT, includeLegacy: true }); const n = reapStaleTuiSessions({ port: PORT, includeLegacy: true });
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
} catch {} } catch {}
+1 -3
View File
@@ -390,9 +390,7 @@ if (!DRY_RUN) {
// and "ocp-proxy" keeps the proxy invisible to that heuristic. // and "ocp-proxy" keeps the proxy invisible to that heuristic.
const OCP_HOME = join(HOME, ".ocp"); const OCP_HOME = join(HOME, ".ocp");
const ocpLogsDir = join(OCP_HOME, "logs"); const ocpLogsDir = join(OCP_HOME, "logs");
// mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
// without an explicit mode that parent lands at the umask default (world-listable 0755).
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 });
// Uninstall legacy service names if present (upgrade path) // Uninstall legacy service names if present (upgrade path)
if (platform === "darwin") { if (platform === "darwin") {
-26
View File
@@ -1,26 +0,0 @@
// Imported FIRST by test-features.mjs, before keys.mjs, so this runs before anything can open
// the key store. ESM hoists imports and evaluates them in order, so a `process.env.X = ...`
// statement in the test's own body would run too late — hence a separate module.
//
// Why this exists: `npm test` used to write real, UNREVOKED api_keys rows into the operator's
// live ~/.ocp/ocp.db (the same database the running server reads) — two per run, unbounded.
// It also made the suite racy: two concurrent runs (e.g. review worktrees) shared one file, so
// `listKeys()` could miss "test-user-1" and the `in` check would throw on undefined.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export const TEST_OCP_DIR = mkdtempSync(join(tmpdir(), "ocp-test-"));
// BOTH are required. keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so neither
// var alone redirects anything — a stray OCP_DIR_OVERRIDE in a production env is inert without
// NODE_ENV=test alongside it. (A daemon OCP launches never carries either: the service units and
// the `ocp` restart fallback strip both — see plist-merge NEVER_PRESERVE / keys.mjs's comment.)
process.env.NODE_ENV = "test";
process.env.OCP_DIR_OVERRIDE = TEST_OCP_DIR;
// Remove the scratch store on exit. Without this the fix would trade unbounded growth in
// ~/.ocp/ocp.db for unbounded growth in $TMPDIR — better, but still litter.
process.on("exit", () => {
try { rmSync(TEST_OCP_DIR, { recursive: true, force: true }); } catch { /* best effort */ }
});
+13 -1079
View File
File diff suppressed because it is too large Load Diff