mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3355d415 | ||
|
|
27216646c8 | ||
|
|
d501e786b8 | ||
|
|
b7463a63f5 | ||
|
|
eeec2bf83d | ||
|
|
63c2de7128 | ||
|
|
1d65bc309e | ||
|
|
88d8bed2e3 | ||
|
|
a90f830b5d | ||
|
|
1b324968f4 | ||
|
|
9f5bc3264a |
@@ -1,5 +1,32 @@
|
||||
# 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
|
||||
|
||||
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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **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.
|
||||
- **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**.
|
||||
- **`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))
|
||||
- **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 |
|
||||
| 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 share one Claude Pro/Max subscription across IDEs, devices, and people, 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 reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
**LAN mode** — share with other devices on your network:
|
||||
**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):
|
||||
```bash
|
||||
# Enable LAN access with per-user auth (recommended)
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
@@ -716,6 +716,7 @@ 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-7` | Previous 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-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||
|
||||
@@ -959,6 +960,10 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
|
||||
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
|
||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||
| `OCP_TUI_EFFORT` | `low` | (TUI-mode) Effort level passed to the interactive `claude` as an explicit `--effort` flag: `low` (default), `medium`, `high`, `xhigh`, `max`, or `inherit` to omit the flag (the pre-flag behaviour: the pane inherits a HOME-dependent effort — the operator's `~/.claude/settings.json` `effortLevel` in real-home mode, claude's built-in default in env-token scratch mode). Explicit `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh` (see `docs/plans/2026-07-13-tui-latency/`); proxied requests rarely benefit from extended thinking. Banner-verified to stay on the subscription pool (`· Claude Max`). An invalid value logs a warning and falls back to `low`. |
|
||||
| `OCP_TUI_STREAM` | `0` (off) | (TUI-mode) When `=1`, `stream:true` requests emit **real SSE `delta.content` chunks as `claude` generates them**, instead of buffering the turn and replaying it. Deltas come from `claude`'s own `MessageDisplay` hook (registered with `--settings` on the ordinary interactive spawn — banner-verified to stay on the subscription pool, `· Claude Max`). Granularity is **block-level**, not token-level. The transcript remains authoritative: the streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and only the transcript text is cached. A turn whose stream cannot be reconciled with the transcript is **refused** (SSE error frame, not cached) and counted as `tui.streamDivergences` on `/health`. A total hook failure (e.g. `--settings` stops registering it after a `claude` version bump) is a *different, silent* failure mode — every streamed turn still succeeds, fully buffered, with no divergence and no error — so it is counted separately as `tui.streamZeroDeltaTurns` (streamed turns where the hook fired **zero** times) and logged as `tui_stream_zero_deltas`; watch it alongside `streamDivergences`. Default off — the buffered path is unchanged and remains the stable default. ⚠️ **Tool-using turns:** the transcript keeps only the model's **last** assistant message, so if the model narrates before calling a tool ("I'll check that file…") and that narration exceeds `OCP_TUI_STREAM_HOLDBACK`, it has already been streamed and cannot be retracted — the turn is then **refused** rather than served (measured live: Opus narrated 475 chars before a `Bash` call). If your deployment lets the model use tools (the TUI default, and anything with `OCP_TUI_FULL_TOOLS=1`), either raise `OCP_TUI_STREAM_HOLDBACK` above the typical narration length — the narration then stays held back and is correctly discarded, at the cost of a later first chunk — or leave streaming off. Streaming is best suited to tool-light chat proxying. See ADR 0007 (2026-07-13 amendment). |
|
||||
| `OCP_TUI_STREAM_HOLDBACK` | `100` | (TUI-mode, streaming) Characters withheld before the first chunk reaches the client. Two jobs. (1) It keeps the **auth-banner gate** alive under streaming, via a guarantee with two required halves: (i) nothing is emitted for a message until its trimmed accumulation exceeds 100 chars — past the default banner detector's reach, since real banners are ≤100 chars — and (ii) once a message boundary follows an emit, nothing further is ever emitted for the rest of the turn, and the turn is refused outright. Half (i) alone only covers a turn's first message; half (ii) is what covers an error banner rendered as a *later* message (e.g. after tool-using prose). Raise the holdback if you replace the detector via `CLAUDE_TUI_ERROR_PATTERNS` with patterns that can match longer messages — that only affects half (i); OCP warns at boot if you do. (2) It is the knob for **tool-using turns** — see the `OCP_TUI_STREAM` caveat below. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does. |
|
||||
| `OCP_TUI_STREAM_DIR` | `$HOME/.ocp-tui/stream` | (TUI-mode, streaming) Directory holding the static `MessageDisplay` hook script + settings file, and the per-session delta sink (`<session-id>.jsonl`, removed at turn teardown). One sink **per session-id** — this is what keeps concurrent TUI turns (`OCP_TUI_MAX_CONCURRENT` ≥ 2) from interleaving one client's deltas into another's stream. |
|
||||
| `OCP_TUI_STREAM_POLL_MS` | `100` | (TUI-mode, streaming) Interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing. |
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
||||
| `OCP_TUI_POOL_SIZE` | `0` (off) | (TUI-mode) Number of **pre-booted warm `claude` panes** kept ready, so a request does not pay the cold boot. `0` disables the pool entirely — the request path is then exactly the cold-boot path. Max `4`; an unparseable value disables it rather than guessing. **Measured on a Mac mini (Sonnet 4.6, `--effort low`): end-to-end p50 `10.17s` (n=6, pool off) → `6.00s` (n=12 warm hits) — −4.2 s / −41%** — the pool recovers both the ~1.2 s boot *and* ~2.9 s of post-input-bar init that a pane which has been idle a moment has already finished. **Cost:** each warm pane is a *live idle `claude` process* held whether or not a request ever arrives (peak processes ≈ pool size + `OCP_TUI_MAX_CONCURRENT` + 1 booting replacement) — which is why it is opt-in. Panes are **single-use**: one turn, then killed and replaced in the background. The **first request after start (and after any model switch) is always a cold miss** — the pool warms the most recently requested model, since OCP cannot know which model the next caller wants. See `docs/plans/2026-07-13-tui-latency/`. |
|
||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||
@@ -1034,7 +1039,7 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
|
||||
### What changes / what doesn't
|
||||
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **No real token streaming *today* — but it is achievable, and planned.** TUI-mode currently buffers the full response then replays it as chunked SSE: you see a delay, then the complete response. This is a limitation of the current implementation, **not** of the path — `claude` fires a `MessageDisplay` hook carrying incremental, byte-faithful `delta`s of the raw reply (they concatenate exactly to the final text, and stay prefix-stable), on the subscription pool, without `-p`. Wiring it into OCP's SSE is tracked as backlog item #2. What is *not* available is token-by-token granularity (the hook fires once per rendered block — roughly one per paragraph, list item, or code block, so the count scales with answer length) — which is plenty for SSE. Evidence: [`docs/plans/2026-07-13-tui-latency/streaming-spike.md`](docs/plans/2026-07-13-tui-latency/streaming-spike.md).
|
||||
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`docs/plans/2026-07-13-tui-latency/streaming-spike.md`](docs/plans/2026-07-13-tui-latency/streaming-spike.md).
|
||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K 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.
|
||||
|
||||
@@ -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.
|
||||
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`**.
|
||||
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
|
||||
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.**
|
||||
|
||||
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||||
|
||||
@@ -333,6 +333,61 @@ 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 6–10 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
|
||||
|
||||
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 S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|
||||
|
||||
@@ -6,26 +6,74 @@ import { join } from "node:path";
|
||||
import { mkdirSync, chmodSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const OCP_DIR = join(homedir(), ".ocp");
|
||||
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
||||
// Tighten the directory mode in case it already existed with broader permissions.
|
||||
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||
// Resolved LAZILY, on first getDb() — not at module top-level. Two reasons, and the second is
|
||||
// the bug this fixes:
|
||||
//
|
||||
// 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.
|
||||
try { chmodSync(dir, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||
return dir;
|
||||
}
|
||||
|
||||
let db;
|
||||
let dbPath; // resolved on first open, alongside the db handle
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
db = new DatabaseSync(DB_PATH);
|
||||
dbPath = join(resolveOcpDir(), "ocp.db");
|
||||
// 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 foreign_keys = ON");
|
||||
initSchema();
|
||||
// Tighten mode on the DB file (0600) after creation / first open.
|
||||
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||
try { chmodSync(dbPath, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||
}
|
||||
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() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
@@ -426,5 +474,5 @@ export function findKey(idOrName) {
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (db) { db.close(); db = null; }
|
||||
if (db) { db.close(); db = null; dbPath = undefined; } // clear both — a path to a closed db is a footgun
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
// TUI warm pane pool (docs/plans/2026-07-13-tui-latency backlog #3).
|
||||
//
|
||||
// WHAT IT IS: a small set of PRE-BOOTED `claude` panes, each already sitting at its
|
||||
@@ -304,6 +306,16 @@ export class TuiPanePool {
|
||||
_drop(pane, reason) {
|
||||
this.dropped++;
|
||||
try { this._killPane(pane.name); } catch { /* already gone */ }
|
||||
// F5: every drop path (expired / unhealthy / model_switch / drain / cancelled_late /
|
||||
// stale_boot) ends up here, and the reap tick drains the WHOLE pool on every tick — so
|
||||
// without this, every warm pane's sink orphans in streamDir with no GC path (killPane only
|
||||
// reaches the tmux session, never the pane's OWN files). Best-effort: pane.streamFile is
|
||||
// undefined for a still-booting identity (the sink path is only known once bootPane
|
||||
// resolves) and rmSync(force:true) is already a no-op on a missing file, so this never
|
||||
// throws into the reaper regardless of which drop path got here.
|
||||
if (pane.streamFile) {
|
||||
try { rmSync(pane.streamFile, { force: true }); } catch { /* best-effort GC */ }
|
||||
}
|
||||
this._log("info", "tui_pool_pane_dropped", { name: pane.name, reason });
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -144,7 +144,35 @@ export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||
// when the pool is off (the default). Reported as `pool: null` when off so the block's
|
||||
// shape stays stable, and as the pool's stats (size / warm / hits / misses / …) when on —
|
||||
// the operator's window onto both the hit rate and the standing idle-process cost.
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore, pool = null) {
|
||||
//
|
||||
// Streaming fields (backlog #2, OCP_TUI_STREAM) are ADDITIVE too:
|
||||
// streamEnabled — is real (MessageDisplay-hook) SSE streaming on for TUI turns?
|
||||
// streamTurns — streamed turns ATTEMPTED, counted before the truncation/auth-banner
|
||||
// gates run (F6) — so a turn REFUSED by those gates still shows up
|
||||
// here, which is exactly the turn an operator most wants visible.
|
||||
// Counting only turns that survived the gates would silently exclude
|
||||
// a turn's worst-case outcome from its own denominator.
|
||||
// streamDeltas — MessageDisplay hook fires OBSERVED, including held-back ones (F6) —
|
||||
// NOT only the ones forwarded to a client. This is what makes
|
||||
// streamZeroDeltaTurns meaningful: a turn can have streamDeltas
|
||||
// incrementing while still emitting nothing to the client (fully held
|
||||
// back, e.g. a short answer), which is healthy, vs. a hook that fired
|
||||
// zero times at all, which is not (see streamZeroDeltaTurns).
|
||||
// streamTopUps — turns where the delta stream was a safe PREFIX of the transcript but
|
||||
// not equal to it; OCP topped up from the transcript and served T.
|
||||
// Benign but worth watching — a persistent rate means the hook is
|
||||
// losing fires.
|
||||
// streamDivergences — turns REFUSED because emitted bytes were not a prefix of the
|
||||
// transcript. THE field to alert on for CORRECTNESS: it means the hook
|
||||
// and the transcript disagreed and OCP chose to fail rather than serve
|
||||
// unverifiable text.
|
||||
// streamZeroDeltaTurns — streamed turns where the hook fired ZERO times (F7). THE field to
|
||||
// alert on for AVAILABILITY: streamTopUps climbing is one fire dropped
|
||||
// here and there (benign); this climbing means the hook is not firing
|
||||
// AT ALL — e.g. `--settings` silently stopped registering it (a claude
|
||||
// version bump), or F3's truncated-script failure mode — and every
|
||||
// streamed turn is quietly degrading to fully-buffered with no error.
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent, streamEnabled = false }, tuiStats, semaphore, pool = null) {
|
||||
return {
|
||||
enabled,
|
||||
entrypointMode, // cli | auto | off
|
||||
@@ -154,5 +182,11 @@ export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent },
|
||||
queued: semaphore.queued, // turns waiting for a slot
|
||||
maxConcurrent,
|
||||
pool: pool ? pool.stats() : null, // warm pane pool, or null when disabled
|
||||
streamEnabled,
|
||||
streamTurns: tuiStats.streamTurns ?? 0,
|
||||
streamDeltas: tuiStats.streamDeltas ?? 0,
|
||||
streamTopUps: tuiStats.streamTopUps ?? 0,
|
||||
streamDivergences: tuiStats.streamDivergences ?? 0,
|
||||
streamZeroDeltaTurns: tuiStats.streamZeroDeltaTurns ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
+135
-9
@@ -14,6 +14,7 @@ import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readTuiTranscript } from "./transcript.mjs";
|
||||
import { prepareStreamHook, streamFilePath, parseDeltaChunk } from "./stream.mjs";
|
||||
|
||||
// F7 fix (audit finding, LOW): the prefix used to be a bare, host-wide constant
|
||||
// ("ocp-tui-"), so a SECOND OCP instance on the same host (e.g. a temporary
|
||||
@@ -167,6 +168,10 @@ const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
||||
export const POOL_BOOT_MS = BOOT_MS * 5;
|
||||
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||
// Hook-sink drain interval when streaming. 100ms: the hook fires at BLOCK granularity
|
||||
// (~5-7 fires per answer, seconds apart), so a finer poll buys nothing and a coarser one
|
||||
// would add visible lag to the first delta. Cheap — one readFileSync of a small file.
|
||||
const STREAM_POLL_MS = parseInt(process.env.OCP_TUI_STREAM_POLL_MS || "100", 10);
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -348,7 +353,25 @@ export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false }
|
||||
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
|
||||
//
|
||||
// `stream` (optional, OCP_TUI_STREAM): { file, settings } — when present, the pane gets
|
||||
// (a) OCP_TUI_STREAM_FILE in its env — read by the static MessageDisplay hook script to
|
||||
// decide WHERE to append this pane's deltas. Delivered as env (not baked into the
|
||||
// settings file) so the settings file stays STATIC and a pre-booted warm pane works.
|
||||
// Verified live: a claude hook inherits the pane's environment.
|
||||
// (b) --settings <file> — registers the MessageDisplay hook.
|
||||
// VERIFIED LIVE (claude 2.1.207, this host) before shipping, because both were spawn-level
|
||||
// risks:
|
||||
// - the startup banner is UNCHANGED with --settings: "Sonnet 4.6 with low effort ·
|
||||
// Claude Max" (subscription pool). --settings is NOT a --bare-class flag — it does not
|
||||
// silently drop the subscription pool. Transcript entrypoint stayed "cli".
|
||||
// - --settings MERGES into the settings hierarchy, it does NOT clobber <HOME>/.claude/
|
||||
// settings.json: with --settings passed, the user-level settings.json's `env` block was
|
||||
// still applied to the hook's environment. So the isolated-HOME settings story the TUI
|
||||
// already relies on (permissions / additionalDirectories — see prepareTuiHome and the
|
||||
// OCP_TUI_FULL_TOOLS note above) survives intact.
|
||||
// When absent, the argv is byte-for-byte the pre-streaming argv.
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode, stream = null) {
|
||||
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
|
||||
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
|
||||
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
|
||||
@@ -392,6 +415,8 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
|
||||
}
|
||||
// Streaming sink: the pane's own per-session delta file (see the `stream` note above).
|
||||
if (stream && stream.file) sets.push(`OCP_TUI_STREAM_FILE=${shq(stream.file)}`);
|
||||
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||
@@ -448,6 +473,10 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
||||
effortArgs = ["--effort", "low"];
|
||||
}
|
||||
|
||||
// --settings registers the MessageDisplay hook. Omitted entirely when streaming is off,
|
||||
// so the OFF argv is byte-for-byte the pre-streaming argv.
|
||||
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
|
||||
|
||||
return [
|
||||
envPrefix,
|
||||
shq(claudeBin),
|
||||
@@ -455,6 +484,7 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
||||
"--session-id", sessionId,
|
||||
...toolArgs,
|
||||
...effortArgs,
|
||||
...settingsArgs,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -504,9 +534,16 @@ export function poolPaneName(port, sessionId) {
|
||||
// readiness wait returns, so a pool that only learned the name on resolve could neither spare
|
||||
// the session from the reaper nor kill it on shutdown. Supplying BOTH also keeps the name's
|
||||
// hex suffix equal to the session-id's, so `tmux ls` correlates to the transcript file.
|
||||
// `streamDir` (optional, OCP_TUI_STREAM): install claude's MessageDisplay hook on this pane.
|
||||
// Done HERE, at boot — not at turn time — and that is the whole reason streaming survives the
|
||||
// WARM POOL: the hook script + settings file are STATIC (one pair per streamDir), and the only
|
||||
// per-turn thing, the sink path, is derived from the pane's own --session-id, which is fixed
|
||||
// right here. So a pre-booted pane already carries its hook and its own sink and streams exactly
|
||||
// like a cold-booted one; nothing request-specific is ever baked into the spawn.
|
||||
export async function bootTuiPane({
|
||||
model, claudeBin, home, realHome, cwd, port, entrypointMode = "cli",
|
||||
tmux = defaultTmux, sessionId = null, name = null, requireReady = false, bootMs = BOOT_MS,
|
||||
streamDir = null,
|
||||
}) {
|
||||
const sid = sessionId || randomUUID();
|
||||
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
|
||||
@@ -528,6 +565,15 @@ export async function bootTuiPane({
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||
|
||||
// Streaming sink for THIS pane (see the streamDir note above). rmSync first so a
|
||||
// re-used session-id can never replay a previous turn's deltas.
|
||||
let streamFile = null, streamSettings = null;
|
||||
if (streamDir) {
|
||||
streamFile = streamFilePath(streamDir, sid);
|
||||
streamSettings = prepareStreamHook(streamDir);
|
||||
try { rmSync(streamFile, { force: true }); } catch { /* start from a fresh sink */ }
|
||||
}
|
||||
|
||||
// Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
|
||||
// from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
|
||||
// spawning process's env to the pane, so the {env} here is intentionally minimal.
|
||||
@@ -540,7 +586,8 @@ export async function bootTuiPane({
|
||||
// session or issue a billing request without a verified interactive context.
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode)],
|
||||
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode,
|
||||
streamFile ? { file: streamFile, settings: streamSettings } : null)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
@@ -559,7 +606,7 @@ export async function bootTuiPane({
|
||||
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
return { name: tmuxName, sessionId: sid, model, ehome, bootedAt: Date.now() };
|
||||
return { name: tmuxName, sessionId: sid, model, ehome, streamFile, bootedAt: Date.now() };
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
@@ -582,6 +629,33 @@ export async function bootTuiPane({
|
||||
// pool refill so the next request finds a warm pane.
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
//
|
||||
// STREAMING (OCP_TUI_STREAM, default off). Pass `onDelta` and `streamDir`, and the pane's
|
||||
// MessageDisplay hook (installed by bootTuiPane; see lib/tui/stream.mjs) appends each raw
|
||||
// delta payload to the pane's own sink. This driver polls that sink and invokes onDelta(payload)
|
||||
// per fire while the turn is still generating. A WARM pane already carries its sink from boot
|
||||
// (pane.streamFile), so the pooled and cold paths stream identically.
|
||||
//
|
||||
// `streamDir` IS PASSED TO THE COLD BOOT UNCONDITIONALLY (not gated on `onDelta`) — F4 fix. The
|
||||
// spawn argv is this project's billing-classification surface: a caller with OCP_TUI_STREAM on
|
||||
// but THIS particular request non-streaming (stream:false) must still get the SAME argv whether
|
||||
// it lands on a pool HIT or a cold-boot MISS, because a pre-booted pool pane cannot know in
|
||||
// advance whether the request it will eventually serve wants streaming — it installs the hook
|
||||
// unconditionally whenever the pool is warming at all (see server.mjs's bootPane closure). Gating
|
||||
// the cold boot's hook install on `onDelta` made a stream:false request's argv depend on whether
|
||||
// it happened to hit the pool or miss it — the exact drift this surface cannot tolerate. Whether
|
||||
// the hook is actually POLLED is a separate, correctly-scoped decision: see `streaming` below,
|
||||
// gated on onDelta && streamFile, so a non-streaming turn never reads its own sink even though
|
||||
// the hook is running.
|
||||
//
|
||||
// The transcript stays AUTHORITATIVE regardless: it is still the terminal-turn signal, still the
|
||||
// source of the returned `text`, and still the input to the caller's honesty gates. The delta
|
||||
// stream is a low-latency MIRROR of it, never a replacement, and the caller asserts the two
|
||||
// agree. With onDelta AND streamDir both omitted, nothing here changes: no poll, no hook.
|
||||
//
|
||||
// `abortSignal` (optional): aborts the transcript wait, so a client that disconnects mid-turn
|
||||
// tears the pane down NOW (the finally below) instead of holding the pane — and therefore the
|
||||
// caller's semaphore slot — until the turn or the wallclock cap ends.
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
@@ -595,6 +669,10 @@ export async function runTuiTurn({
|
||||
tmux = defaultTmux,
|
||||
pool = null, // TuiPanePool | null — null (default) === today's cold-boot-only path
|
||||
onPane = null, // optional observer: ({ warm }) => void, for logging/metrics
|
||||
onDelta = null, // (payload) => void — invoked per MessageDisplay hook fire, mid-turn
|
||||
streamDir = null, // hook sink dir, passed to the COLD boot UNCONDITIONALLY (F4 — see above);
|
||||
// a warm pane brings its own, fixed at its own boot
|
||||
abortSignal = null,
|
||||
}) {
|
||||
// 1. Warm pane, or cold boot. A MISS is never an error — it is exactly today's path.
|
||||
let pane = pool ? pool.acquire(model) : null;
|
||||
@@ -607,12 +685,36 @@ export async function runTuiTurn({
|
||||
if (pool) pool.refill();
|
||||
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
|
||||
if (!pane) {
|
||||
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux });
|
||||
// streamDir passed AS-IS (not gated on onDelta) — F4: see the STREAMING comment above.
|
||||
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux,
|
||||
streamDir });
|
||||
}
|
||||
const tmuxName = pane.name;
|
||||
const sessionId = pane.sessionId; // THIS pane's own session-id — one session, one turn
|
||||
const ehome = pane.ehome || home || process.env.HOME;
|
||||
|
||||
// Streaming state is read off the PANE, not recomputed here — a warm pane fixed its sink at
|
||||
// boot, and a cold one just did the same above. If the pool was booted WITHOUT a streamDir
|
||||
// while onDelta is set, streamFile is null and the turn degrades to buffered: correct, just
|
||||
// not fast. (server.mjs wires the same streamDir into both paths so that cannot happen.)
|
||||
const streamFile = pane.streamFile || null;
|
||||
const streaming = !!(onDelta && streamFile);
|
||||
const streamCursor = { consumed: 0 };
|
||||
let streamStopped = false;
|
||||
let pollTimer = null;
|
||||
// Drain every complete line appended since the last drain. Never throws into the turn: a
|
||||
// malformed line is skipped by parseDeltaChunk, and an onDelta that throws is contained.
|
||||
const drainDeltas = () => {
|
||||
if (!streaming) return;
|
||||
let text;
|
||||
try { text = readFileSync(streamFile, "utf8"); } catch { return; } // absent until the first fire
|
||||
const { deltas, consumed } = parseDeltaChunk(text, streamCursor.consumed);
|
||||
streamCursor.consumed = consumed;
|
||||
for (const d of deltas) {
|
||||
try { onDelta(d); } catch { /* a sink error must never abort the turn */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
const promptFile = `${tmpDir}/prompt.txt`;
|
||||
@@ -644,13 +746,37 @@ export async function runTuiTurn({
|
||||
// Submit (separate Enter key event).
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 5. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
// 5a. Streaming only: start polling the hook sink. Runs CONCURRENTLY with the
|
||||
// transcript wait below — the deltas are what make the answer visible while the
|
||||
// turn is still generating; the transcript is what makes it authoritative.
|
||||
if (streaming) {
|
||||
const loop = () => {
|
||||
if (streamStopped) return;
|
||||
drainDeltas();
|
||||
pollTimer = setTimeout(loop, STREAM_POLL_MS);
|
||||
};
|
||||
pollTimer = setTimeout(loop, STREAM_POLL_MS);
|
||||
}
|
||||
|
||||
// 5b. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
|
||||
// Returns { text, entrypoint, truncated } from readTuiTranscript.
|
||||
const result = await readTuiTranscript({ home: ehome, sessionId, wallclockMs, abortSignal });
|
||||
|
||||
// 5c. FINAL drain. The terminal marker can land between two poll ticks, so the last
|
||||
// delta(s) may still be unread — without this the tail would be missing from the
|
||||
// stream and every turn would need a transcript top-up.
|
||||
streamStopped = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
drainDeltas();
|
||||
return result;
|
||||
} finally {
|
||||
// 6. Teardown — always, even on throw. A pooled pane is torn down here exactly like a
|
||||
// cold-booted one: SINGLE-USE, never returned to the pool (see pool.mjs).
|
||||
// 6. Teardown — always, even on throw (including an abortSignal disconnect, which is
|
||||
// exactly why the pane cannot outlive a client that walked away). A pooled pane is
|
||||
// torn down here exactly like a cold-booted one: SINGLE-USE, never returned (pool.mjs).
|
||||
streamStopped = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
if (streamFile) { try { rmSync(streamFile, { force: true }); } catch { /* best effort */ } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -267,11 +267,21 @@ export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TU
|
||||
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
||||
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
||||
// file does not exist until the turn starts, so resolution happens inside the loop.
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||
// `abortSignal` (optional): when it fires, stop waiting and throw TuiAbortError. The one
|
||||
// caller that passes it is the STREAMING TUI path, which ties it to the client's socket:
|
||||
// a client that disconnects mid-turn should not leave the pane running (and the caller's
|
||||
// concurrency slot held) until the turn or the 120s cap ends. runTuiTurn's finally does the
|
||||
// teardown. Omitted => the loop is byte-for-byte the pre-streaming loop.
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250, abortSignal = null }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
let lastEntrypoint = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
const err = new Error("tui_aborted: client disconnected before the turn completed");
|
||||
err.name = "TuiAbortError";
|
||||
throw err;
|
||||
}
|
||||
const resolved = p || findTranscriptPath(home, sessionId);
|
||||
if (resolved && existsSync(resolved)) {
|
||||
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
"contextWindow": 200000,
|
||||
"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",
|
||||
"displayName": "Claude Sonnet 4.6",
|
||||
|
||||
@@ -622,7 +622,12 @@ cmd_restart() {
|
||||
self_r="${BASH_SOURCE[0]}"
|
||||
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
||||
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
||||
DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
# env -u strips test-only key-store redirection vars (A4): if the invoking shell had
|
||||
# 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
|
||||
sleep 3
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
|
||||
+14
-8
@@ -122,11 +122,17 @@ provider = {
|
||||
"models": []
|
||||
}
|
||||
|
||||
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
|
||||
# Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet /
|
||||
# -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 = {
|
||||
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
}
|
||||
|
||||
def get_model_meta(mid):
|
||||
@@ -178,11 +184,11 @@ config.setdefault("agents", {})
|
||||
config["agents"].setdefault("defaults", {})
|
||||
config["agents"]["defaults"].setdefault("models", {})
|
||||
|
||||
# Build alias map (prefix match)
|
||||
# Build alias map (family prefix match — version-agnostic, see model_meta note)
|
||||
alias_prefixes = {
|
||||
"claude-opus-4": "Claude Opus",
|
||||
"claude-sonnet-4": "Claude Sonnet",
|
||||
"claude-haiku-4": "Claude Haiku",
|
||||
"claude-opus": "Claude Opus",
|
||||
"claude-sonnet": "Claude Sonnet",
|
||||
"claude-haiku": "Claude Haiku",
|
||||
}
|
||||
|
||||
for mid in model_ids:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.21.1",
|
||||
"version": "3.22.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.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
//
|
||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||
// 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()),
|
||||
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
|
||||
@@ -36,7 +49,7 @@ export function mergePlistEnv(existing, template) {
|
||||
|
||||
const preserved = {};
|
||||
for (const [k, v] of Object.entries(existingEnv)) {
|
||||
if (!KNOWN.has(k)) preserved[k] = v;
|
||||
if (!KNOWN.has(k) && !NEVER_PRESERVE.has(k)) preserved[k] = v;
|
||||
}
|
||||
if (Object.keys(preserved).length === 0) return template;
|
||||
|
||||
@@ -72,7 +85,7 @@ export function mergeSystemdEnv(existing, template) {
|
||||
const KNOWN = new Set(Object.keys(templateEnv));
|
||||
|
||||
const preservedLines = Object.entries(existingEnv)
|
||||
.filter(([k]) => !KNOWN.has(k))
|
||||
.filter(([k]) => !KNOWN.has(k) && !NEVER_PRESERVE.has(k))
|
||||
.map(([k, v]) => `Environment=${k}=${v}`);
|
||||
if (preservedLines.length === 0) return template;
|
||||
|
||||
|
||||
+349
-29
@@ -47,6 +47,7 @@ import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneH
|
||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.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";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -96,8 +97,40 @@ function _collectNodeManagerCandidates(home) {
|
||||
|
||||
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() {
|
||||
const isWin = process.platform === "win32";
|
||||
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 {
|
||||
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
|
||||
return process.env.CLAUDE_BIN;
|
||||
@@ -107,28 +140,43 @@ function resolveClaude() {
|
||||
}
|
||||
}
|
||||
|
||||
const home = process.env.HOME || "";
|
||||
const candidates = [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(home, ".local/bin/claude"),
|
||||
..._collectNodeManagerCandidates(home),
|
||||
];
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
const candidates = isWin
|
||||
? _collectWindowsClaudeCandidates()
|
||||
: [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(home, ".local/bin/claude"),
|
||||
..._collectNodeManagerCandidates(home),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
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; }
|
||||
} 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 {
|
||||
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; }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"FATAL: claude binary not found.\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" +
|
||||
" shown by `which claude` in your interactive shell.\n" +
|
||||
(isWin
|
||||
? " 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" +
|
||||
" shown by `which claude` in your interactive shell.\n") +
|
||||
" Checked: " + candidates.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -352,8 +400,61 @@ const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
|
||||
const tuiStats = {
|
||||
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
|
||||
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
|
||||
@@ -389,6 +490,14 @@ const tuiPool = TUI_POOL_SIZE > 0
|
||||
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),
|
||||
@@ -425,7 +534,12 @@ const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
|
||||
// erroring loudly — never a silent auth/credential corruption (there are no credentials here).
|
||||
function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
|
||||
try {
|
||||
mkdirSync(`${dir}/.claude`, { recursive: true });
|
||||
// mode 0700, and it matters for the PARENT: with `recursive`, this call can create ~/.ocp
|
||||
// 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).
|
||||
for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) {
|
||||
try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ }
|
||||
@@ -1354,7 +1468,14 @@ async function callClaude(model, messages, conversationId, keyName, res) {
|
||||
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
|
||||
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
|
||||
// `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 prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
@@ -1382,6 +1503,23 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
|
||||
// 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
|
||||
// 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 {
|
||||
const { text, entrypoint, truncated } = await runTuiTurn({
|
||||
prompt,
|
||||
@@ -1403,7 +1541,30 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
|
||||
? ({ 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.
|
||||
// A throw here propagates to the catch below (recordModelError + reject), so the
|
||||
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||
@@ -1427,20 +1588,73 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
|
||||
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
|
||||
}
|
||||
|
||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
||||
// 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 });
|
||||
// ── 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
|
||||
// Entrypoint/billing-pool observation was already recorded above, right after runTuiTurn
|
||||
// returned — see the A3-fix comment there (it must cover failed turns too, so it cannot live
|
||||
// on this success-only path).
|
||||
return text;
|
||||
} 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);
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -1448,6 +1662,102 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) ────────────────────────────────
|
||||
// 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
|
||||
@@ -2340,6 +2650,11 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
|
||||
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) {
|
||||
// TUI-mode: no real token stream — buffer the full turn via callClaudeTui,
|
||||
// optionally write-back to cache, then replay as chunked SSE.
|
||||
@@ -2641,8 +2956,13 @@ const server = createServer(async (req, res) => {
|
||||
// `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(
|
||||
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
|
||||
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT, streamEnabled: TUI_MODE && TUI_STREAM },
|
||||
tuiStats, tuiSemaphore, tuiPool,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -390,7 +390,9 @@ if (!DRY_RUN) {
|
||||
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
|
||||
const OCP_HOME = join(HOME, ".ocp");
|
||||
const ocpLogsDir = join(OCP_HOME, "logs");
|
||||
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
|
||||
// mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and
|
||||
// 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)
|
||||
if (platform === "darwin") {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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 */ }
|
||||
});
|
||||
+570
-15
@@ -3,22 +3,24 @@
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
// MUST come before keys.mjs: redirects the key store to a scratch dir (see test-env.mjs).
|
||||
import { TEST_OCP_DIR } from "./test-env.mjs";
|
||||
import { getDb, getDbPath, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { isLoopbackBind } from "./lib/net.mjs";
|
||||
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// Use a test database to avoid corrupting real data
|
||||
const TEST_DB = join(homedir(), ".ocp", "ocp-test.db");
|
||||
try { unlinkSync(TEST_DB); } catch {}
|
||||
process.env.HOME = homedir(); // normalize HOME so homedir()-derived paths are stable across shells
|
||||
|
||||
// Monkey-patch DB_PATH for testing (override the module-level variable)
|
||||
// Since keys.mjs uses lazy init, we can set env before first getDb() call
|
||||
process.env.HOME = homedir(); // ensure consistent
|
||||
// The scaffolding that used to live here CLAIMED to use "a test database to avoid corrupting
|
||||
// real data" by setting an env var before the first getDb(). It never worked: keys.mjs read no
|
||||
// env var, and ESM hoisting meant the assignment ran after the import anyway. The redirect is
|
||||
// now real, and lives in test-env.mjs (imported above, before keys.mjs). This test proves it.
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -535,7 +537,7 @@ async function runSingleflightTests() {
|
||||
await runSingleflightTests();
|
||||
|
||||
// ── Plist Env Merge Tests ──
|
||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
||||
import { mergePlistEnv, mergeSystemdEnv, NEVER_PRESERVE } from "./scripts/lib/plist-merge.mjs";
|
||||
|
||||
console.log("\nPlist env merge:");
|
||||
|
||||
@@ -649,6 +651,78 @@ test("mergePlistEnv is idempotent", () => {
|
||||
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
||||
});
|
||||
|
||||
// ── A4: security denylist — test-only key-store redirection vars must NEVER survive a setup
|
||||
// re-run, even when a prior unit already carried them. Mutation-proof: drop the
|
||||
// `!NEVER_PRESERVE.has(k)` guard in either merge fn and these fail (the vars get preserved).
|
||||
test("NEVER_PRESERVE denylists exactly the two key-store redirection vars", () => {
|
||||
assert.ok(NEVER_PRESERVE.has("NODE_ENV") && NEVER_PRESERVE.has("OCP_DIR_OVERRIDE"));
|
||||
assert.equal(NEVER_PRESERVE.size, 2, "exactly two — a new entry needs its own rationale + test");
|
||||
});
|
||||
|
||||
const PLIST_EXISTING_WITH_TEST_VARS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>dev.ocp.proxy</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>3456</string>
|
||||
<key>CLAUDE_CACHE_TTL</key>
|
||||
<string>600</string>
|
||||
<key>NODE_ENV</key>
|
||||
<string>test</string>
|
||||
<key>OCP_DIR_OVERRIDE</key>
|
||||
<string>/tmp/scratch-store</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
|
||||
test("mergePlistEnv strips test-only redirection vars (A4) but keeps legit user keys", () => {
|
||||
const merged = mergePlistEnv(PLIST_EXISTING_WITH_TEST_VARS, SAMPLE_TEMPLATE_PLIST);
|
||||
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/, "a legit user key is still preserved");
|
||||
assert.doesNotMatch(merged, /<key>NODE_ENV<\/key>/, "NODE_ENV must never reach a service unit");
|
||||
assert.doesNotMatch(merged, /OCP_DIR_OVERRIDE/, "OCP_DIR_OVERRIDE must never reach a service unit (key or value)");
|
||||
});
|
||||
|
||||
test("mergePlistEnv: an existing unit whose ONLY extras are denylisted → template unchanged", () => {
|
||||
const existing = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>3456</string>
|
||||
<key>NODE_ENV</key>
|
||||
<string>test</string>
|
||||
<key>OCP_DIR_OVERRIDE</key>
|
||||
<string>/tmp/scratch-store</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
assert.equal(mergePlistEnv(existing, SAMPLE_TEMPLATE_PLIST), SAMPLE_TEMPLATE_PLIST, "nothing left to preserve → clean template");
|
||||
});
|
||||
|
||||
const SYSTEMD_EXISTING_WITH_TEST_VARS = `[Unit]
|
||||
Description=OCP — Open Claude Proxy
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
||||
Environment=CLAUDE_PROXY_PORT=3456
|
||||
Environment=CLAUDE_CACHE_TTL=600
|
||||
Environment=NODE_ENV=test
|
||||
Environment=OCP_DIR_OVERRIDE=/tmp/scratch-store
|
||||
Restart=always
|
||||
`;
|
||||
|
||||
test("mergeSystemdEnv strips test-only redirection vars (A4) but keeps legit user keys", () => {
|
||||
const merged = mergeSystemdEnv(SYSTEMD_EXISTING_WITH_TEST_VARS, SAMPLE_TEMPLATE_SYSTEMD);
|
||||
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/, "a legit user key is still preserved");
|
||||
assert.doesNotMatch(merged, /Environment=NODE_ENV=/, "NODE_ENV must never reach a service unit");
|
||||
assert.doesNotMatch(merged, /OCP_DIR_OVERRIDE/, "OCP_DIR_OVERRIDE must never reach a service unit");
|
||||
});
|
||||
|
||||
test("mergeSystemdEnv is idempotent", () => {
|
||||
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
||||
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
||||
@@ -2978,12 +3052,18 @@ test("buildTuiHealthBlock: shape + live counters (the additive /health tui block
|
||||
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
|
||||
const block = buildTuiHealthBlock(
|
||||
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||
// `pool` joined this key set with the warm pane pool. The tui block is ADR-0007-owned (it
|
||||
// did not exist at v3.16.4, so it is outside ADR 0006's grandfather freeze), and the
|
||||
// addition is purely additive: every pre-existing key below still carries a byte-identical
|
||||
// value, and `pool` is null unless the operator opts in via OCP_TUI_POOL_SIZE.
|
||||
assert.deepEqual(Object.keys(block).sort(),
|
||||
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "pool", "queued"]);
|
||||
// Shape is ADDITIVE-only: the seven original keys must all still be present (existing
|
||||
// /health consumers are grandfathered, ADR 0006), plus `pool` (warm pane pool) and the
|
||||
// stream* fields (backlog #2). Asserting CONTAINMENT plus an exact added-set — rather than
|
||||
// one flat deepEqual — is what makes "additive" itself the thing under test: a future field
|
||||
// that silently REPLACED an original key would pass a flat equality check that was updated
|
||||
// alongside it, but cannot pass this one.
|
||||
const ORIGINAL_KEYS = ["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"];
|
||||
const keys = Object.keys(block);
|
||||
for (const k of ORIGINAL_KEYS) assert.ok(keys.includes(k), `original /health key must survive: ${k}`);
|
||||
assert.deepEqual(keys.filter((k) => !ORIGINAL_KEYS.includes(k)).sort(),
|
||||
["pool", "streamDeltas", "streamDivergences", "streamEnabled", "streamTopUps", "streamTurns", "streamZeroDeltaTurns"],
|
||||
"only the documented pool + streaming fields may be added");
|
||||
assert.equal(block.pool, null, "no pool passed → null (the default, pool disabled)");
|
||||
assert.equal(block.enabled, true);
|
||||
assert.equal(block.entrypointMode, "cli");
|
||||
@@ -3246,6 +3326,31 @@ test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ── Referential integrity (PR #152 review) ──────────────────────────────────
|
||||
// The value-mirror assertions above only prove the alias equals a string literal —
|
||||
// they pass even if that literal points at a model that does not exist in
|
||||
// models[]. A one-line slip (edit an alias, forget the models[] entry) would leave
|
||||
// /v1/models missing the model while every `model: "<alias>"` request passes
|
||||
// validation and then fails at CLI spawn. VALID_MODELS keys on alias *names*, so
|
||||
// nothing else checks alias *targets*. This is the guard with teeth.
|
||||
const _spotModelIds = new Set(_spotModels.models.map(m => m.id));
|
||||
|
||||
test("models.json: claude-sonnet-5 is present in models[] (the entry this PR adds)", () => {
|
||||
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
|
||||
});
|
||||
|
||||
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
|
||||
for (const [name, target] of Object.entries(_spotModels.aliases)) {
|
||||
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
|
||||
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) {
|
||||
assert.ok(_spotModelIds.has(target), `legacyAliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||
function escapeHtml(s) {
|
||||
@@ -3442,9 +3547,459 @@ async function runAsyncTests() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TUI real streaming: MessageDisplay hook sink (backlog #2) ───────────────
|
||||
// Pure-logic coverage for lib/tui/stream.mjs: sink parsing, the concat===T assertion,
|
||||
// prefix-stability, the auth-banner holdback, message scoping, and the error paths.
|
||||
import { TuiDeltaAssembler, parseDeltaChunk, buildStreamSettings, streamFilePath, HOOK_SCRIPT, prepareStreamHook, resolveStreamHoldback, DEFAULT_HOLDBACK_CHARS } from "./lib/tui/stream.mjs";
|
||||
|
||||
test("stream: parseDeltaChunk consumes only COMPLETE lines (a torn write stays unread)", () => {
|
||||
const p = (i, d, final = false) => JSON.stringify({ hook_event_name: "MessageDisplay", session_id: "s", message_id: "m", index: i, final, delta: d });
|
||||
// second payload is mid-write — no trailing newline yet
|
||||
const partial = `${p(0, "## A\n\n")}\n${p(1, "body").slice(0, 20)}`;
|
||||
const r1 = parseDeltaChunk(partial, 0);
|
||||
assert.equal(r1.deltas.length, 1, "only the terminated line is consumed");
|
||||
assert.equal(r1.consumed, 1);
|
||||
// now it lands complete
|
||||
const whole = `${p(0, "## A\n\n")}\n${p(1, "body")}\n`;
|
||||
const r2 = parseDeltaChunk(whole, r1.consumed);
|
||||
assert.equal(r2.deltas.length, 1, "the once-partial line is picked up exactly once");
|
||||
assert.equal(r2.deltas[0].delta, "body");
|
||||
assert.equal(r2.consumed, 2);
|
||||
// idempotent: nothing new
|
||||
assert.equal(parseDeltaChunk(whole, r2.consumed).deltas.length, 0);
|
||||
});
|
||||
|
||||
test("stream: parseDeltaChunk skips blank/garbage lines and foreign hook events", () => {
|
||||
const md = JSON.stringify({ hook_event_name: "MessageDisplay", message_id: "m", index: 0, final: true, delta: "ok" });
|
||||
const other = JSON.stringify({ hook_event_name: "Stop", message_id: "m", delta: "nope" });
|
||||
const text = `\n{not json\n${other}\n${md}\n`;
|
||||
const { deltas } = parseDeltaChunk(text, 0);
|
||||
assert.equal(deltas.length, 1);
|
||||
assert.equal(deltas[0].delta, "ok");
|
||||
});
|
||||
|
||||
// The live-verified contract (claude 2.1.207): deltas are the raw markdown source and
|
||||
// concat(deltas) === extractLatestAssistantText(transcript), byte-exactly.
|
||||
const mdFire = (i, delta, { final = false, mid = "m1" } = {}) =>
|
||||
({ hook_event_name: "MessageDisplay", session_id: "s1", message_id: mid, index: i, final, delta });
|
||||
|
||||
test("stream: concat(deltas) === T → exact, no top-up, prefix-stable at every n", () => {
|
||||
const chunks = ["## Mutex\n\n", "A **mutual exclusion lock** prevents concurrent access.\n\n", "```javascript\nconst m = new Mutex();\n```"];
|
||||
const T = chunks.join("");
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 10 });
|
||||
let acc = "";
|
||||
chunks.forEach((c, i) => {
|
||||
const out = a.push(mdFire(i, c, { final: i === chunks.length - 1 }));
|
||||
if (out) acc += out;
|
||||
assert.ok(T.startsWith(a.full), `prefix-stable at n=${i}`);
|
||||
});
|
||||
const rec = a.finalize(T);
|
||||
assert.equal(rec.ok, true);
|
||||
assert.equal(rec.exact, true, "concat(deltas) === T");
|
||||
assert.equal(acc + rec.tail, T, "client's assembled stream === T");
|
||||
assert.equal(a.deltas, 3);
|
||||
});
|
||||
|
||||
test("stream: holdback withholds the first chars so the auth-banner gate can still fire", () => {
|
||||
const banner = "Please run /login · API Error: 401 Invalid authentication credentials"; // 69 chars, a real one
|
||||
const a = new TuiDeltaAssembler(); // default holdback 100
|
||||
const out = a.push(mdFire(0, banner, { final: true }));
|
||||
assert.equal(out, null, "a banner-length message must NEVER reach the client");
|
||||
assert.equal(a.emitted, "", "nothing emitted");
|
||||
// and the whole-message detector still classifies it — the gate runs on T, before any flush
|
||||
assert.ok(detectTuiUpstreamError(a.full) !== null, "banner still detected at terminal");
|
||||
});
|
||||
|
||||
test("stream: holdback releases once past the detector's reach, and only then", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
assert.equal(a.push(mdFire(0, "x".repeat(80))), null, "80 chars: still held");
|
||||
const out = a.push(mdFire(1, "y".repeat(40)));
|
||||
assert.equal(out, "x".repeat(80) + "y".repeat(40), "released as one chunk once >100");
|
||||
assert.equal(a.push(mdFire(2, "tail")), "tail", "subsequent deltas stream straight through");
|
||||
});
|
||||
|
||||
// ── resolveStreamHoldback: the FLOOR under OCP_TUI_STREAM_HOLDBACK (A1 fix) ────────────
|
||||
// The C-1 auth-banner guarantee holds only while the holdback >= the default detector's
|
||||
// 100-char reach. These tests pin that the resolver CLAMPS UP to the floor. They are
|
||||
// mutation-proof: delete the `parsed < floor` branch and the sub-floor cases below fail
|
||||
// (a 50 would pass straight through, reopening the leak). The clamped flag drives the boot
|
||||
// warning in server.mjs, so its truthiness is asserted alongside every value.
|
||||
test("holdback: a sub-floor value is clamped UP to the floor and flagged", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("50"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("0"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("-5"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("99"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
});
|
||||
|
||||
test("holdback: garbage / NaN falls back to the floor and is flagged (not silently 0)", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("unlimited"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("5MB"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
});
|
||||
|
||||
test("holdback: an above-floor value passes through unchanged and is NOT flagged", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("200"), { value: 200, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback("101"), { value: 101, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(String(DEFAULT_HOLDBACK_CHARS)), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
});
|
||||
|
||||
test("holdback: an unset env var takes the floor WITHOUT flagging (no spurious boot warning)", () => {
|
||||
assert.deepEqual(resolveStreamHoldback(undefined), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(null), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(""), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(" "), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
});
|
||||
|
||||
test("holdback: the floor is a parameter, so a deployment can raise (never lower) it", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("150", 200), { value: 200, clamped: true }, "custom floor still clamps up");
|
||||
assert.deepEqual(resolveStreamHoldback("300", 200), { value: 300, clamped: false });
|
||||
});
|
||||
|
||||
test("stream: a short answer never passes the holdback and is delivered whole at terminal", () => {
|
||||
const T = "The capital of France is Paris.";
|
||||
const a = new TuiDeltaAssembler();
|
||||
assert.equal(a.push(mdFire(0, T, { final: true })), null);
|
||||
const rec = a.finalize(T);
|
||||
assert.equal(rec.ok, true);
|
||||
assert.equal(rec.exact, true);
|
||||
assert.equal(rec.tail, T, "the whole short answer is flushed at terminal (buffered semantics)");
|
||||
});
|
||||
|
||||
test("stream: a DROPPED delta is a safe prefix → top-up from the transcript, exact=false", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 5 });
|
||||
a.push(mdFire(0, "Hello world, this is the first block. "));
|
||||
const T = "Hello world, this is the first block. And the tail the hook never delivered.";
|
||||
const rec = a.finalize(T);
|
||||
assert.equal(rec.ok, true, "prefix → recoverable");
|
||||
assert.equal(rec.exact, false, "flagged: concat(deltas) !== T");
|
||||
assert.equal(rec.tail, "And the tail the hook never delivered.");
|
||||
assert.equal(a.emitted + rec.tail, T, "client still receives exactly T");
|
||||
});
|
||||
|
||||
test("stream: emitted bytes NOT a prefix of T → divergence, refuse the turn", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 5 });
|
||||
a.push(mdFire(0, "Let me go and read that file for you first."));
|
||||
const rec = a.finalize("A completely different final answer.");
|
||||
assert.equal(rec.ok, false, "must NOT serve text the transcript disagrees with");
|
||||
assert.equal(rec.tail, null);
|
||||
});
|
||||
|
||||
// Message scoping: the transcript keeps only the LAST assistant message, so the assembler
|
||||
// must too. Discarding is safe while nothing has been emitted; after that it is a divergence.
|
||||
test("stream: new message_id BEFORE any emit → held text discarded, stays exact vs T", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
a.push(mdFire(0, "I'll check the file.", { mid: "m1" })); // short pre-tool prose, held back
|
||||
assert.equal(a.emitted, "");
|
||||
const answer = "The file defines a Mutex class with acquire and release, and " + "z".repeat(90);
|
||||
const out = a.push(mdFire(0, answer, { mid: "m2", final: true }));
|
||||
assert.equal(out, answer, "only the FINAL message's text is emitted");
|
||||
const rec = a.finalize(answer); // T = extractLatestAssistantText = the last message only
|
||||
assert.equal(rec.ok, true);
|
||||
assert.equal(rec.exact, true, "scoping to the last message_id keeps concat === T true");
|
||||
assert.equal(a.messages, 2);
|
||||
});
|
||||
|
||||
test("stream: new message_id AFTER an emit → unretractable, flagged and refused", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 10 });
|
||||
a.push(mdFire(0, "Long pre-tool prose that already went out to the client.", { mid: "m1" }));
|
||||
assert.notEqual(a.emitted, "");
|
||||
// Assert what push() RETURNS, not merely that finalize() refuses. This test used to check
|
||||
// only restartedAfterEmit + finalize().ok, which left it passing while F1 was live: the
|
||||
// second message's bytes were still being handed to the client. "The turn is refused" and
|
||||
// "the client got the bytes anyway" were both true at once.
|
||||
const out = a.push(mdFire(0, "The real answer.", { mid: "m2", final: true }));
|
||||
assert.equal(out, null, "after a message boundary follows an emit, NOTHING more may be emitted");
|
||||
assert.equal(a.restartedAfterEmit, true);
|
||||
assert.equal(a.finalize("The real answer.").ok, false, "must refuse: prose already emitted is not in T");
|
||||
});
|
||||
|
||||
test("F1: an auth banner rendered as a LATER message is never forwarded to the client", () => {
|
||||
// The leak this class exists to prevent, in the shape production actually runs
|
||||
// (OCP_TUI_FULL_TOOLS=1 → multi-message tool-using turns are the norm):
|
||||
// 1. the model narrates past the holdback before a tool call → released, emitted != ""
|
||||
// 2. credentials expire mid-turn → claude renders the 401 as ordinary assistant TEXT,
|
||||
// as a NEW message
|
||||
// 3. pre-fix: push() took the `if (this.released)` branch — `released` was never reset at
|
||||
// a message boundary — and returned the BANNER verbatim, straight to the client.
|
||||
// The holdback protected only the FIRST message of a turn. This asserts it protects the rest.
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
const narration = "I'll check that file for you and then report back with what I find inside it.";
|
||||
a.push(mdFire(0, narration + narration, { mid: "m1" })); // > holdback → released
|
||||
assert.notEqual(a.emitted, "", "precondition: the narration really did reach the client");
|
||||
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
const out = a.push(mdFire(1, BANNER, { mid: "m2", final: true }));
|
||||
assert.equal(out, null, "the auth banner must NOT be forwarded once a later message begins");
|
||||
assert.ok(!a.emitted.includes("401"), "no byte of the banner may have reached the client");
|
||||
assert.equal(a.finalize(BANNER).ok, false, "and the turn is refused, not served");
|
||||
});
|
||||
|
||||
test("F1: a first payload with message_id:null cannot disarm the guard", () => {
|
||||
// The residual bypass the reviewer found by probing. `this.messageId` used to be initialized
|
||||
// to null, so a first payload carrying message_id:null compared EQUAL to it → no boundary
|
||||
// registered → `messages` stayed 0 → when the REAL boundary arrived, `messages > 1` evaluated
|
||||
// 1 > 1 === false → restartedAfterEmit never armed → the released branch forwarded the banner.
|
||||
// The whole F1 guard was disarmed by a single null field. parseDeltaChunk does not validate
|
||||
// message_id, so such a payload does reach push().
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
const narration = "I'll check that file for you and then report back with what I find inside it.";
|
||||
a.push({ hook_event_name: "MessageDisplay", message_id: null, delta: narration + narration });
|
||||
assert.notEqual(a.emitted, "", "precondition: the narration released to the client");
|
||||
assert.equal(a.messages, 1, "a null message_id is still a MESSAGE — it must register as one");
|
||||
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
const out = a.push({ hook_event_name: "MessageDisplay", message_id: "m2", delta: BANNER });
|
||||
assert.equal(out, null, "the banner must not be forwarded — the guard must arm regardless");
|
||||
assert.equal(a.restartedAfterEmit, true);
|
||||
assert.ok(!a.emitted.includes("401"));
|
||||
});
|
||||
|
||||
test("F1: whitespace cannot buy a release — the holdback screens TRIMMED length", () => {
|
||||
// detectTuiUpstreamError() TRIMS before applying its <=100-char rule, so gating release on
|
||||
// the UNTRIMMED pending.length let 101 spaces trim to "" → the detector has nothing to
|
||||
// classify → returns null → release fires having screened nothing, and every subsequent
|
||||
// delta of that message (a banner included) streams unfiltered.
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
assert.equal(a.push(mdFire(0, " ".repeat(101), { mid: "m1" })), null,
|
||||
"101 chars of whitespace must not clear a 100-char holdback");
|
||||
assert.equal(a.released, false, "…and must not flip the assembler into released state");
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
assert.equal(a.push(mdFire(1, BANNER, { mid: "m1" })), null, "so the banner stays held back");
|
||||
assert.ok(!a.emitted.includes("401"));
|
||||
});
|
||||
|
||||
test("F3: a STALE or truncated hook script is overwritten, not trusted because it exists", () => {
|
||||
// ~/.ocp-tui/stream/{md-hook.sh,settings.json} persist across OCP restarts. The old
|
||||
// write-if-missing guard meant a host that booted once under an older version was stuck on
|
||||
// that version's HOOK_SCRIPT forever — no upgrade could reach it. Worse, a non-atomic write
|
||||
// interrupted mid-flight leaves a TRUNCATED md-hook.sh that existsSync() calls fine, and
|
||||
// claude BLOCKS on that hook synchronously on every fire.
|
||||
const dir = mkdtemp2(`${tmpdir2()}/ocp-hook-`);
|
||||
mkdir2(dir, { recursive: true });
|
||||
writeFile2(`${dir}/md-hook.sh`, "#!/bin/sh\n# stale, truncated leftov", { mode: 0o700 });
|
||||
writeFile2(`${dir}/settings.json`, "{ TRUNCATED", { mode: 0o600 });
|
||||
|
||||
const settings = prepareStreamHook(dir);
|
||||
|
||||
assert.equal(readFile2(`${dir}/md-hook.sh`, "utf8"), HOOK_SCRIPT,
|
||||
"the stale script must be replaced with the current one, not left because it existed");
|
||||
assert.deepEqual(JSON.parse(readFile2(settings, "utf8")), buildStreamSettings(`${dir}/md-hook.sh`),
|
||||
"…and so must the stale settings file");
|
||||
});
|
||||
|
||||
test("stream: hook script is a write-and-exit sh script and tolerates a missing sink var", () => {
|
||||
// forceSyncExecution: claude BLOCKS on this hook, so it must do no work inline.
|
||||
assert.ok(HOOK_SCRIPT.startsWith("#!/bin/sh"));
|
||||
assert.ok(HOOK_SCRIPT.includes('[ -n "$OCP_TUI_STREAM_FILE" ] || exec cat >/dev/null'),
|
||||
"no sink configured => swallow stdin and exit 0; never fail, never block claude");
|
||||
assert.ok(!/curl|node |python/.test(HOOK_SCRIPT), "no interpreter/network work in a blocking hook");
|
||||
});
|
||||
|
||||
test("stream: settings registers exactly one MessageDisplay command hook (static, no per-request data)", () => {
|
||||
const s = buildStreamSettings("/x/md-hook.sh");
|
||||
assert.deepEqual(Object.keys(s.hooks), ["MessageDisplay"]);
|
||||
assert.equal(s.hooks.MessageDisplay[0].hooks[0].type, "command");
|
||||
assert.equal(s.hooks.MessageDisplay[0].hooks[0].command, "/x/md-hook.sh");
|
||||
// Warm-pool compatibility: the settings file must NOT carry a session/request-specific path.
|
||||
assert.ok(!JSON.stringify(s).includes(".jsonl"), "sink path comes from the pane env, not the settings file");
|
||||
});
|
||||
|
||||
test("stream: sink path is keyed by session_id (concurrent panes cannot interleave)", () => {
|
||||
// OCP_TUI_MAX_CONCURRENT defaults to 2 — two claude panes DO run at once. A shared sink
|
||||
// would splice request A's deltas into request B's stream.
|
||||
const A = streamFilePath("/d", "aaaa-1111");
|
||||
const B = streamFilePath("/d", "bbbb-2222");
|
||||
assert.notEqual(A, B, "one sink per session-id");
|
||||
assert.ok(A.endsWith("/aaaa-1111.jsonl"));
|
||||
});
|
||||
|
||||
test("stream: buildTuiCmd — OFF is byte-for-byte the pre-streaming argv; ON adds only env + --settings", () => {
|
||||
const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli");
|
||||
assert.ok(!off.includes("--settings"), "no --settings when streaming is off");
|
||||
assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off");
|
||||
const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" });
|
||||
assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env");
|
||||
assert.ok(on.includes("--settings '/d/s.json'"));
|
||||
// must not regress the MCP wall or the pinned effort (#156)
|
||||
assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact");
|
||||
assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact");
|
||||
assert.ok(!on.includes(" -p ") && !on.includes("--bare"), "still a plain interactive TUI spawn");
|
||||
});
|
||||
|
||||
test("stream: /health block is additive and exposes the divergence counter", () => {
|
||||
const stats = { lastEntrypoint: "cli", entrypointMismatches: 0, streamTurns: 3, streamDeltas: 21, streamTopUps: 1, streamDivergences: 0 };
|
||||
const sem = { inflight: 0, queued: 0 };
|
||||
const b = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 2, streamEnabled: true }, stats, sem);
|
||||
assert.equal(b.streamEnabled, true);
|
||||
assert.equal(b.streamTurns, 3);
|
||||
assert.equal(b.streamDivergences, 0);
|
||||
// existing fields unchanged (grandfathered /health consumers)
|
||||
assert.equal(b.enabled, true);
|
||||
assert.equal(b.entrypointMode, "cli");
|
||||
assert.equal(b.maxConcurrent, 2);
|
||||
// a pre-streaming tuiStats (no stream* keys) must not produce undefined/NaN
|
||||
const legacy = buildTuiHealthBlock({ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, { lastEntrypoint: null, entrypointMismatches: 0 }, sem);
|
||||
assert.equal(legacy.streamEnabled, false);
|
||||
assert.equal(legacy.streamDivergences, 0);
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing —
|
||||
// otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).
|
||||
// ─── TUI streaming × warm pool: the INTEGRATION seam (backlog #2 rebased onto #158) ───
|
||||
//
|
||||
// The hook is installed by bootTuiPane at BOOT, and runTuiTurn reads the sink off the PANE
|
||||
// (pane.streamFile). That indirection is the entire reason a POOLED pane streams: the pool
|
||||
// pre-boots panes long before a request exists, so anything derived at turn time would leave
|
||||
// every pool HIT silently buffered while every MISS streamed — a perf regression with no
|
||||
// failing test and no error, visible only as "streaming mysteriously does nothing in prod".
|
||||
// These three guard that seam.
|
||||
console.log("\nTUI streaming × warm pane pool integration:");
|
||||
|
||||
import { bootTuiPane as bootPaneUnderTest, runTuiTurn as runTurnUnderTest } from "./lib/tui/session.mjs";
|
||||
import { mkdtempSync as mkdtemp2, writeFileSync as writeFile2, mkdirSync as mkdir2, readFileSync as readFile2 } from "node:fs";
|
||||
import { tmpdir as tmpdir2 } from "node:os";
|
||||
|
||||
// Fake tmux that records the spawned pane command and always looks ready + pasted.
|
||||
function makeTmuxRecorder() {
|
||||
const cmds = [];
|
||||
const tmux = (args) => {
|
||||
cmds.push(args);
|
||||
if (args[0] === "capture-pane") {
|
||||
// input bar present AND the prompt visibly landed → both polls pass immediately
|
||||
return { status: 0, stdout: "[Pasted text #1 +2 lines]\n ? for shortcuts" };
|
||||
}
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
return { tmux, cmds, paneCmd: () => (cmds.find((a) => a[0] === "new-session") || []).slice(-1)[0] || "" };
|
||||
}
|
||||
|
||||
// A HOME with one already-terminal transcript for `sid`, so readTuiTranscript returns at once.
|
||||
function seedTranscript(home, sid, text) {
|
||||
const dir = `${home}/.claude/projects/x`;
|
||||
mkdir2(dir, { recursive: true });
|
||||
writeFile2(`${dir}/${sid}.jsonl`, JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text }], stop_reason: "end_turn" },
|
||||
turn_duration: 1234, cc_entrypoint: "cli",
|
||||
}) + "\n");
|
||||
}
|
||||
|
||||
test("bootTuiPane with a streamDir installs the hook AT BOOT and hands the sink back on the pane", async () => {
|
||||
const home = mkdtemp2(`${tmpdir2()}/ocp-t-`);
|
||||
const streamDir = mkdtemp2(`${tmpdir2()}/ocp-s-`);
|
||||
const rec = makeTmuxRecorder();
|
||||
const pane = await bootPaneUnderTest({
|
||||
model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux, streamDir,
|
||||
});
|
||||
// The pane carries its OWN sink, named from its OWN session-id — which is what a pre-booted
|
||||
// pool pane needs, since it is minted with no knowledge of the request it will eventually serve.
|
||||
assert.ok(pane.streamFile, "a streamDir must yield a per-pane sink on the returned pane");
|
||||
assert.ok(pane.streamFile.includes(pane.sessionId), "the sink is keyed by the pane's own session-id");
|
||||
const cmd = rec.paneCmd();
|
||||
assert.ok(cmd.includes("OCP_TUI_STREAM_FILE="), "the pane's env must carry its sink path");
|
||||
assert.ok(cmd.includes(pane.streamFile), "…and it must be THIS pane's sink, not a shared one");
|
||||
assert.ok(cmd.includes("--settings"), "the MessageDisplay hook must be registered at spawn");
|
||||
});
|
||||
|
||||
test("bootTuiPane WITHOUT a streamDir spawns exactly today's pane — no hook, no --settings", async () => {
|
||||
const home = mkdtemp2(`${tmpdir2()}/ocp-t-`);
|
||||
const rec = makeTmuxRecorder();
|
||||
const pane = await bootPaneUnderTest({
|
||||
model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux,
|
||||
});
|
||||
assert.equal(pane.streamFile, null, "no streamDir → no sink (streaming is opt-in, default OFF)");
|
||||
const cmd = rec.paneCmd();
|
||||
assert.ok(!cmd.includes("--settings"), "the default spawn must not gain --settings");
|
||||
assert.ok(!cmd.includes("OCP_TUI_STREAM_FILE"), "the default spawn must not gain the hook env");
|
||||
});
|
||||
|
||||
test("REGRESSION: a WARM (pooled) pane streams — the sink comes off the pane, not the turn", async () => {
|
||||
const home = mkdtemp2(`${tmpdir2()}/ocp-t-`);
|
||||
const streamDir = mkdtemp2(`${tmpdir2()}/ocp-s-`);
|
||||
const rec = makeTmuxRecorder();
|
||||
|
||||
// Pre-boot a pane the way the POOL does (its own session-id + sink, fixed at boot).
|
||||
const warm = await bootPaneUnderTest({
|
||||
model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux, streamDir,
|
||||
});
|
||||
// Its hook has already fired twice by the time the turn's transcript goes terminal.
|
||||
writeFile2(warm.streamFile,
|
||||
JSON.stringify({ hook_event_name: "MessageDisplay", delta: "Hello " }) + "\n" +
|
||||
JSON.stringify({ hook_event_name: "MessageDisplay", delta: "world" }) + "\n");
|
||||
seedTranscript(home, warm.sessionId, "Hello world");
|
||||
|
||||
const seen = [];
|
||||
const pool = { acquire: () => warm, refill: () => {}, warm: 0 };
|
||||
const out = await runTurnUnderTest({
|
||||
prompt: "say hello", model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux, pool,
|
||||
onDelta: (d) => seen.push(d.delta),
|
||||
// streamDir is deliberately NOT passed: on a pool HIT runTuiTurn never cold-boots, so if it
|
||||
// recomputed the sink from a turn-time streamDir (the pre-rebase shape) this turn would emit
|
||||
// ZERO deltas and silently serve buffered. Reading pane.streamFile is what makes it stream.
|
||||
streamDir: null,
|
||||
});
|
||||
assert.deepEqual(seen, ["Hello ", "world"], "the pooled pane's deltas must reach the client");
|
||||
assert.equal(out.text, "Hello world", "and the transcript stays authoritative for the final text");
|
||||
});
|
||||
|
||||
console.log("\nTest isolation (the suite must never touch the operator's live key store):");
|
||||
|
||||
test("the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db", () => {
|
||||
// The guard that was missing. `npm test` wrote live, UNREVOKED api_keys rows straight into the
|
||||
// operator's real ~/.ocp/ocp.db — the same database the running server reads — two per run,
|
||||
// unbounded (737 junk keys vs 12 real ones on the maintainer's host before this landed). It
|
||||
// went unnoticed for so long precisely because NOTHING asserted where the store actually was.
|
||||
const real = join(homedir(), ".ocp", "ocp.db");
|
||||
const used = getDbPath();
|
||||
assert.ok(used, "getDb() must have opened something by now");
|
||||
assert.notEqual(used, real, "the suite must NOT open the operator's live key database");
|
||||
assert.ok(used.startsWith(TEST_OCP_DIR), `expected a scratch db under ${TEST_OCP_DIR}, got ${used}`);
|
||||
});
|
||||
|
||||
test("a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE", () => {
|
||||
// Must run OUT OF PROCESS. The parent is irreversibly NODE_ENV=test by the time any test runs
|
||||
// (test-env.mjs set it before keys.mjs was imported), so the production path is unreachable
|
||||
// from in here — and an in-process test can only ever RE-IMPLEMENT the predicate, which is
|
||||
// worthless: the first cut of this test did exactly that, and deleting the whole NODE_ENV gate
|
||||
// from keys.mjs still left the suite at 320 passed / 0 failed. A copy of the predicate is not
|
||||
// the predicate. So: spawn a child with no NODE_ENV, the override set, and HOME redirected to
|
||||
// a temp dir (so the real key store is never opened), and assert what the REAL keys.mjs did.
|
||||
const home = mkdtempSync(join(tmpdir(), "ocp-prodsim-"));
|
||||
const evil = mkdtempSync(join(tmpdir(), "ocp-evil-"));
|
||||
try {
|
||||
const keysUrl = pathToFileURL(join(import.meta.dirname, "keys.mjs")).href;
|
||||
// The child prints the override it SAW, then the store it actually opened. Printing both is
|
||||
// the negative control: without it, a future refactor that renamed the env var and missed
|
||||
// this test's `env` object would leave the child with no override at all — and "prod opened
|
||||
// the right store" would pass for the wrong reason. Asserting the child saw it and ignored
|
||||
// it anyway is the claim we actually want to make.
|
||||
const probe = `import { getDb, getDbPath, closeDb } from ${JSON.stringify(keysUrl)};
|
||||
getDb(); process.stdout.write(process.env.OCP_DIR_OVERRIDE + "\\n" + getDbPath()); closeDb();`;
|
||||
const env = { ...process.env, HOME: home, OCP_DIR_OVERRIDE: evil };
|
||||
delete env.NODE_ENV; // a production server has no NODE_ENV
|
||||
const [seen, opened] = execFileSync(process.execPath, ["--input-type=module", "-e", probe],
|
||||
{ env, encoding: "utf8" }).trim().split("\n");
|
||||
assert.equal(seen, evil, "precondition: the child must actually SEE the override");
|
||||
assert.equal(opened, join(home, ".ocp", "ocp.db"), "a prod process must open HOME/.ocp/ocp.db");
|
||||
assert.ok(!opened.startsWith(evil), "…having seen the override, a prod process must IGNORE it");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(evil, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("listKeys does not depend on rows left behind by an earlier or concurrent run", () => {
|
||||
// The ~1-in-6 flake: two runs sharing one db file. keys.find() returned undefined and the
|
||||
// caller's `in` check threw a TypeError instead of failing cleanly. With a per-run scratch db
|
||||
// the store starts empty, so the count is exactly what THIS run created.
|
||||
const mine = listKeys().filter((k) => k.name === "test-user-1");
|
||||
assert.equal(mine.length, 1, "exactly one test-user-1 — a shared store would accumulate duplicates");
|
||||
});
|
||||
|
||||
runAsyncTests().then(() => Promise.all(pendingAsync)).then(() => {
|
||||
closeDb();
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
||||
|
||||
Reference in New Issue
Block a user