mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551d4e7db6 | ||
|
|
ddaea4df17 | ||
|
|
fc63b8a49a | ||
|
|
6848f9751c | ||
|
|
5258d5d395 | ||
|
|
6854075c01 | ||
|
|
45152d58b0 | ||
|
|
d96da46fa0 | ||
|
|
2538233059 | ||
|
|
31e5a44099 | ||
|
|
2922d68842 | ||
|
|
38da104b97 | ||
|
|
5aaab5ea28 | ||
|
|
3bd19956ff | ||
|
|
fe615cb0d3 | ||
|
|
60930f0ba4 | ||
|
|
c86e3d014f | ||
|
|
3322d7bdae | ||
|
|
79c1d61e1d | ||
|
|
a37ff713d9 | ||
|
|
6d4751f983 | ||
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb | ||
|
|
1f577c075f | ||
|
|
6dff36959a | ||
|
|
1b02f181fa | ||
|
|
0000926358 | ||
|
|
aa1c65beb1 | ||
|
|
879b40fe93 | ||
|
|
68d58e7df4 | ||
|
|
4a7d79c330 | ||
|
|
c3b1f32c86 | ||
|
|
4458490caa | ||
|
|
36be723198 | ||
|
|
7b065600aa | ||
|
|
1b5a742711 | ||
|
|
05a984df89 | ||
|
|
a30b20978c | ||
|
|
cd98b51b96 | ||
|
|
74260d7f6f | ||
|
|
885f62addf |
@@ -29,10 +29,14 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
|
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
|
||||||
# Each token is matched as a fixed string against server.mjs only.
|
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
|
||||||
|
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
|
||||||
|
# drift to a known-wrong host, not necessarily a hallucination).
|
||||||
|
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
|
||||||
BLACKLIST=(
|
BLACKLIST=(
|
||||||
"api.anthropic.com/api/oauth/usage"
|
"api.anthropic.com/api/oauth/usage"
|
||||||
|
"console.anthropic.com/v1/oauth/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
FAIL=0
|
FAIL=0
|
||||||
@@ -51,8 +55,8 @@ jobs:
|
|||||||
============================================================
|
============================================================
|
||||||
server.mjs contains a token on the OCP alignment blacklist.
|
server.mjs contains a token on the OCP alignment blacklist.
|
||||||
|
|
||||||
These tokens were introduced by LLM hallucinations and do
|
These tokens are either LLM hallucinations that never appeared in cli.js,
|
||||||
not appear in cli.js at any shipped Claude Code version.
|
or pinned wrong-host variants of a verified Class A endpoint (a drift).
|
||||||
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
||||||
(commit b87992f) for the full incident record.
|
(commit b87992f) for the full incident record.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ The following Rules apply to **Class A operations** (the `cli.js`-mirror surface
|
|||||||
|
|
||||||
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
|
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
|
||||||
|
|
||||||
|
### OAuth token-host verification (2026-05-31)
|
||||||
|
|
||||||
|
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
|
||||||
|
machinery is a Class A surface (Rules 1–5). Because `cli.js` now ships as a
|
||||||
|
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
|
||||||
|
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
|
||||||
|
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
|
||||||
|
|
||||||
|
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
|
||||||
|
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
|
||||||
|
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
|
||||||
|
host is absent (0 hits).
|
||||||
|
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
|
||||||
|
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
|
||||||
|
|
||||||
|
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
|
||||||
|
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
|
||||||
|
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
|
||||||
|
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Historical Lesson: The 2026-04-11 Drift
|
## Historical Lesson: The 2026-04-11 Drift
|
||||||
|
|||||||
+167
@@ -1,5 +1,172 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TUI session-scope / boot-reap (#148)** — `lib/tui/session.mjs`'s tmux session prefix is now scoped per-instance by listen port (`ocp-tui-<port>-`) instead of a bare host-wide `ocp-tui-` constant, so a second OCP instance on the same host (e.g. a temporary verification instance) can no longer have its live TUI sessions reaped or `kill-server`'d by another instance's boot/periodic sweep. The one-time boot reap also claims exact-shape legacy `ocp-tui-<8hex>` sessions (pre-fix naming) once, to clean up zombies left behind across an in-place upgrade.
|
||||||
|
- **`-p` spawn-token mutex + keychain caching (#150)** — the real-HOME token fallback used when the keychain token is within its 5-minute expiry window is now serialized behind a mutex, so concurrent `-p` spawns no longer race the same single-use refresh token against each other (the credential-fork hazard). Added a 30s TTL cache + last-good-label memoization for the keychain read, cutting per-spawn event-loop blocking. The isolation decision (`/health` isolated/real-home reporting) is now re-evaluated per spawn instead of memoized forever, so `/health` no longer misreports a stale decision. New module `lib/spawn-auth.mjs` extracts the pure, unit-testable primitives (mutex, TTL cache, expiry gate, label ordering).
|
||||||
|
- **Concurrency queue / disconnect handling (#149)** — the shared semaphore now honors a runtime-lowered `maxConcurrent` immediately (previously a decrease was silently ignored until in-flight tasks finished on their own) and wakes queued waiters right away when the limit is raised. Queued `-p`/TUI requests are now linked to the client's HTTP connection via `AbortSignal`; a client that disconnects while queued is spliced out of the queue instead of still spawning `claude` once a slot frees. A singleflight follower whose leader disconnected now retries instead of inheriting a spurious 500, and a queued-then-disconnected request is no longer recorded as a usage failure or logged as an error (quiet disconnect handling).
|
||||||
|
|
||||||
|
## v3.21.0 — 2026-06-25
|
||||||
|
|
||||||
|
Cleanup + docs release: TUI dead-code removal, docs honesty, and release prep. No new `cli.js` wire behavior; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||||
|
|
||||||
|
### TUI dead-code / footgun cleanup
|
||||||
|
|
||||||
|
- **A1 — removed inert entrypoint-env path** (`lib/tui/session.mjs`): deleted `resolveTuiEntrypointEnv()` and the redundant env-strip block in `runTuiTurn`. The `{env}` object passed to `spawnSync` (tmux itself) was the wrong target — tmux does NOT forward the spawning process's environment to the pane; the pane's `claude` gets its env exclusively from the `env` prefix string built inside `buildTuiCmd` (verified live 2026-06-01). The spawnSync env is now intentionally minimal (`HOME` only). Behavior is unchanged: `buildTuiCmd` already handled all claude-specific env vars via its prefix string.
|
||||||
|
- **A2 — removed test-only transcript helpers** (`lib/tui/transcript.mjs`): deleted `encodeCwd()` and `transcriptPath()` exports and the tests that pinned them. Production resolves transcripts exclusively via `findTranscriptPath()` (glob by session-id), which is immune to the exact path-encoding rule. No non-test importers existed (grep confirms). A `// TODO` comment near `findTranscriptPath()` notes that a CI fixture-contract test would make claude-schema drift fail loudly.
|
||||||
|
- **A3 — removed headless-unusable `--dangerously-skip-permissions` branch** (`lib/tui/session.mjs` + `README.md`): `OCP_TUI_FULL_TOOLS=1` now always takes the `--allowedTools` path. The removed branch pushed `--dangerously-skip-permissions` when `CLAUDE_SKIP_PERMISSIONS=true`; on claude v2.1.x this triggers an interactive bypass-acceptance screen that a headless tmux pane cannot answer → the turn hangs to the wallclock cap and bricks the pane. The working path is `--allowedTools` + scratch-home `settings.json` `additionalDirectories`. `CLAUDE_SKIP_PERMISSIONS` for the `-p` path is unchanged (still used in `server.mjs`).
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Client-tools boundary** (README `§ How It Works`): OCP is a text-prompt bridge only — it does not pass OpenAI `tools`/`functions` or Anthropic `tool_use` blocks to the client. Clients receive assistant TEXT only; client-local tool execution is not supported by design (bypassing `cli.js` = out of scope per `ALIGNMENT.md`).
|
||||||
|
- **ToS honesty** (README `§ Deployment model & security`): pooling one Claude subscription across multiple distinct people may violate Anthropic's Consumer ToS and risk account suspension by the abuse classifier. The defensible framing is "one person, your own devices" — friends/team sharing is not. The prior language ("account terms are your call") was accurate but understated the risk.
|
||||||
|
- **"Why OCP" posture** (README `§ Why OCP?`): new bullet making explicit that OCP drives the official `claude` CLI as-is — no OAuth token extraction, no binary patching, no protocol invention — so traffic looks like genuine Claude Code (`cc_entrypoint=cli`).
|
||||||
|
- **Promotion plan** (`docs/PROMOTION.md`): "stable & visible" strategy covering goal (polish + low-key OSS visibility, NOT growth-hacking given the live ToS/billing risk), pre-requisites (stability first), honest ToS disclosure requirement, items explicitly skipped (multi-backend routing → OLP; gateway model-discovery; raw API passthrough → ALIGNMENT.md scope), TUI toggle as billing-split insurance, and low-key visibility actions. Framed as a recommendation for the maintainer to review, not a committed plan.
|
||||||
|
|
||||||
|
### Previously shipped (v3.20.x) — documented here for completeness
|
||||||
|
|
||||||
|
- **Default `-p` spawn-home isolation** (v3.20.0 / PR-A): per-request `claude` spawns run in a credential-free minimal scratch HOME (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token, cutting per-request latency (measured ~10–28s → ~3–7s). Kill-switch: `OCP_SPAWN_REAL_HOME=1`. Active mode shown at startup and on `/health.spawn`.
|
||||||
|
- **Bounded concurrency wait-queue** (v3.20.0 / PR-B): excess `-p` requests queue (up to `CLAUDE_MAX_QUEUE`, default 16) instead of being rejected; a full queue returns `HTTP 429` + `Retry-After` (not an opaque 500). New env vars: `CLAUDE_MAX_QUEUE`, `CLAUDE_QUEUE_RETRY_AFTER`. Surfaced on `/health.concurrency` + `/health.stats.queueRejections`.
|
||||||
|
- **`ocp restart`** macOS `bootout`+`bootstrap` (v3.20.0 / PR-B): safe restart command that forces launchd to re-read the plist (unlike `kickstart -k` which reuses the cached env).
|
||||||
|
- **`/ocp` plugin OpenClaw-2026.5.27 compat** (v3.20.0 / PR-C): gateway plugin updated for the current OpenClaw API version.
|
||||||
|
|
||||||
|
## v3.20.1 — 2026-06-13
|
||||||
|
|
||||||
|
TUI-mode auth hardening: fixes the recurring `Please run /login · API Error: 401` (the PI231 incident) and reaps leaked defunct `claude` sessions. ([#141](https://github.com/dtzp555-max/ocp/pull/141))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TUI 401 / credential corruption (#141)** — interactive `claude` prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var (unlike `-p` mode, where the env token wins). OCP TUI's per-request spawn + `kill-session` cycle raced claude's single-use refresh-token rotation, corrupting the refresh token to an empty string → permanent 401 that `claude /login` couldn't fix (each new spawn re-corrupted it). This bit Linux/file-based hosts specifically (macOS reads credentials from the Keychain, so Mac mini was immune). **Fix:** when `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI claude now runs in a credential-free scratch HOME (`<HOME>/.ocp-tui/home`, overridable by `OCP_TUI_HOME`) seeded with onboarding + cwd-trust but **no `.credentials.json`**, so the env token is the only credential and claude never runs the refresh path. Recurrence-proof — a later `claude login` can no longer break TUI. Also: `buildTuiCmd` passes `CLAUDE_CODE_OAUTH_TOKEN` to the spawn, and `reapStaleTuiSessions` reaps defunct `claude` sessions (tmux-server-owned zombies) via `kill-server` when no foreign session remains, plus a 15-min idle-gated periodic reap. When the env token is unset, behaviour is byte-for-byte unchanged (real-home + credentials.json). Two independent fresh-context reviewers (Iron Rule 10) + a live PI231 portability test (works with a corrupt credentials.json present). Authorized by the ADR 0007 PR-D amendment (Class B).
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
- `CLAUDE_CODE_OAUTH_TOKEN` — when set on a TUI host, TUI authenticates via this long-lived token in a credential-isolated home (recommended; immune to credentials.json corruption).
|
||||||
|
- `OCP_TUI_HOME` — overrides the TUI scratch home; if you previously pointed it at your real home, unset it to get the credential-isolated default.
|
||||||
|
|
||||||
|
## v3.20.0 — 2026-06-10
|
||||||
|
|
||||||
|
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||||
|
|
||||||
|
### TUI — honesty & cache correctness (#137)
|
||||||
|
|
||||||
|
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
|
||||||
|
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
|
||||||
|
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
|
||||||
|
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
|
||||||
|
|
||||||
|
### TUI — concurrency & observability (#139)
|
||||||
|
|
||||||
|
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
|
||||||
|
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
|
||||||
|
|
||||||
|
### Operations (#138)
|
||||||
|
|
||||||
|
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
|
||||||
|
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
|
||||||
|
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
|
||||||
|
|
||||||
|
### New environment variables
|
||||||
|
|
||||||
|
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
|
||||||
|
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
|
||||||
|
|
||||||
|
## v3.19.0 — 2026-06-02
|
||||||
|
|
||||||
|
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
|
||||||
|
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
|
||||||
|
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
|
||||||
|
|
||||||
|
## v3.18.0 — 2026-06-01
|
||||||
|
|
||||||
|
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123–#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
|
||||||
|
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
|
||||||
|
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
|
||||||
|
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
|
||||||
|
|
||||||
|
### Reliability / correctness
|
||||||
|
|
||||||
|
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
|
||||||
|
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
|
||||||
|
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
|
||||||
|
|
||||||
|
### Alignment / governance
|
||||||
|
|
||||||
|
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
|
||||||
|
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
|
||||||
|
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
|
||||||
|
|
||||||
|
### New environment variables
|
||||||
|
|
||||||
|
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
|
||||||
|
|
||||||
|
## v3.17.1 — 2026-05-31
|
||||||
|
|
||||||
|
### Fix — code-audit P1/P2 hardening
|
||||||
|
|
||||||
|
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
|
||||||
|
|
||||||
|
**Availability / correctness (P1):**
|
||||||
|
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
|
||||||
|
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
|
||||||
|
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
|
||||||
|
|
||||||
|
**Security gates / cache integrity (P2):**
|
||||||
|
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
|
||||||
|
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
|
||||||
|
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
|
||||||
|
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
|
||||||
|
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.17.0 — 2026-05-31
|
||||||
|
|
||||||
|
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
|
||||||
|
|
||||||
|
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
|
||||||
|
|
||||||
|
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
|
||||||
|
|
||||||
|
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
|
||||||
|
|
||||||
|
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
|
||||||
|
|
||||||
|
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
|
||||||
|
|
||||||
|
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
|
||||||
|
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
|
||||||
|
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Model — add claude-opus-4-8
|
||||||
|
|
||||||
|
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v3.16.4 — 2026-05-13
|
## v3.16.4 — 2026-05-13
|
||||||
|
|
||||||
### Refactor — port-literal SPOT + CI guardrail
|
### Refactor — port-literal SPOT + CI guardrail
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ There are several Claude proxy projects. OCP picks a specific lane: **align tigh
|
|||||||
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||||
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
||||||
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||||
|
- **Drives the official CLI as-is, no binary patching.** OCP spawns the official `claude` CLI (or hosts it in an interactive tmux pane for TUI mode) — it does not extract OAuth tokens from memory, patch the binary, or invent protocol extensions. Traffic therefore looks like genuine Claude Code to Anthropic's classifiers (`cc_entrypoint=cli`). See `ALIGNMENT.md` for why this constraint is load-bearing.
|
||||||
|
|
||||||
### Comparison
|
### Comparison
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
|
|||||||
|
|
||||||
**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 share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||||
|
|
||||||
|
### Related: OLP — Open LLM Proxy
|
||||||
|
|
||||||
|
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
|
||||||
|
|
||||||
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
|
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
|
||||||
|
|
||||||
## Supported Tools
|
## Supported Tools
|
||||||
@@ -116,7 +121,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
installed and logged in (`claude auth status`). Install missing pieces
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
using my system's package manager.
|
using my system's package manager.
|
||||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
|
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
|
||||||
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||||
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||||
|
|
||||||
@@ -124,11 +129,12 @@ Before each step, tell me what you'll run and wait for confirmation.
|
|||||||
On any error, diagnose first — don't auto-retry.
|
On any error, diagnose first — don't auto-retry.
|
||||||
```
|
```
|
||||||
|
|
||||||
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
|
**LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
I want to install OCP on this device as a LAN server so my family and other
|
I want to install OCP on this device as a LAN server so my own devices on the
|
||||||
devices on the network can share my Claude Pro/Max subscription.
|
network can reach my Claude Pro/Max subscription through a local
|
||||||
|
OpenAI-compatible endpoint.
|
||||||
|
|
||||||
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
"Server Setup" → "LAN mode" path:
|
"Server Setup" → "LAN mode" path:
|
||||||
@@ -142,7 +148,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||||
6. Run `ocp lan` to show me the LAN IP and connect command.
|
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||||
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
|
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
|
||||||
|
|
||||||
Tell me each step before running it. On error, diagnose before retrying.
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
```
|
```
|
||||||
@@ -165,7 +171,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
chmod +x ocp-connect
|
chmod +x ocp-connect
|
||||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
3. Follow any IDE-specific manual hints it prints.
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
|
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
|
||||||
5. Tell me to reload my shell + restart any IDE that was already running.
|
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||||
|
|
||||||
Don't auto-retry on error. Tell me the failure mode first.
|
Don't auto-retry on error. Tell me the failure mode first.
|
||||||
@@ -235,7 +241,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
|||||||
**Verify:**
|
**Verify:**
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
curl http://127.0.0.1:3456/v1/models
|
||||||
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Headless install notes
|
#### Headless install notes
|
||||||
@@ -281,7 +287,7 @@ chmod +x ocp-connect
|
|||||||
./ocp-connect <server-ip>
|
./ocp-connect <server-ip>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
|
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ocp-connect <server-ip>
|
./ocp-connect <server-ip>
|
||||||
@@ -314,7 +320,7 @@ OCP Connect v1.3.0
|
|||||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
Testing API access...
|
Testing API access...
|
||||||
✓ API accessible (4 models available)
|
✓ API accessible (5 models available)
|
||||||
|
|
||||||
Shell config:
|
Shell config:
|
||||||
✓ .bashrc
|
✓ .bashrc
|
||||||
@@ -344,6 +350,7 @@ OCP Connect v1.3.0
|
|||||||
✓ OpenClaw configured
|
✓ OpenClaw configured
|
||||||
Provider: ocp
|
Provider: ocp
|
||||||
Models:
|
Models:
|
||||||
|
• ocp/claude-opus-4-8
|
||||||
• ocp/claude-opus-4-7
|
• ocp/claude-opus-4-7
|
||||||
• ocp/claude-opus-4-6
|
• ocp/claude-opus-4-6
|
||||||
• ocp/claude-sonnet-4-6
|
• ocp/claude-sonnet-4-6
|
||||||
@@ -365,7 +372,7 @@ OCP Connect v1.3.0
|
|||||||
The script automatically:
|
The script automatically:
|
||||||
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||||
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
||||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+)
|
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
|
||||||
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
||||||
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
||||||
|
|
||||||
@@ -404,10 +411,23 @@ ocp keys revoke son-ipad # Revoke a key
|
|||||||
|------|-----|----------|
|
|------|-----|----------|
|
||||||
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
|
||||||
|
|
||||||
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
||||||
|
|
||||||
|
### Deployment model & security (read this)
|
||||||
|
|
||||||
|
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
|
||||||
|
|
||||||
|
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
|
||||||
|
|
||||||
|
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
|
||||||
|
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
|
||||||
|
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
|
||||||
|
- **Account terms and ToS — read before sharing with others.** Claude Pro/Max are *per-user* accounts. Pooling a single subscription across **multiple distinct people** may violate Anthropic's Consumer Terms of Service and risk account suspension by the abuse classifier. The defensible framing is **"one person, your own devices"** — sharing with friends or a team is not. OCP does not change your account terms, and whether any particular sharing setup complies with the ToS is the account holder's responsibility. Review Anthropic's Usage Policy before extending access to other people.
|
||||||
|
|
||||||
|
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
|
||||||
|
|
||||||
### Anonymous Access (optional)
|
### Anonymous Access (optional)
|
||||||
|
|
||||||
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||||
@@ -423,7 +443,7 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
|||||||
|
|
||||||
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||||
|
|
||||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||||
|
|
||||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||||
|
|
||||||
@@ -469,6 +489,8 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
|||||||
- Admin and anonymous users are never subject to quotas
|
- Admin and anonymous users are never subject to quotas
|
||||||
- PATCH is a partial update — omitted fields are left unchanged
|
- PATCH is a partial update — omitted fields are left unchanged
|
||||||
|
|
||||||
|
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
|
||||||
|
|
||||||
### Important Notes
|
### Important Notes
|
||||||
|
|
||||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||||
@@ -674,17 +696,26 @@ Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored loca
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
```
|
```
|
||||||
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
|
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
|
||||||
```
|
```
|
||||||
|
|
||||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||||
|
|
||||||
|
### Client-tools boundary
|
||||||
|
|
||||||
|
OCP is a **text-prompt bridge** to the official `claude` CLI. It does **not** pass through OpenAI `tools`/`functions` payloads or Anthropic `tool_use` blocks to the client. Clients (Cline, Cursor, OpenClaw, etc.) pointed at OCP receive **assistant TEXT only** — they never get `tool_calls` to execute locally.
|
||||||
|
|
||||||
|
Any tool use happens server-side, under the `--allowedTools` set configured on the OCP host. In default mode (no `CLAUDE_NO_CONTEXT`), the `claude` CLI's own built-in tools are available to the model; in TUI mode, the operator controls the tool surface via `OCP_TUI_FULL_TOOLS`. Either way, the tools run under the operator's credentials on the server, and the client sees only the final text output.
|
||||||
|
|
||||||
|
**Client-local tool execution is not supported by design.** Supporting it would require bypassing the `claude` CLI to call the raw Anthropic API directly — that is a different product, and is out of scope per `ALIGNMENT.md` (every OCP endpoint must correspond to something `cli.js` actually does).
|
||||||
|
|
||||||
## Available Models
|
## Available Models
|
||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-6` | Previous Opus, retained for pinning |
|
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||||
|
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||||
|
|
||||||
@@ -705,7 +736,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/v1/models` | GET | List available models |
|
| `/v1/models` | GET | List available models |
|
||||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||||
| `/health` | GET | Comprehensive health check |
|
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
|
||||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||||
| `/status` | GET | Combined overview (usage + health) |
|
| `/status` | GET | Combined overview (usage + health) |
|
||||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||||
@@ -822,6 +853,29 @@ ocp restart
|
|||||||
openclaw gateway restart
|
openclaw gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
|
||||||
|
|
||||||
|
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp restart
|
||||||
|
```
|
||||||
|
|
||||||
|
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the new value reached the running process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
|
||||||
|
```
|
||||||
|
|
||||||
|
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
|
||||||
|
|
||||||
### Usage shows "unknown"
|
### Usage shows "unknown"
|
||||||
|
|
||||||
Usually caused by an expired Claude CLI session. Fix:
|
Usually caused by an expired Claude CLI session. Fix:
|
||||||
@@ -840,6 +894,10 @@ node ~/ocp/scripts/sync-openclaw.mjs
|
|||||||
|
|
||||||
This is read-only at startup; the warning never blocks the gateway from running.
|
This is read-only at startup; the warning never blocks the gateway from running.
|
||||||
|
|
||||||
|
### A TUI session vanished right after upgrading OCP
|
||||||
|
|
||||||
|
If you ran a pre-3.21.1 OCP instance and a post-3.21.1 instance on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance — restart the affected session (`ocp restart` or re-run your TUI turn) and it will come back under the new instance's port-scoped naming.
|
||||||
|
|
||||||
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
|
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
|
||||||
|
|
||||||
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
|
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
|
||||||
@@ -851,6 +909,24 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
|||||||
|
|
||||||
Future `ocp update` invocations sync automatically.
|
Future `ocp update` invocations sync automatically.
|
||||||
|
|
||||||
|
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
|
||||||
|
|
||||||
|
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix.
|
||||||
|
|
||||||
|
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
|
||||||
|
|
||||||
|
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (systemd): confirm the token is in the service env
|
||||||
|
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
|
||||||
|
|
||||||
|
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -863,7 +939,9 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||||
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
|
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
|
||||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
|
||||||
|
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
|
||||||
|
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
|
||||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||||
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
||||||
@@ -871,7 +949,19 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||||
|
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||||
|
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||||
|
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||||
|
| `OCP_SPAWN_REAL_HOME` | *(unset)* | Kill-switch for the default `-p`/stream-json **spawn-home isolation** (latency fix). When unset and an OAuth token is resolvable, OCP runs the per-request `claude` spawn in a **credential-free minimal scratch home** (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token — so it loads none of the operator's heavy global `~/.claude` (plugins/skills/hooks) or the project `CLAUDE.md`, cutting per-request latency (measured ~10–28s → ~3–7s). Set to `"1"` to force the legacy real-`HOME` spawn (no cwd override) even when a token exists. With **no** resolvable token, OCP falls back to the real `HOME` automatically (zero regression). Active mode is shown at startup and on `/health.spawn`. |
|
||||||
|
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||||
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
|
| `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_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_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||||
|
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json` `additionalDirectories` instead. See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
||||||
|
|
||||||
### Streaming heartbeat
|
### Streaming heartbeat
|
||||||
|
|
||||||
@@ -883,6 +973,141 @@ Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. I
|
|||||||
|
|
||||||
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
||||||
|
|
||||||
|
## Subscription-pool (TUI) mode
|
||||||
|
|
||||||
|
> **SECURITY — read before enabling.**
|
||||||
|
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
|
||||||
|
|
||||||
|
### What it is and why
|
||||||
|
|
||||||
|
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| Launch method | `cc_entrypoint` | Billing pool |
|
||||||
|
|---------------|-----------------|-------------|
|
||||||
|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
|
||||||
|
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
|
||||||
|
|
||||||
|
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
|
||||||
|
|
||||||
|
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
|
||||||
|
|
||||||
|
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
|
||||||
|
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
|
||||||
|
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
|
||||||
|
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
|
||||||
|
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
|
||||||
|
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
|
||||||
|
|
||||||
|
### Enabling TUI-mode (opt-in)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites
|
||||||
|
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||||
|
# tmux must be installed: brew install tmux / apt install tmux
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
|
||||||
|
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
|
||||||
|
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
|
||||||
|
# is the only credential and is authoritative. This both stops a stale credentials.json
|
||||||
|
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
|
||||||
|
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
|
||||||
|
# See the auth note below + ADR 0007 PR-D.
|
||||||
|
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||||
|
# Optionally tune:
|
||||||
|
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||||
|
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
|
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||||
|
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
|
||||||
|
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||||
|
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
- **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.
|
||||||
|
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||||
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||||
|
|
||||||
|
### ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
|
||||||
|
|
||||||
|
**TUI mode cannot serve real-time or interactive-latency consumers.** This is a hard property of the
|
||||||
|
path, stated plainly so you can rule it out before building on it:
|
||||||
|
|
||||||
|
| | measured |
|
||||||
|
|---|---|
|
||||||
|
| **TTFT floor (first token)** | **≈ 6 s** — immovable |
|
||||||
|
| cold boot → input bar ready | ~1 s (per request; not the bottleneck) |
|
||||||
|
| OCP's own overhead above the CLI | ~4 s (n=1 same-turn decomposition) |
|
||||||
|
| direct Anthropic API, same prompt (for scale) | 0.84–1.64 s |
|
||||||
|
|
||||||
|
The ~6 s floor is the `claude` CLI itself: it always injects the full Claude Code system prompt plus
|
||||||
|
its tool definitions before your prompt, on every turn, no matter what you ask. No flag removes it
|
||||||
|
(`--exclude-dynamic-system-prompt-sections` was measured: **no effect** on the floor). Extended
|
||||||
|
thinking is *not* the cause — `OCP_TUI_EFFORT` already defaults to `low`, which is what cuts a
|
||||||
|
formerly-inherited `xhigh` down to this floor and collapses its variance.
|
||||||
|
|
||||||
|
On top of the floor you pay the model's generation time (a function of output length). Progressive
|
||||||
|
output is not wired up **yet** (see "No real token streaming" above — it is achievable and planned),
|
||||||
|
so today a turn returns as one blob once generation completes. Note that streaming, when it lands,
|
||||||
|
will move the *first* byte earlier — it does **not** shorten the turn, and a consumer that needs the
|
||||||
|
complete answer gains nothing from it.
|
||||||
|
|
||||||
|
**Use TUI mode for**: batch, background, and latency-insensitive work where the subscription pool is
|
||||||
|
the point. **Do not use it for**: anything a person is waiting on interactively, or any consumer with
|
||||||
|
a sub-5-second budget. Full measurements and methodology:
|
||||||
|
[`docs/plans/2026-07-13-tui-latency/`](docs/plans/2026-07-13-tui-latency/).
|
||||||
|
|
||||||
|
### Monitoring drift via `/health`
|
||||||
|
|
||||||
|
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"tui": {
|
||||||
|
"enabled": true, // CLAUDE_TUI_MODE === "true"
|
||||||
|
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
|
||||||
|
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
|
||||||
|
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
|
||||||
|
"inflight": 1, // TUI turns running right now
|
||||||
|
"queued": 0, // TUI turns waiting for a concurrency slot
|
||||||
|
"maxConcurrent": 2 // OCP_TUI_MAX_CONCURRENT
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
|
||||||
|
|
||||||
|
### Kill-switch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset CLAUDE_TUI_MODE
|
||||||
|
# restart OCP
|
||||||
|
```
|
||||||
|
|
||||||
|
The stream-json path is restored immediately. No other change is needed.
|
||||||
|
|
||||||
|
### 2026-06-15 operator checklist
|
||||||
|
|
||||||
|
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||||
|
|
||||||
|
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
|
||||||
|
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
|
||||||
|
|
||||||
|
### Architecture and design decisions
|
||||||
|
|
||||||
|
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
Top-level files a contributor or operator may need to know:
|
Top-level files a contributor or operator may need to know:
|
||||||
|
|||||||
+22
-15
@@ -132,6 +132,10 @@ function fmtChars(n) {
|
|||||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
function barColor(pct) {
|
function barColor(pct) {
|
||||||
if (pct >= 80) return "bar-red";
|
if (pct >= 80) return "bar-red";
|
||||||
if (pct >= 50) return "bar-amber";
|
if (pct >= 50) return "bar-amber";
|
||||||
@@ -144,8 +148,8 @@ async function refreshStatus() {
|
|||||||
const r = data.requests || {};
|
const r = data.requests || {};
|
||||||
|
|
||||||
document.getElementById("status-cards").innerHTML = `
|
document.getElementById("status-cards").innerHTML = `
|
||||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
|
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
|
||||||
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
|
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
|
||||||
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
||||||
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
||||||
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
||||||
@@ -160,15 +164,15 @@ async function refreshStatus() {
|
|||||||
document.getElementById("plan-cards").innerHTML = `
|
document.getElementById("plan-cards").innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">Session (5h)</div>
|
<div class="label">Session (5h)</div>
|
||||||
<div class="value">${s.percent || '?'}</div>
|
<div class="value">${escapeHtml(s.percent || '?')}</div>
|
||||||
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
||||||
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
|
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">Weekly (7d)</div>
|
<div class="label">Weekly (7d)</div>
|
||||||
<div class="value">${w.percent || '?'}</div>
|
<div class="value">${escapeHtml(w.percent || '?')}</div>
|
||||||
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
||||||
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
|
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -181,21 +185,21 @@ async function refreshUsage() {
|
|||||||
const tbody = document.querySelector("#key-usage-table tbody");
|
const tbody = document.querySelector("#key-usage-table tbody");
|
||||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${k.key_name}</td>
|
<td>${escapeHtml(k.key_name)}</td>
|
||||||
<td>${k.requests}</td>
|
<td>${k.requests}</td>
|
||||||
<td>${k.successes}</td>
|
<td>${k.successes}</td>
|
||||||
<td>${k.errors}</td>
|
<td>${k.errors}</td>
|
||||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||||
<td class="mono">${k.last_request || '-'}</td>
|
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||||
|
|
||||||
const rtbody = document.querySelector("#recent-table tbody");
|
const rtbody = document.querySelector("#recent-table tbody");
|
||||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
|
||||||
<td>${r.key_name}</td>
|
<td>${escapeHtml(r.key_name)}</td>
|
||||||
<td>${r.model}</td>
|
<td>${escapeHtml(r.model)}</td>
|
||||||
<td>${fmtChars(r.prompt_chars)}</td>
|
<td>${fmtChars(r.prompt_chars)}</td>
|
||||||
<td>${fmtChars(r.response_chars)}</td>
|
<td>${fmtChars(r.response_chars)}</td>
|
||||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||||
@@ -216,13 +220,16 @@ async function refreshKeys() {
|
|||||||
const tbody = document.querySelector("#keys-table tbody");
|
const tbody = document.querySelector("#keys-table tbody");
|
||||||
tbody.innerHTML = (data.keys || []).map(k => `
|
tbody.innerHTML = (data.keys || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${k.name}</td>
|
<td>${escapeHtml(k.name)}</td>
|
||||||
<td class="mono">${k.keyPreview}</td>
|
<td class="mono">${escapeHtml(k.keyPreview)}</td>
|
||||||
<td class="mono">${k.created_at}</td>
|
<td class="mono">${escapeHtml(k.created_at)}</td>
|
||||||
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
||||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
|
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("");
|
`).join("");
|
||||||
|
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
|
||||||
|
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
|
||||||
|
);
|
||||||
} catch(e) { /* not admin */ }
|
} catch(e) { /* not admin */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# OCP Promotion Strategy — "Stable & Visible"
|
||||||
|
|
||||||
|
> **This document is a recommendation for the maintainer to review and adjust, not a committed plan.**
|
||||||
|
> It reflects the project's current posture (post-v3.21.0) and should be revisited whenever
|
||||||
|
> the Anthropic billing / ToS environment changes significantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal: Polish + Low-Key OSS Visibility
|
||||||
|
|
||||||
|
The goal is **stability and quiet discoverability**, not growth-hacking. OCP is a personal power tool
|
||||||
|
that has been open-sourced because others can benefit from it. The right audience finds it via GitHub
|
||||||
|
search, issue threads in related projects, and word of mouth — not viral posts.
|
||||||
|
|
||||||
|
**Explicitly avoid:**
|
||||||
|
|
||||||
|
- HN / Reddit front-page pushes, influencer outreach, or any campaign that would attract a large
|
||||||
|
influx of users before the ToS/billing situation has settled. Anthropic is actively tightening
|
||||||
|
billing and enforcement on subscription-sharing (the June-15 Agent-SDK billing split is
|
||||||
|
*paused*, not cancelled — and consumer-ToS enforcement on multi-person sharing is a live risk).
|
||||||
|
A high-traffic spotlight right now would draw scrutiny that a low-profile project avoids.
|
||||||
|
- Promising features that require bypassing the `claude` CLI (raw API calls, OAuth extraction, etc.)
|
||||||
|
— that would violate `ALIGNMENT.md` and the ToS simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pre-Requisite: Stability First
|
||||||
|
|
||||||
|
Do not promote until the house is in order:
|
||||||
|
|
||||||
|
- [x] The concurrency / latency perf fixes are shipped (v3.20.x–v3.21.0).
|
||||||
|
- [x] Docs honesty is complete (client-tools boundary, ToS sharing disclosure, this doc).
|
||||||
|
- [ ] The June-15 Agent-SDK billing split is either confirmed cancelled or OCP has a confirmed
|
||||||
|
stable path (TUI toggle as insurance — see §5 below).
|
||||||
|
|
||||||
|
Promoting a project that has known rough edges in docs or stability only generates support burden
|
||||||
|
and negative first impressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Honest ToS Disclosure on Sharing
|
||||||
|
|
||||||
|
Any promotion materials must carry the same disclosure as `README.md § "Deployment model & security"`:
|
||||||
|
|
||||||
|
> Pooling a single Claude subscription across **multiple distinct people** may violate Anthropic's
|
||||||
|
> Consumer Terms of Service and risk account suspension. The defensible framing is "one person,
|
||||||
|
> your own devices". Friends/team sharing is not.
|
||||||
|
|
||||||
|
This framing should appear in any README badge, linked blog post, or issue comment that mentions
|
||||||
|
LAN sharing. It is not a disclaimer that discourages usage — it is honest positioning that protects
|
||||||
|
both the project and its users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What to Explicitly Skip
|
||||||
|
|
||||||
|
These items are **not gaps in OCP** — they are deliberate stance decisions:
|
||||||
|
|
||||||
|
- **Multi-backend routing** (routing to OpenAI, Gemini, Llama, etc.) — that is the sibling [OLP
|
||||||
|
project](https://github.com/dtzp555-max/olp)'s role. OCP stays Claude-only by design.
|
||||||
|
- **Gateway model-discovery** (auto-detecting which models a remote server offers) — not needed
|
||||||
|
for OCP's single-provider, single-subscription model. `models.json` is the SPOT.
|
||||||
|
- **Raw Anthropic API passthrough** (bypassing the `claude` CLI) — out of scope per `ALIGNMENT.md`.
|
||||||
|
|
||||||
|
Do not add these to OCP roadmaps or respond to feature requests for them with "planned" — the
|
||||||
|
correct answer is "that's OLP territory" or "out of scope per ALIGNMENT.md".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TUI Toggle as Insurance
|
||||||
|
|
||||||
|
The `CLAUDE_TUI_MODE` opt-in is the primary mitigation if the June-15 billing split reactivates
|
||||||
|
and makes the default `-p` path draw from the metered Agent SDK credit pool.
|
||||||
|
|
||||||
|
Keep the TUI toggle:
|
||||||
|
- Functional and tested across the three deployment hosts.
|
||||||
|
- Documented in the README, including the security constraints (single-user only).
|
||||||
|
- Easily discoverable for users who get unexpectedly metered.
|
||||||
|
|
||||||
|
If the split reactivates, the recommended operator path is: set `CLAUDE_TUI_MODE=true` +
|
||||||
|
`CLAUDE_CODE_OAUTH_TOKEN` → credential-isolated scratch home → subscription pool. That path is
|
||||||
|
already shipped and documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Low-Key Visibility Actions (when §2 pre-requisites are met)
|
||||||
|
|
||||||
|
- Keep the GitHub README polished and honest — it is the primary landing page.
|
||||||
|
- Respond promptly to issues and PRs — the project's reputation is built on reliability, not
|
||||||
|
marketing.
|
||||||
|
- Add OCP to the `awesome-claude` / `awesome-llm-tools` lists if they exist and allow self-PRs
|
||||||
|
— low-effort, targeted, reaches the right audience.
|
||||||
|
- When related projects (Cline, OpenCode, OpenClaw, Continue.dev) post about local Claude proxies,
|
||||||
|
a short factual comment linking to OCP is appropriate — not spam.
|
||||||
|
- Maintain the `CHANGELOG.md` with clear, honest summaries — users who are already running OCP
|
||||||
|
are the best vector for word-of-mouth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: v3.21.0 cleanup cycle. Maintainer should re-read before any external promotion.*
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||||
|
|
||||||
|
**Date:** 2026-05-31
|
||||||
|
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
|
||||||
|
**Deciders:** project maintainer
|
||||||
|
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| `cc_entrypoint` value | Billing pool |
|
||||||
|
|-----------------------|-------------|
|
||||||
|
| `cli` | Pro/Max subscription pool |
|
||||||
|
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
|
||||||
|
|
||||||
|
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
|
||||||
|
|
||||||
|
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
|
||||||
|
|
||||||
|
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
|
||||||
|
|
||||||
|
### Source-verified entrypoint mechanism (PR-4 amendment)
|
||||||
|
|
||||||
|
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
|
||||||
|
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
|
||||||
|
|
||||||
|
```
|
||||||
|
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
|
||||||
|
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
|
||||||
|
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
|
||||||
|
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
|
||||||
|
drawing from the metered pool.
|
||||||
|
|
||||||
|
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
|
||||||
|
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
|
||||||
|
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
|
||||||
|
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
|
||||||
|
labelled as such.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
|
||||||
|
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
|
||||||
|
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").
|
||||||
|
|
||||||
|
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||||||
|
|
||||||
|
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
|
||||||
|
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
|
||||||
|
inherited value first** so a stray env var from OCP's own parent process can never leak in and
|
||||||
|
mislabel the billing header. Then:
|
||||||
|
|
||||||
|
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|
||||||
|
|----------------------|-----------|
|
||||||
|
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
|
||||||
|
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
|
||||||
|
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
|
||||||
|
|
||||||
|
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
|
||||||
|
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
|
||||||
|
guarantee a PTY), it fails/drops the request — it does not force the signal.*
|
||||||
|
|
||||||
|
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
|
||||||
|
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
|
||||||
|
|
||||||
|
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
|
||||||
|
|
||||||
|
### 2026-06-15 verification protocol
|
||||||
|
|
||||||
|
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
|
||||||
|
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
|
||||||
|
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
|
||||||
|
escalate spoofing.
|
||||||
|
|
||||||
|
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
|
||||||
|
major cli.js upgrade.
|
||||||
|
|
||||||
|
### Default behaviour is unchanged
|
||||||
|
|
||||||
|
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
|
||||||
|
|
||||||
|
### Kill-switch
|
||||||
|
|
||||||
|
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||||
|
|
||||||
|
### Home strategy
|
||||||
|
|
||||||
|
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
|
||||||
|
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
|
||||||
|
> is unset) the default is a **credential-free scratch home**, not the real home. The
|
||||||
|
> descriptions below remain accurate for the **no-env-token** case and the **explicit
|
||||||
|
> `OCP_TUI_HOME` override** case.
|
||||||
|
|
||||||
|
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||||
|
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
|
||||||
|
|
||||||
|
### Working directory
|
||||||
|
|
||||||
|
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
|
||||||
|
|
||||||
|
### MCP hard-disable
|
||||||
|
|
||||||
|
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
|
||||||
|
|
||||||
|
### Session namespace
|
||||||
|
|
||||||
|
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECURITY — PROMINENT WARNING
|
||||||
|
|
||||||
|
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
|
||||||
|
|
||||||
|
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
|
||||||
|
|
||||||
|
- A guest prompt would run `claude` with the **operator's** filesystem access.
|
||||||
|
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
|
||||||
|
|
||||||
|
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
|
||||||
|
|
||||||
|
The B-path (multi-tenant isolation) requires:
|
||||||
|
1. `--tools ""` (no built-in tools)
|
||||||
|
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
|
||||||
|
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
|
||||||
|
|
||||||
|
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observability and concurrency (PR-B amendment)
|
||||||
|
|
||||||
|
**Date:** 2026-06-10
|
||||||
|
**Status:** Accepted — amends ADR 0007.
|
||||||
|
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
|
||||||
|
|
||||||
|
### C-4 — independent concurrency bound for the TUI path
|
||||||
|
|
||||||
|
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
|
||||||
|
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
|
||||||
|
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
|
||||||
|
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||||
|
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
|
||||||
|
also multiplies subscription rate-limit pressure.
|
||||||
|
|
||||||
|
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
|
||||||
|
`TuiSemaphore`):
|
||||||
|
|
||||||
|
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
|
||||||
|
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
|
||||||
|
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
|
||||||
|
host alive under a family burst while still allowing some overlap. It is deliberately **not**
|
||||||
|
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
|
||||||
|
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
|
||||||
|
coupling them would mis-size one of the two paths.
|
||||||
|
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
|
||||||
|
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
|
||||||
|
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
|
||||||
|
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
|
||||||
|
rather than silent OOM.
|
||||||
|
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
|
||||||
|
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
|
||||||
|
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
|
||||||
|
|
||||||
|
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
|
||||||
|
the semaphore is never entered. The default stream-json path is untouched.
|
||||||
|
|
||||||
|
### C-5 — operator-visible drift surface on `/health` (additive)
|
||||||
|
|
||||||
|
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
|
||||||
|
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
|
||||||
|
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
|
||||||
|
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
|
||||||
|
|
||||||
|
```
|
||||||
|
tui: {
|
||||||
|
enabled: <TUI_MODE>,
|
||||||
|
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
|
||||||
|
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
|
||||||
|
entrypointMismatches: <count of cli-expected-but-got-other turns>,
|
||||||
|
inflight: <current concurrent TUI turns>,
|
||||||
|
queued: <turns waiting for a slot>,
|
||||||
|
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
|
||||||
|
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
|
||||||
|
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
|
||||||
|
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
|
||||||
|
consumers regardless of mode.
|
||||||
|
|
||||||
|
### ALIGNMENT authorization for the `/health` change
|
||||||
|
|
||||||
|
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
|
||||||
|
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
|
||||||
|
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
|
||||||
|
request and requires either a behaviour-preserving refactor PR or its own ADR."*
|
||||||
|
|
||||||
|
This amendment **is** that authorization. The argument:
|
||||||
|
|
||||||
|
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
|
||||||
|
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
|
||||||
|
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
|
||||||
|
monitoring) read the fields they already read and are unaffected — the change is
|
||||||
|
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
|
||||||
|
a non-ADR contract change.
|
||||||
|
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
|
||||||
|
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
|
||||||
|
endpoint or a new method (which would each require their own fresh ADR under the New Class B
|
||||||
|
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
|
||||||
|
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
|
||||||
|
authority, and this amendment records it explicitly.
|
||||||
|
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
|
||||||
|
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
|
||||||
|
`ALIGNMENT.md`'s Class B citation requirement.
|
||||||
|
|
||||||
|
### `OCP_TUI_MAX_CONCURRENT` summary
|
||||||
|
|
||||||
|
| Env var | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication + defunct-reaping (PR-C amendment)
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Accepted — amends ADR 0007.
|
||||||
|
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
|
||||||
|
|
||||||
|
### How the TUI `claude` authenticates
|
||||||
|
|
||||||
|
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
|
||||||
|
|
||||||
|
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
|
||||||
|
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
|
||||||
|
|
||||||
|
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
|
||||||
|
|
||||||
|
### Why the fallback path corrupts (the PI231 incident)
|
||||||
|
|
||||||
|
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
|
||||||
|
|
||||||
|
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
|
||||||
|
|
||||||
|
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
|
||||||
|
|
||||||
|
### Defunct `<claude>` reaping
|
||||||
|
|
||||||
|
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
|
||||||
|
|
||||||
|
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
|
||||||
|
|
||||||
|
### ALIGNMENT authorization (Class B)
|
||||||
|
|
||||||
|
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential-isolated home for env-token auth (PR-D amendment)
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
|
||||||
|
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
|
||||||
|
|
||||||
|
| Condition | Result |
|
||||||
|
|---|---|
|
||||||
|
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
|
||||||
|
| env token passed + `credentials.json` moved aside | **works** (real answer) |
|
||||||
|
|
||||||
|
### Corrected root cause
|
||||||
|
|
||||||
|
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
|
||||||
|
|
||||||
|
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
|
||||||
|
|
||||||
|
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
|
||||||
|
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
|
||||||
|
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
|
||||||
|
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
|
||||||
|
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
|
||||||
|
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
|
||||||
|
|
||||||
|
### This RESOLVES — not reintroduces — the scratch-home caveat
|
||||||
|
|
||||||
|
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
|
||||||
|
|
||||||
|
### ALIGNMENT authorization (Class B)
|
||||||
|
|
||||||
|
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
|
||||||
|
- Kill-switch is immediate (unset env var + restart); zero code change required.
|
||||||
|
- Default stream-json path is untouched — no regression risk for existing deployments.
|
||||||
|
|
||||||
|
### Negative / trade-offs
|
||||||
|
|
||||||
|
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
|
||||||
|
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
|
||||||
|
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
|
||||||
|
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
|
||||||
|
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
|
||||||
|
|
||||||
|
### Coexistence
|
||||||
|
|
||||||
|
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# TUI-mode latency: measured floor, and the four things worth fixing
|
||||||
|
|
||||||
|
**Date**: 2026-07-13
|
||||||
|
**Status**: findings + backlog. **Superseded in part** — see the dated update boxes below.
|
||||||
|
Item #1 shipped ([#156](https://github.com/dtzp555-max/ocp/pull/156)); item #2 is **dead**
|
||||||
|
([`streaming-spike.md`](streaming-spike.md)); item #4 measured, **no effect**; item #3 stands.
|
||||||
|
**Measured on**: Mac mini / macOS 26.5.2 / Claude Code **v2.1.207** / Sonnet 5 / Claude Max subscription / **real-home mode** (no `CLAUDE_CODE_OAUTH_TOKEN`, no `OCP_TUI_HOME` in the service env)
|
||||||
|
**Evidence**: [`measurements.jsonl`](measurements.jsonl) — **n=15** (3 configs × 5) · banner captures [`billing-banner.txt`](billing-banner.txt) · harness [`floor.sh`](floor.sh)
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
An external consumer (the 知音 AI project) benchmarked OCP's prompt path and measured
|
||||||
|
**TTFT p50 ≈ 30–32 s**, and excluded OCP as a backend on that basis. That number is real,
|
||||||
|
but it is *not* the model being slow — this document decomposes where the 30 seconds
|
||||||
|
actually go, and what OCP can do about it.
|
||||||
|
|
||||||
|
**The harness deliberately does not go through OCP.** It spawns `tmux` + `claude` directly
|
||||||
|
(session prefix `zhiyin-floor-`, never `ocp-tui-*`) and polls `tmux capture-pane` for
|
||||||
|
incremental render, so it measures the **true first-token time** of the underlying
|
||||||
|
subscription path — the floor OCP could reach if it were perfect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
All rows in [`measurements.jsonl`](measurements.jsonl); every number below is recomputable from it.
|
||||||
|
|
||||||
|
| Config | n | boot→input-ready (median) | **TTFT (median)** | TTFT range | full answer (median) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| baseline (inherits global `effortLevel: xhigh`) | 5 | 1.07 s | **10.35 s** | 8.32 – 17.19 s | 11.32 s |
|
||||||
|
| **`--effort low`** | 5 | 1.03 s | **6.17 s** | **5.87 – 6.44 s** | 9.98 s |
|
||||||
|
| `--bare` | 5 | 0.44 s | **no answer at all** (5/5 `ttft_ms: -1`) | — | — |
|
||||||
|
|
||||||
|
> **Not from this harness**: the direct Anthropic API reference figure (TTFT 0.84–1.64 s, n=2)
|
||||||
|
> comes from the 知音 AI project's own smoke test, not from `measurements.jsonl`. It is quoted
|
||||||
|
> only to size the gap; do not look for it in the evidence file.
|
||||||
|
|
||||||
|
### Where the 30 seconds go
|
||||||
|
|
||||||
|
```
|
||||||
|
~1.0 s spawn → claude's input bar is ready ← NOT the bottleneck
|
||||||
|
~6-10 s true TTFT (first token rendered in the pane)
|
||||||
|
~20 s ████ waiting for the whole turn to finish ████ ← this is the 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
`runTuiTurn` blocks on the native transcript until a terminal event (`lib/tui/session.mjs`
|
||||||
|
"Block on the native transcript … until terminal"; `readTuiTranscript` in
|
||||||
|
`lib/tui/transcript.mjs`; ADR 0007 step 4) — i.e. it waits for the **entire turn** to complete
|
||||||
|
before returning anything. There is no streaming path. The ~20 s delta between this harness's
|
||||||
|
real TTFT and OCP's reported 30–32 s is exactly that.
|
||||||
|
|
||||||
|
> **⚠️ 2026-07-13 correction — this decomposition attributes the ~20 s to the wrong thing.** It was
|
||||||
|
> inferred from the external 30–32 s report, never measured *through* OCP. It has since been measured
|
||||||
|
> through a real OCP instance (TUI mode, `claude-sonnet-4-6`, the same ~1850-token prompt, n=5):
|
||||||
|
> **median 11.30 s** before [#156](https://github.com/dtzp555-max/ocp/pull/156), **9.55 s** after.
|
||||||
|
> Same-turn decomposition (baseline row `i=5`): **11.563 s** wall through OCP vs `turn_duration:
|
||||||
|
> 7.319 s` of CLI-internal time on that same turn → **OCP's own overhead ≈ 4.2 s** (n=1), **not
|
||||||
|
> ~20 s**. The rest of any larger number is the model *generating a long answer*,
|
||||||
|
> which the blocking wait does not cause and streaming would not shorten — it would only move the
|
||||||
|
> first byte earlier. The 30–32 s figure therefore reflects a much longer output (and/or the
|
||||||
|
> then-inherited `xhigh` effort), not 20 s of OCP dead time. See
|
||||||
|
> [`streaming-spike.md`](streaming-spike.md) § "What streaming would have bought".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Blocking constraint: `--bare` silently drops you off the subscription pool
|
||||||
|
|
||||||
|
Captured live ([`billing-banner.txt`](billing-banner.txt)) — the startup banner is the **only**
|
||||||
|
reliable indicator:
|
||||||
|
|
||||||
|
```
|
||||||
|
[] | Sonnet 5 with xhigh effort · Claude Max
|
||||||
|
[--effort low] | Sonnet 5 with low effort · Claude Max
|
||||||
|
[--bare] | Sonnet 5 with xhigh effort · API Usage Billing ← ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
`--bare` ("skip hooks, LSP, plugin…") **also skips the subscription-credential resolution
|
||||||
|
path**. It really does cut boot to 0.43–0.45 s — but you are no longer on the subscription,
|
||||||
|
which defeats the entire purpose of TUI mode (ADR 0007 exists solely to reach the
|
||||||
|
subscription pool).
|
||||||
|
|
||||||
|
**The failure is silent.** All 5 `--bare` samples reached input-ready (boot 0.43–0.45 s), were
|
||||||
|
sent the prompt, and then produced **no answer at all** — 60 s timeout, no error, no crash, the
|
||||||
|
pane simply never rendered a token (the API-billing account had no credit balance). Nothing in
|
||||||
|
the transcript or the exit status reveals this.
|
||||||
|
|
||||||
|
**Anyone changing spawn flags must diff the banner line before and after.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog — four items, ranked by value ÷ effort
|
||||||
|
|
||||||
|
### 1. Pass `--effort` explicitly on spawn — **do this first**
|
||||||
|
|
||||||
|
`buildTuiCmd` (`lib/tui/session.mjs`) does not pass `--effort` — `grep -rn -- "--effort\|effortLevel" lib/ server.mjs`
|
||||||
|
returns zero hits. What the pane's `claude` ends up using therefore depends on **which HOME mode
|
||||||
|
`resolveTuiHome()` picked**:
|
||||||
|
|
||||||
|
| mode | HOME | effort the pane gets |
|
||||||
|
|---|---|---|
|
||||||
|
| **real-home** (legacy default — *current* service config: no `CLAUDE_CODE_OAUTH_TOKEN`, no `OCP_TUI_HOME`) | `~` | **inherits the operator's `~/.claude/settings.json` → `effortLevel: xhigh` on this host** |
|
||||||
|
| env-token scratch (`CLAUDE_CODE_OAUTH_TOKEN` set — the direction #146/#150 pushed) | `~/.ocp-tui/home` | that settings.json contains only `permissions.additionalDirectories`; `prepareTuiHome()` never writes `effortLevel` → **claude's built-in default** |
|
||||||
|
|
||||||
|
**Scope note**: TUI mode is currently *off* on this host (`CLAUDE_TUI_MODE=false`; `/health` →
|
||||||
|
`"tui": {"enabled": false}`), so live traffic takes the `-p` path today. The statement below is
|
||||||
|
about what happens **when TUI mode is enabled**.
|
||||||
|
|
||||||
|
On the current HOME config, **every TUI request would run extended thinking** — pure waste
|
||||||
|
for the typical "generate this JSON" request, and it makes latency depend on an unrelated global
|
||||||
|
setting the operator may have changed for their own interactive use. And the mode split means
|
||||||
|
the effort level silently changes if the operator ever switches to env-token mode.
|
||||||
|
**Passing `--effort` explicitly fixes both problems at once.**
|
||||||
|
|
||||||
|
- **Effect (real-home, measured)**: TTFT p50 **10.35 s → 6.17 s (−40 %)**, and the spread
|
||||||
|
collapses from 8.32–17.19 s to **5.87–6.44 s**. For a proxy, the variance reduction matters
|
||||||
|
more than the median.
|
||||||
|
- **Cost**: one flag. Suggested: a new `OCP_TUI_EFFORT` env var (default `low`), documented in
|
||||||
|
README § "Environment Variables" per `release_kit.new_feature_doc_expectations`.
|
||||||
|
- **Risk**: none — banner confirms it stays on `Claude Max` (see `billing-banner.txt`).
|
||||||
|
- ⚠️ Do **not** reach for `--bare` to shave boot: see above.
|
||||||
|
|
||||||
|
### 2. Real streaming instead of blocking on turn-terminal — **ACHIEVABLE → [`streaming-spike.md`](streaming-spike.md)**
|
||||||
|
|
||||||
|
> **2026-07-13 update — the prereq spike was run. The answer is YES, but not from either source this
|
||||||
|
> item guessed at.** (a) The transcript grows at *event* granularity (the whole answer lands in one
|
||||||
|
> line, ~0.3 s before terminal) — dead. (b) The pane is a **rendered** view whose `capture-pane` text
|
||||||
|
> no longer contains the answer's source bytes (`## `, `**`, code fences are gone) — dead, and worse
|
||||||
|
> than "lossy": it is *not the model's text*. **But there is a third source neither this backlog nor
|
||||||
|
> the first spike considered: `claude` fires a `MessageDisplay` hook carrying incremental,
|
||||||
|
> byte-faithful `delta`s of the raw reply.** Verified live on a plain interactive TUI spawn (no `-p`),
|
||||||
|
> banner `· Claude Max`: 7 fires spread across generation, `concat(deltas) === T` **byte-exactly**
|
||||||
|
> (579 == 579), `T.startsWith(S)` true at every step, `## ` / `**` / ```` ```javascript ```` all
|
||||||
|
> present in the deltas. Granularity is block-level (~5–7 chunks/answer), not token-level — plenty for
|
||||||
|
> SSE. **Build it.**
|
||||||
|
>
|
||||||
|
> ⚠️ Two corrections to this item as written: the **"~20 s" is wrong** (inferred from an external
|
||||||
|
> report, never measured through OCP — the same-turn decomposition puts OCP's own overhead at **~4 s**,
|
||||||
|
> n=1), and **streaming moves the first byte, not the last** — so a consumer needing the *complete*
|
||||||
|
> answer (the JSON-card case that motivated this) gains **nothing** from it. Build it for
|
||||||
|
> progressively-rendering consumers, not as a throughput win.
|
||||||
|
>
|
||||||
|
> Full evidence + implementer caveats (the hook is `forceSyncExecution` — claude BLOCKS on it):
|
||||||
|
> **[`streaming-spike.md`](streaming-spike.md)**. Original framing preserved below.
|
||||||
|
|
||||||
|
Today `runTuiTurn` blocks on the transcript until the turn is *finished*. The pane is already
|
||||||
|
rendering tokens incrementally the whole time — this harness proves you can observe first token
|
||||||
|
at ~6 s by polling `tmux capture-pane`.
|
||||||
|
|
||||||
|
- **Effect**: turns a 30 s wall into a ~6 s TTFT with progressive output; enables SSE streaming
|
||||||
|
on the OCP endpoint instead of a single blob at the end.
|
||||||
|
- **Cost**: real work. Pane capture is ANSI/redraw-based and lossy for exact text (wrapping,
|
||||||
|
scrollback, spinner lines). Two candidate sources: (a) incremental reads of the transcript
|
||||||
|
JSONL, (b) `capture-pane` diffing with a stable start marker. (a) is much cleaner **if it
|
||||||
|
holds**.
|
||||||
|
- **Prereq spike (do this before designing anything)**: does the transcript JSONL grow *during*
|
||||||
|
a turn, or only at the end? If only at the end, (a) is dead and you are stuck with (b).
|
||||||
|
|
||||||
|
### 3. Warm pane pool — ~1 s
|
||||||
|
|
||||||
|
Every request spawns a fresh tmux session + `claude` (`randomUUID()` + `new-session`, then
|
||||||
|
`kill-session` in `finally`; `grep -rn "pool\|warm\|reuse" lib/tui/*.mjs` → zero hits). Boot to
|
||||||
|
input-ready is ~1.0 s, paid on every request. A pool of pre-booted panes (single-use, replaced in
|
||||||
|
the background) amortizes it to zero for any workload below the pool refill rate.
|
||||||
|
|
||||||
|
- **Effect**: −1.0 s.
|
||||||
|
- **Cost**: moderate; interacts with the session reaper and the per-port prefix scoping added in
|
||||||
|
#148 — pooled panes must not look like zombies to the sweep.
|
||||||
|
- Lower priority than #1 and #2: it is the smallest slice.
|
||||||
|
|
||||||
|
### 4. Trim the prefill — ~~probably not worth it~~ **MEASURED: no detectable benefit. Do not adopt.**
|
||||||
|
|
||||||
|
> **2026-07-13 update.** `--exclude-dynamic-system-prompt-sections` was measured with the same
|
||||||
|
> harness (`floor.sh`, n=5, Sonnet 5, on top of `--effort low`): **TTFT median 6.39 s**
|
||||||
|
> (5.87–10.54 s) vs **6.17 s** (5.87–6.44 s) for `--effort low` alone — i.e. **0.22 s worse, inside
|
||||||
|
> the noise band**, with one worse outlier; dropping that outlier does not change the verdict. n=5
|
||||||
|
> cannot prove "zero", only "no benefit detectable above noise" — but there is also a **mechanistic**
|
||||||
|
> reason not to expect one: `--help` says the flag *"Improves cross-user prompt-cache **reuse**"*, and
|
||||||
|
> **OCP is single-user** — there is no cross-user cache to share, so the flag has nothing to buy here.
|
||||||
|
> The banner stayed on `· Claude Max` (no billing-pool drop), but there is no win to bank. The ~6 s
|
||||||
|
> floor stands as stated below. Raw rows: [`prefill-spike-measurements.jsonl`](prefill-spike-measurements.jsonl).
|
||||||
|
|
||||||
|
|
||||||
|
After #1–#3, the floor is **~6 s**, and it does not go lower. `claude` always injects the full
|
||||||
|
Claude Code system prompt + tool definitions (thousands to tens of thousands of prefill tokens)
|
||||||
|
regardless of what you ask it. `--exclude-dynamic-system-prompt-sections` exists and may shave
|
||||||
|
some of it — **unmeasured**; worth one spike, but do not expect to reach the direct API's
|
||||||
|
~1 s.
|
||||||
|
|
||||||
|
**Consequence to accept, and to state in the README**: even fully optimized, TUI mode has a
|
||||||
|
**~6 s TTFT floor**, so it cannot serve real-time / interactive-latency consumers. It remains
|
||||||
|
appropriate for batch, background, and cost-insensitive-latency use. The 知音 AI project
|
||||||
|
excluded it on this basis (their prompt-latency budget is 2–4 s) *independently* of the ToS
|
||||||
|
question already documented in the README.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# harness never touches OCP's :3456 service or ocp-tui-* sessions, and never kill-server
|
||||||
|
bash docs/plans/2026-07-13-tui-latency/floor.sh 5 # baseline
|
||||||
|
TAG=effort-low EXTRA_ARGS="--effort low" bash .../floor.sh 5 # −40 %
|
||||||
|
TAG=bare EXTRA_ARGS="--bare" bash .../floor.sh 5 # the trap
|
||||||
|
|
||||||
|
# billing-pool check for ANY spawn-flag change — the banner is the only source of truth
|
||||||
|
tmux new-session -d -s probe -x 200 -y 50 -c "$HOME" \
|
||||||
|
"claude --model claude-sonnet-5 --session-id $(uuidgen) <your-flags-here>"
|
||||||
|
sleep 6; tmux capture-pane -p -t probe | grep -E "Claude Max|API Usage Billing"
|
||||||
|
tmux kill-session -t probe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interaction with OCP while the harness runs
|
||||||
|
|
||||||
|
- **Kill direction is safe both ways**: `reapStaleTuiSessions()` only `kill-session`s names
|
||||||
|
matching `ocp-tui-<port>-`, which `zhiyin-floor-*` never matches; and the harness only
|
||||||
|
`kill-session`s its own single session — it contains **no `kill-server`**.
|
||||||
|
- **One benign interaction** (only when TUI mode is enabled — the reap tick is itself gated on
|
||||||
|
`TUI_MODE`): OCP's periodic `kill-server` (zombie reaping) is gated on
|
||||||
|
`othersRemain` — *any* foreign-prefixed tmux session suppresses it. So while the harness is
|
||||||
|
running, that sweep is skipped. This is the coexistence guard working as designed; it resumes
|
||||||
|
on the next tick.
|
||||||
|
|
||||||
|
## Harness caveats (stated so the numbers are not over-trusted)
|
||||||
|
|
||||||
|
- **n=5 per config**, single host, single model (Sonnet 5), single prompt size (~1850 tokens).
|
||||||
|
Enough to separate 6 s from 10 s from 30 s; **not** enough for a p95.
|
||||||
|
- TTFT is "marker visible in `capture-pane`", which includes tmux render latency (small, but
|
||||||
|
nonzero) — it is an upper bound on the true first-token time.
|
||||||
|
- **The harness's readiness marker is not OCP's.** `floor.sh` waits for `│ >|❯|Try "`; OCP's
|
||||||
|
`tuiInputReady()` matches `/\? for shortcuts/`. These are different events, so the ~1.0 s
|
||||||
|
boot figure is **not** directly comparable to OCP's `BOOT_MS` gate (default cap 4000 ms). It
|
||||||
|
does not affect the conclusions (1 s ≪ 6 s TTFT), but it is not apples-to-apples.
|
||||||
|
- The first version of this harness reported TTFT **0.08 s** — a false positive: the prompt
|
||||||
|
literally contained the marker string it was grepping for, so the match fired the instant the
|
||||||
|
prompt was pasted. Fixed by describing the marker instead of spelling it. **The script exited 0
|
||||||
|
and "successfully" produced 5 samples both times** — exit status proves nothing here.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[] | ▝▜█████▛▘ Sonnet 5 with xhigh effort · Claude Max
|
||||||
|
[--effort low] | ▝▜█████▛▘ Sonnet 5 with low effort · Claude Max
|
||||||
|
[--bare] | ▝▜█████▛▘ Sonnet 5 with xhigh effort · API Usage Billing
|
||||||
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OCP TUI-mode latency floor harness — see README.md in this directory.
|
||||||
|
#
|
||||||
|
# 目的:回答一个问题——如果把 OCP 现有的两个已知开销砍掉
|
||||||
|
# (a) 每请求 spawn + boot(可用预热进程池消除)
|
||||||
|
# (b) 假流式(等 turn_duration 才返回,可用增量读 pane 消除)
|
||||||
|
# 之后,订阅池路径的**真实 TTFT 地板**是多少?
|
||||||
|
#
|
||||||
|
# 判据:地板 ≤ 4s → OCP 作为"省钱选项"可行;> 8s → 死透,不再讨论。
|
||||||
|
#
|
||||||
|
# 红线:
|
||||||
|
# - 不经过生产 OCP 服务(:3456)—— 直接起 tmux+claude,OCP 进程零干扰
|
||||||
|
# - tmux session 前缀用 zhiyin-floor-(**不是** ocp-tui-),避免被 OCP 的
|
||||||
|
# reaper 当成自己的会话杀掉,也避免我们杀到它的
|
||||||
|
# - 用 real HOME(凭据)—— scratch HOME + symlink 凭据会 fork OAuth 导致 401
|
||||||
|
# (见跨机记忆 tui_scratch_home_credential_fork)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
N=${1:-5}
|
||||||
|
MODEL=${MODEL:-claude-sonnet-5}
|
||||||
|
EXTRA_ARGS=${EXTRA_ARGS:-} # 额外 CLI 参数(如 --effort low --bare)
|
||||||
|
TAG=${TAG:-baseline}
|
||||||
|
OUT=${OUT:-$(dirname "$0")/measurements.jsonl}
|
||||||
|
PROMPT_FILE=$(mktemp)
|
||||||
|
PREFIX="zhiyin-floor"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
|
||||||
|
# ── 构造提示:~2000 token 的假会议转写 + 明确的起始标记 ────────────────
|
||||||
|
# 单行(多行会在 tmux send-keys 时提前触发 Enter)
|
||||||
|
build_prompt() {
|
||||||
|
local seg="Speaker A said the quarterly pipeline is tracking behind plan and the enterprise segment needs a different motion. Speaker B replied that the current onboarding flow loses roughly a third of trial accounts before the first integration is complete. They debated whether the fix belongs in product or in customer success. "
|
||||||
|
local body=""
|
||||||
|
for _ in $(seq 1 22); do body+="$seg"; done
|
||||||
|
printf '%s' "You are a real-time meeting copilot. Meeting transcript so far: $body --- Task: produce ONE prompt card as compact JSON with keys: points (array of 3 short Chinese bullet points), keyline (one English sentence the user can read aloud). IMPORTANT: your reply MUST begin with three hash characters immediately followed by the uppercase word CARD (no space between them), then the JSON. No preamble, no markdown fences." > "$PROMPT_FILE"
|
||||||
|
}
|
||||||
|
build_prompt
|
||||||
|
PROMPT_CHARS=$(wc -c < "$PROMPT_FILE" | tr -d ' ')
|
||||||
|
|
||||||
|
now_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
||||||
|
|
||||||
|
echo "配置: $TAG 参数: [$EXTRA_ARGS]"
|
||||||
|
echo "模型: $MODEL 样本: $N 提示长度: ${PROMPT_CHARS} chars (≈$((PROMPT_CHARS/4)) token)"
|
||||||
|
echo "输出: $OUT"
|
||||||
|
echo
|
||||||
|
|
||||||
|
for i in $(seq 1 "$N"); do
|
||||||
|
SESS="${PREFIX}-$$-$i"
|
||||||
|
SID=$(uuidgen)
|
||||||
|
|
||||||
|
# ── 冷启动:spawn + 等输入框就绪 ─────────────────────────────────
|
||||||
|
T_SPAWN=$(now_ms)
|
||||||
|
tmux new-session -d -s "$SESS" -x 200 -y 50 \
|
||||||
|
-e CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 \
|
||||||
|
-e CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 \
|
||||||
|
-c "$HOME" \
|
||||||
|
"claude --model $MODEL --session-id $SID --strict-mcp-config --disallowedTools 'mcp__*' $EXTRA_ARGS" 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then echo "[$i] tmux spawn 失败,跳过"; continue; fi
|
||||||
|
|
||||||
|
# 轮询输入框就绪(claude TUI 的输入提示符)
|
||||||
|
READY=0
|
||||||
|
for _ in $(seq 1 150); do # 上限 15s
|
||||||
|
PANE=$(tmux capture-pane -p -t "$SESS" 2>/dev/null || true)
|
||||||
|
if grep -qE '│ >|❯|Try "' <<<"$PANE"; then READY=1; break; fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
T_READY=$(now_ms)
|
||||||
|
BOOT_MS=$((T_READY - T_SPAWN))
|
||||||
|
if [ "$READY" -ne 1 ]; then
|
||||||
|
echo "[$i] 启动超时(${BOOT_MS}ms),pane 末 3 行:"
|
||||||
|
tmux capture-pane -p -t "$SESS" 2>/dev/null | tail -3 | sed 's/^/ /'
|
||||||
|
tmux kill-session -t "$SESS" 2>/dev/null
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 热态:粘提示 → 回车 → 量首 token ─────────────────────────────
|
||||||
|
tmux send-keys -t "$SESS" -l "$(cat "$PROMPT_FILE")" 2>/dev/null
|
||||||
|
sleep 0.4 # 让粘贴落地(OCP 用 400ms 轮询粒度)
|
||||||
|
T0=$(now_ms)
|
||||||
|
tmux send-keys -t "$SESS" Enter 2>/dev/null
|
||||||
|
|
||||||
|
TTFT_MS=-1
|
||||||
|
for _ in $(seq 1 600); do # 上限 60s
|
||||||
|
if tmux capture-pane -p -t "$SESS" 2>/dev/null | grep -q '###CARD'; then
|
||||||
|
TTFT_MS=$(( $(now_ms) - T0 )); break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 完整回答:pane 连续 2s 不再变化 ──────────────────────────────
|
||||||
|
COMPLETE_MS=-1
|
||||||
|
if [ "$TTFT_MS" -ge 0 ]; then
|
||||||
|
LAST=""; STABLE=0
|
||||||
|
for _ in $(seq 1 900); do # 上限 90s
|
||||||
|
CUR=$(tmux capture-pane -p -t "$SESS" 2>/dev/null | cksum)
|
||||||
|
if [ "$CUR" = "$LAST" ]; then
|
||||||
|
STABLE=$((STABLE+1))
|
||||||
|
[ "$STABLE" -ge 20 ] && { COMPLETE_MS=$(( $(now_ms) - T0 - 2000 )); break; }
|
||||||
|
else
|
||||||
|
STABLE=0; LAST="$CUR"
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"i":%d,"tag":"%s","model":"%s","extra_args":"%s","prompt_chars":%s,"boot_ms":%d,"ttft_ms":%d,"complete_ms":%d}\n' \
|
||||||
|
"$i" "$TAG" "$MODEL" "$EXTRA_ARGS" "$PROMPT_CHARS" "$BOOT_MS" "$TTFT_MS" "$COMPLETE_MS" | tee -a "$OUT"
|
||||||
|
|
||||||
|
tmux kill-session -t "$SESS" 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -f "$PROMPT_FILE"
|
||||||
|
echo
|
||||||
|
echo "=== 汇总 ==="
|
||||||
|
python3 - "$OUT" <<'EOF'
|
||||||
|
import json,sys,statistics
|
||||||
|
rows=[json.loads(l) for l in open(sys.argv[1]) if l.strip()]
|
||||||
|
ok=[r for r in rows if r['ttft_ms']>=0]
|
||||||
|
if not ok: print("无有效样本"); sys.exit()
|
||||||
|
def s(k):
|
||||||
|
v=[r[k] for r in ok if r[k]>=0]
|
||||||
|
return f"n={len(v)} 中位={statistics.median(v)/1000:.2f}s 最小={min(v)/1000:.2f}s 最大={max(v)/1000:.2f}s" if v else "无"
|
||||||
|
print(f" 冷启动 boot : {s('boot_ms')} ← 预热进程池可完全消除")
|
||||||
|
print(f" TTFT(首 token) : {s('ttft_ms')} ★ 这就是地板")
|
||||||
|
print(f" 完整回答 : {s('complete_ms')}")
|
||||||
|
print(f"\n 失败样本: {len(rows)-len(ok)}/{len(rows)}")
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{"i": 1, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1077, "ttft_ms": 6172, "complete_ms": 9929}
|
||||||
|
{"i": 2, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1026, "ttft_ms": 6160, "complete_ms": 9996}
|
||||||
|
{"i": 3, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1010, "ttft_ms": 6437, "complete_ms": 9977}
|
||||||
|
{"i": 4, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1033, "ttft_ms": 5872, "complete_ms": 9944}
|
||||||
|
{"i": 5, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1154, "ttft_ms": 6387, "complete_ms": 9993}
|
||||||
|
{"i":1,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1300,"ttft_ms":8321,"complete_ms":9939}
|
||||||
|
{"i":2,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1070,"ttft_ms":10347,"complete_ms":11320}
|
||||||
|
{"i":3,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":911,"ttft_ms":13061,"complete_ms":15163}
|
||||||
|
{"i":4,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1441,"ttft_ms":9981,"complete_ms":11066}
|
||||||
|
{"i":5,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1036,"ttft_ms":17189,"complete_ms":17985}
|
||||||
|
{"i":1,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":429,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":2,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":437,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":3,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":444,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":4,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":446,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":5,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":441,"ttft_ms":-1,"complete_ms":-1}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{"hook_event_name": "MessageDisplay", "index": 0, "final": false, "delta": "## Mutex\n\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 1, "final": false, "delta": "A **mutual exclusion lock** prevents concurrent access to a shared resource, ensuring only one thread runs the critical section at a time.\n\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 2, "final": false, "delta": "- Acquiring a locked mutex blocks the caller until the current holder releases it.\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 3, "final": false, "delta": "- Failing to release a mutex causes a deadlock, freezing all waiting threads.\n\n```javascript\nconst { Mutex } = require('async-mutex');\n\nconst mutex = new Mutex();\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 4, "final": false, "delta": "let counter = 0;\n\nasync function increment() {\n const release = await mutex.acquire();\n try {\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 5, "final": false, "delta": " counter++; // only one caller here at a time\n } finally {\n release();\n }\n}\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 6, "final": true, "delta": "```"}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"i":1,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":934,"ttft_ms":5867,"complete_ms":9953}
|
||||||
|
{"i":2,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1275,"ttft_ms":6388,"complete_ms":9874}
|
||||||
|
{"i":3,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":874,"ttft_ms":10537,"complete_ms":11782}
|
||||||
|
{"i":4,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1170,"ttft_ms":6379,"complete_ms":9947}
|
||||||
|
{"i":5,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1329,"ttft_ms":6443,"complete_ms":9884}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Backlog #2 (real streaming): **achievable** — via the `MessageDisplay` hook
|
||||||
|
|
||||||
|
**Date**: 2026-07-13
|
||||||
|
**Status**: prereq-spike result. **Streaming IS achievable on the TUI path**, byte-faithfully, on the
|
||||||
|
subscription pool. Three obvious sources are dead ends; a fourth one works.
|
||||||
|
**Scope**: answers the prereq spike that [`README.md`](README.md) § "Backlog #2" demanded *before* any
|
||||||
|
streaming design:
|
||||||
|
|
||||||
|
> **Prereq spike (do this before designing anything)**: does the transcript JSONL grow *during* a
|
||||||
|
> turn, or only at the end? If only at the end, (a) is dead and you are stuck with (b).
|
||||||
|
|
||||||
|
The answer: **(a) is dead, (b) is dead — and you are not stuck with either.** The CLI exposes its own
|
||||||
|
streaming interface as a **hook**, which the backlog did not consider.
|
||||||
|
|
||||||
|
**Measured on**: Mac mini / Claude Code **v2.1.207** / Sonnet 4.6 + Sonnet 5 / Claude Max /
|
||||||
|
real-home mode. Every claim below is reproducible from the commands given.
|
||||||
|
|
||||||
|
> **Honesty note on how this document was produced.** Its first version concluded the exact opposite —
|
||||||
|
> "streaming is not achievable; the CLI exposes no byte-faithful incremental source" — and was **wrong**.
|
||||||
|
> An adversarial reviewer, commissioned specifically to *refute* it, found `MessageDisplay` on a second
|
||||||
|
> pass; its own first pass had enumerated the hook registry with a truncated grep (it reported 21
|
||||||
|
> events — there are **30**). Both the wrong conclusion and its refutation are preserved here, because
|
||||||
|
> "we checked, it's impossible" is the most expensive kind of claim to get wrong: it closes a door and
|
||||||
|
> nobody re-opens it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ The source that works: the `MessageDisplay` hook
|
||||||
|
|
||||||
|
`claude` fires a **`MessageDisplay`** hook as it renders each block of the assistant's reply. The
|
||||||
|
payload carries the **raw markdown source** of an incremental `delta`, plus a monotonic `index` and a
|
||||||
|
`final` flag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "hook_event_name": "MessageDisplay",
|
||||||
|
"turn_id": "6cb31d21-…", "message_id": "84ab9832-…",
|
||||||
|
"index": 0, "final": false, "delta": "## Mutex\n\n" }
|
||||||
|
```
|
||||||
|
*(payload also carries `session_id`, `transcript_path`, `prompt_id`, `cwd`)*
|
||||||
|
|
||||||
|
Registered as an ordinary command hook via `--settings` on a **plain interactive TUI spawn** (no `-p`,
|
||||||
|
no `--bare`), `claude-sonnet-4-6`, `--effort low`. Banner verified:
|
||||||
|
`▝▜█████▛▘ Sonnet 4.6 with low effort · Claude Max` — **subscription pool, not metered billing**.
|
||||||
|
|
||||||
|
One live turn — 7 fires, spread across generation:
|
||||||
|
|
||||||
|
```
|
||||||
|
index=0 final=false len= 10 '## Mutex\n\n'
|
||||||
|
index=1 final=false len= 140 'A **mutual exclusion lock** prevents concurrent access to a shar…'
|
||||||
|
index=2 final=false len= 83 '- Acquiring a locked mutex blocks the caller until the current h…'
|
||||||
|
index=3 final=false len= 163 '- Failing to release a mutex causes a deadlock, freezing all wai…'
|
||||||
|
index=4 final=false len= 96 'let counter = 0;\n\nasync function increment() {\n const release =…'
|
||||||
|
index=5 final=false len= 84 ' counter++; // only one caller here at a time\n } finally {\n …'
|
||||||
|
index=6 final=true len= 3 '```'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every invariant a proxy needs — all hold:**
|
||||||
|
|
||||||
|
| requirement | result |
|
||||||
|
|---|---|
|
||||||
|
| **byte-faithful** — deltas are the model's *source*, not the rendered pane | ✅ `## `, `**`, ```` ```javascript ```` all present in the deltas |
|
||||||
|
| **exactness** — `concat(deltas) === T` (the transcript-authoritative text) | ✅ **true**, 579 == 579 bytes |
|
||||||
|
| **prefix-stable** — `T.startsWith(concat(deltas[0..n]))` at every n | ✅ **true at all 7 steps** |
|
||||||
|
| **incremental** — arrives during generation, not at the end | ✅ 7 fires spread across the turn |
|
||||||
|
| **no `-p`** — stays out of the metered `sdk-cli` pool | ✅ plain interactive TUI |
|
||||||
|
| **subscription pool** | ✅ banner `· Claude Max` |
|
||||||
|
|
||||||
|
This is exactly the contract a streaming design needs: deltas forward straight into SSE
|
||||||
|
`delta.content` chunks, and the transcript's final text `T` stays a cheap end-of-turn assertion
|
||||||
|
(`concat === T`) instead of a reconciliation problem.
|
||||||
|
|
||||||
|
### Caveats for the implementer
|
||||||
|
|
||||||
|
- **Block-level granularity, not token-level** — the hook fires **once per rendered block** (roughly one
|
||||||
|
per paragraph / list item / code block), so the chunk count **scales with answer length**: 7 fires for a
|
||||||
|
~600-byte answer, **18 for a ~2 KB one**. Plenty for SSE (`delta.content` has no minimum size), but do
|
||||||
|
not promise token-by-token output, and do not hard-code any assumption about chunk count.
|
||||||
|
- **🔴 The sink MUST be keyed by `session_id` — this is live TODAY, not a future concern.**
|
||||||
|
`OCP_TUI_MAX_CONCURRENT` defaults to **2**, so **two `claude` processes already run concurrently**. One
|
||||||
|
hook command writing to one shared sink would **interleave deltas from two different turns into one
|
||||||
|
stream** — request A's client receiving request B's text, the worst failure a proxy can have, and one a
|
||||||
|
single-request test will never surface. The payload carries `session_id` (and `turn_id` / `message_id`),
|
||||||
|
so demux is easy: derive the sink path from `session_id` (`<dir>/<session_id>.jsonl`) and read only your
|
||||||
|
own turn's file. This *also* keeps the design **warm-pool compatible**, because a pre-booted pane's
|
||||||
|
session-id is fixed at boot — one static hook script serves every pane. **Test it with ≥2 concurrent
|
||||||
|
streaming requests carrying distinguishable prompts and assert zero cross-contamination.**
|
||||||
|
- **⚠️ `forceSyncExecution: true` in the hook's source — `claude` BLOCKS on the hook.** A slow hook
|
||||||
|
adds latency to *every* delta. The hook must write and exit immediately (e.g. write to a FIFO / unix
|
||||||
|
socket that OCP reads; never work inline). **Measure the added per-delta latency.**
|
||||||
|
- **Thinking blocks appear to be excluded — but this is NOT yet stress-tested. Verify before shipping.**
|
||||||
|
The exclusion is inferred from `content.map(c => c.type === "text" ? c.text : "")` — but that snippet is
|
||||||
|
from the **`final:true`** call site, not the incremental one. Four live turns (incl. two at `--effort
|
||||||
|
high`) showed no thinking text in any delta and `concat === T` held — **but each transcript's thinking
|
||||||
|
block was empty (`thinking:""`, 0 chars)**, so the exclusion was never actually stressed. **The failure
|
||||||
|
mode is severe**: if thinking deltas *do* fire on some config (Opus, `xhigh`), `concat(deltas) !== T`
|
||||||
|
**and OCP streams the model's private reasoning to the caller**. The end-of-turn `concat === T` assertion
|
||||||
|
would *detect* that but **cannot prevent** it — SSE deltas cannot be un-sent. **Before shipping, run a
|
||||||
|
turn on a model+effort that produces substantive thinking** (a hard reasoning prompt on Opus / `xhigh`)
|
||||||
|
and confirm both (a) no thinking text in any delta and (b) `concat === T` still holds.
|
||||||
|
- OCP already owns the spawn (isolated HOME, its own flags), so injecting `--settings` with a
|
||||||
|
`MessageDisplay` hook sits inside the existing architecture.
|
||||||
|
- **`ALIGNMENT.md`**: this consumes `claude`'s **own** hook surface as emitted — forwarding, not
|
||||||
|
inventing. Not a new endpoint, not a fabricated protocol. (Class B / ADR 0007 — the TUI spawn is
|
||||||
|
OCP-owned; no `cli.js` citation applies.)
|
||||||
|
|
||||||
|
### Reproduce in 60 seconds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# hook script: append the payload (arrives on stdin) and exit immediately
|
||||||
|
printf '#!/bin/bash\ncat >> "$MD_LOG"; printf "\\n" >> "$MD_LOG"; exit 0\n' > /tmp/h.sh && chmod +x /tmp/h.sh
|
||||||
|
echo '{"hooks":{"MessageDisplay":[{"hooks":[{"type":"command","command":"MD_LOG=/tmp/deltas.jsonl /tmp/h.sh"}]}]}}' > /tmp/s.json
|
||||||
|
|
||||||
|
# plain interactive claude in tmux (prefix NOT ocp-tui-*, and never kill-server)
|
||||||
|
tmux new-session -d -s md-probe -x 220 -y 50 \
|
||||||
|
"claude --model claude-sonnet-4-6 --effort low --session-id $(uuidgen) --settings /tmp/s.json"
|
||||||
|
# …wait for '? for shortcuts', paste a markdown-producing prompt, press Enter…
|
||||||
|
|
||||||
|
jq -r '"\(.index) \(.final) \(.delta|@json)"' /tmp/deltas.jsonl # incremental raw-markdown deltas
|
||||||
|
# then assert: concat(deltas) == extractLatestAssistantText(<transcript>.jsonl)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three dead ends (still worth knowing — they say what NOT to build)
|
||||||
|
|
||||||
|
### (a) Incremental transcript reads — **dead: event granularity, not token granularity**
|
||||||
|
|
||||||
|
The transcript JSONL *does* grow during a turn, but one **whole event at a time**; the assistant's text
|
||||||
|
event is written as **one complete line**, appearing only ~0.3 s before the terminal `turn_duration`.
|
||||||
|
|
||||||
|
Observed (session `efd5b161`, `turn_duration: 7319 ms`):
|
||||||
|
|
||||||
|
```
|
||||||
|
#6 t+0.0s type=user (the prompt)
|
||||||
|
#15 t+4.7s type=assistant blocks=thinking
|
||||||
|
#16 t+7.0s type=assistant blocks=text ← the ENTIRE answer, in one line
|
||||||
|
#21 t+7.3s type=system subtype=turn_duration ← terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-checked at **20 ms polling + `fs.watch`** (25× finer): a partial line **never touches disk** —
|
||||||
|
one write, `+1` line, carrying the complete answer. Also forced with the undocumented
|
||||||
|
`CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1`: still 1 assistant event, 0 partials (interactive mode has no
|
||||||
|
stream-json *sink* for it to write to).
|
||||||
|
|
||||||
|
**The transcript is still needed** — as the terminal-turn signal, as the authoritative `concat === T`
|
||||||
|
check, and as the input to the existing honesty gates (auth-banner detection, `truncated`). It is just
|
||||||
|
not the *streaming* source.
|
||||||
|
|
||||||
|
### (b) `tmux capture-pane` diffing — **dead: the pane is a RENDERED view, not the text**
|
||||||
|
|
||||||
|
The backlog expected to fall back to this, calling it "lossy … (wrapping, scrollback, spinner lines)".
|
||||||
|
The loss is far worse than formatting noise: **the pane does not contain the answer's source bytes at
|
||||||
|
all.** The TUI *renders* markdown, and `capture-pane -p` strips the ANSI that rendering produced.
|
||||||
|
|
||||||
|
Same turn, same lines:
|
||||||
|
|
||||||
|
```
|
||||||
|
TRANSCRIPT (authoritative T): PANE (capture-pane -p -J -S -500):
|
||||||
|
'## Semaphore' '⏺ Semaphore' ← heading marker gone
|
||||||
|
'' ''
|
||||||
|
'A **semaphore** is a synchro…' ' A semaphore is a synchro…' ← bold markers gone, indented
|
||||||
|
```
|
||||||
|
|
||||||
|
| token in the answer | in `T` | in the pane's answer region |
|
||||||
|
|---|---|---|
|
||||||
|
| `## ` (ATX heading) | yes | **no** — rendered as `⏺` |
|
||||||
|
| `**` (bold markers) | yes | **no** — rendered to ANSI bold, then stripped by `-p` |
|
||||||
|
| ` ```javascript ` (fence + language) | yes | **no** — fence and language tag both gone |
|
||||||
|
| `- ` (list item) | yes | yes |
|
||||||
|
|
||||||
|
*(A literal `**` does appear elsewhere in the pane — in the **prompt echo**, because the prompt asked
|
||||||
|
for bold. Not in the answer.)*
|
||||||
|
|
||||||
|
**`capture-pane -e` (keeping the ANSI) does not rescue it — the inverse is provably non-unique.**
|
||||||
|
With `T` = ``"## Alpha\n\n**bravo**\n\n```javascript\nlet x=1;\n```"``:
|
||||||
|
|
||||||
|
```
|
||||||
|
⏺\e[39m \e[1mAlpha\n\n\e[0m \e[1mbravo\n\n\e[0m \e[34mlet\e[39m x=\e[32m1\e[39m;
|
||||||
|
```
|
||||||
|
|
||||||
|
`## Alpha` → **SGR 1 (bold)**. `**bravo**` → **SGR 1 (bold)**. *Identical ANSI* — an H2 and a bold span
|
||||||
|
are indistinguishable, never mind `**` vs `__`. The fence and its `javascript` tag are consumed by the
|
||||||
|
syntax highlighter into colours; recovering the tag would mean inverting a highlighter, and
|
||||||
|
`let x=1;` is valid in several languages.
|
||||||
|
|
||||||
|
So `T.startsWith(paneText)` is **false** — raw and indent-stripped, on essentially every markdown
|
||||||
|
answer. A proxy streaming pane text would be streaming **something the model did not say**. With
|
||||||
|
`MessageDisplay` available there is no reason to go near it.
|
||||||
|
|
||||||
|
### (c) `--debug-file` — **dead: it logs stream *timing*, never stream *content***
|
||||||
|
|
||||||
|
Worth stating precisely, because a casual check misleads in **both** directions here.
|
||||||
|
|
||||||
|
The default log level is `debug`, which **suppresses every `verbose` site**. Raise it and per-chunk
|
||||||
|
lines *do* appear, spread across generation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose claude --debug-file /tmp/d.log …
|
||||||
|
```
|
||||||
|
```
|
||||||
|
05:51:11.088 [VERBOSE] [shoji-engine] yield stream_event/- ← 16 of these, mid-turn,
|
||||||
|
05:51:11.537 [VERBOSE] [shoji-engine] yield stream_event/- over ~3.9 s of generation
|
||||||
|
05:51:15.192 [DEBUG] [shoji-engine] turn 1 end (usage in=575 out=255 api=6736ms stop=end_turn resultLen=857)
|
||||||
|
```
|
||||||
|
|
||||||
|
**But they carry no payload** — the format is `yield <type>/<subtype>`, a bare presence marker. Run with
|
||||||
|
no category filter (i.e. all categories) at verbose level: `content_block_delta` = **0**, `text_delta` =
|
||||||
|
**0**, `content_block_start` / `message_start` = **0**. The only byte-exact text in the log is the
|
||||||
|
end-of-turn `Stop` hook payload (`"last_assistant_message":"## Title\n\n**alpha bravo charlie**"`) —
|
||||||
|
transcript granularity. The log tells you **when** tokens arrive, never **what** they are. It is also
|
||||||
|
~2.7 MB per turn.
|
||||||
|
|
||||||
|
### Also checked, also not the answer
|
||||||
|
|
||||||
|
| candidate | outcome |
|
||||||
|
|---|---|
|
||||||
|
| `--output-format stream-json` (the one interface that emits `text_delta`) | **requires `--print`/`-p`** → `cc_entrypoint=sdk-cli` → the **metered** credit pool, which is exactly what TUI mode exists to avoid. Reproduced live. |
|
||||||
|
| `--input-format stream-json` | `Error: --input-format=stream-json requires output-format=stream-json` → same gate. |
|
||||||
|
| `CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1` (undocumented) | No stream-json sink in interactive mode → no partials. Banner stayed `· Claude Max`. |
|
||||||
|
| `sessionMirror` (undocumented) | Gated on `outputFormat === "stream-json"` → the `-p` family. |
|
||||||
|
| `--sdk-url` (hidden) | Forces stream-json + non-interactive → `sdk-cli`. *(inferred from the minified bundle; not banner-tested)* |
|
||||||
|
| `~/.claude/sessions/<pid>.json` | Registry metadata only (`{pid, sessionId, cwd, status, version, entrypoint:"cli", kind:"interactive"}`). No assistant text. *(Its `entrypoint:"cli"` incidentally confirms the TUI path stays on the subscription pool.)* |
|
||||||
|
| `~/.claude/history.jsonl` | User prompts only; the answer text is absent. |
|
||||||
|
| Asking the model to emit plain text (so the pane renders faithfully) | Would mean **mutating the caller's prompt** — a correctness violation for a proxy, and still not byte-faithful (wrapping + indent remain). Rejected. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Value: what streaming actually buys (read before building)
|
||||||
|
|
||||||
|
Streaming is *possible*. Whether it is *worth it* depends on the consumer, and the honest answer is
|
||||||
|
uncomfortable:
|
||||||
|
|
||||||
|
- **Streaming never makes the answer arrive sooner. It moves the *first* byte, not the *last*.** The
|
||||||
|
final token lands at the same wall-clock moment either way.
|
||||||
|
- So a consumer that must have the **complete** answer before it can act — e.g. one parsing a structured
|
||||||
|
JSON reply, **which is exactly the 知音 AI use case that motivated this entire investigation** — gains
|
||||||
|
**nothing at all**. Only a **progressively-rendering** consumer (a chat UI) gains.
|
||||||
|
|
||||||
|
And the number the backlog attached to this item was wrong:
|
||||||
|
|
||||||
|
- The backlog's "~20 s" was inferred from an external 30–32 s report, **never measured through OCP**.
|
||||||
|
Measured through a real OCP instance (TUI mode, `claude-sonnet-4-6`, ~1850-token prompt, n=5):
|
||||||
|
**median 11.30 s** before [#156](https://github.com/dtzp555-max/ocp/pull/156), **9.55 s** after.
|
||||||
|
- **Same-turn decomposition** (baseline row `i=5`): **11.563 s** wall through OCP vs `turn_duration:
|
||||||
|
7.319 s` of CLI-internal time on that same turn → **OCP's own overhead ≈ 4.2 s** (n=1, baseline
|
||||||
|
`effort=high` config). *Caveats*: n=1; and `turn_duration` is the CLI's internal duration of an
|
||||||
|
**OCP-driven** turn, not a separate "native" baseline. Do **not** subtract this `effort=high` 7.3 s
|
||||||
|
from the `effort=low` 9.55 s median — a low-effort turn generates faster, so mixing them
|
||||||
|
*understates* the overhead.
|
||||||
|
- So OCP's own overhead is **single-digit seconds**, not ~20 s. The rest of any large number is the
|
||||||
|
model generating a long answer — which streaming hides but does not shorten.
|
||||||
|
|
||||||
|
**Recommendation**: build it — the contract is clean and the cost is small — but size the expectation
|
||||||
|
honestly. It is a *perceived-latency* feature for progressively-rendering consumers, not a throughput
|
||||||
|
win, and it does not move the **~6 s TTFT floor** ([`README.md`](README.md)) that rules TUI mode out for
|
||||||
|
interactive-latency consumers regardless.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 2026-06-15 Canary Runbook
|
||||||
|
|
||||||
|
**Purpose:** Confirm that a TUI-mode turn is billed to the **Pro/Max subscription pool** (not the Agent SDK credit pool) after Anthropic's 2026-06-15 billing split activates.
|
||||||
|
|
||||||
|
The billing classifier reading `cli` is **necessary but NOT sufficient** proof. (Note the naming: the value is stored in the JSONL transcript under the field name `entrypoint`, and sent to Anthropic on the wire as the `cc_entrypoint` header — they carry the same value after claude's startup classification. The commands below grep the transcript, so they match `entrypoint`.) A `cli` label tells you OCP sent the right classification; it does not tell you Anthropic billed the right pool. The only authoritative test is to observe whether the **Agent SDK credit balance** moves or not before and after the canary turn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
|
||||||
|
- `tmux` installed on the host
|
||||||
|
- No other OCP traffic during the canary (quiesce — see below)
|
||||||
|
- Access to your Anthropic account billing page (manual step — see below)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Quiesce the host
|
||||||
|
|
||||||
|
Stop any IDE or client that is actively sending requests through this OCP instance.
|
||||||
|
|
||||||
|
Confirm the proxy is idle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep activeRequests
|
||||||
|
# Expected: "activeRequests": 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait until `activeRequests` is `0` before proceeding. If you cannot quiesce (e.g. family members are actively using it), run the canary on a separate OCP instance or during a quiet window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Read the Agent SDK credit balance BEFORE the canary
|
||||||
|
|
||||||
|
> **Manual step — no programmatic API available.**
|
||||||
|
>
|
||||||
|
> OCP's `/usage` endpoint reads `anthropic-ratelimit-unified-*` response headers from the Pro/Max plan quota (5-hour and 7-day subscription windows). These headers report **subscription usage**, not the Agent SDK credit pool balance. There is no known programmatic API to query the Agent SDK credit pool balance from outside the Anthropic web app.
|
||||||
|
|
||||||
|
To read the balance:
|
||||||
|
|
||||||
|
1. Open [https://claude.ai/settings/billing](https://claude.ai/settings/billing) (or your Anthropic Console billing page) in a browser.
|
||||||
|
2. Find the **Agent SDK Credits** section (sometimes labeled "API Credits" or "Agent SDK usage").
|
||||||
|
3. Note the current balance (e.g. `$18.43 remaining of $20.00`).
|
||||||
|
|
||||||
|
Write the value down — you will compare it after the canary turn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Send the canary turn
|
||||||
|
|
||||||
|
With TUI-mode on and the host quiesced, send exactly one small request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:3456/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "claude-haiku-4-5-20251001",
|
||||||
|
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
|
||||||
|
"max_tokens": 10
|
||||||
|
}' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Use Haiku (the cheapest model) to minimize any hypothetical impact if the canary turns red.
|
||||||
|
|
||||||
|
Wait for the response to arrive completely (TUI-mode buffers the full response before returning — you will see a delay of several seconds, then the full reply).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Confirm the transcript shows `entrypoint:"cli"`
|
||||||
|
|
||||||
|
After the canary turn completes, inspect the most recent JSONL transcript for the billing-classifier label:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The canary was run quiesced (Step 1), so the most recent JSONL across ALL project
|
||||||
|
# dirs IS the canary turn. We glob every projects subdir instead of recomputing
|
||||||
|
# claude's cwd-encoding rule (it maps every "/" AND "." to "-", e.g. ~/.ocp-tui/work
|
||||||
|
# => projects/-home-<user>--ocp-tui-work/; see lib/tui/transcript.mjs encodeCwd) —
|
||||||
|
# a glob is robust even if that encoding changes in a future claude build.
|
||||||
|
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||||
|
echo "Transcript: $LATEST"
|
||||||
|
grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1
|
||||||
|
# Expected: "entrypoint":"cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the output shows `"entrypoint":"cli"`, the billing-classifier label is correct. If it shows `"entrypoint":"sdk-cli"`, the spawn did not get a real PTY — stop immediately and do not re-enable TUI-mode without investigation. Check `tmux new-session` manually and review ADR 0007 § spawn/PTY gate. (If the grep returns nothing, the transcript may not yet be flushed — re-run after a second, or confirm the turn completed.)
|
||||||
|
|
||||||
|
**Reminder: an `entrypoint:cli` label (the `cc_entrypoint=cli` wire header) is necessary but not sufficient.** It tells you OCP sent the right label to Anthropic. You must still check the credit balance in Step 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Re-read the Agent SDK credit balance AFTER the canary
|
||||||
|
|
||||||
|
Return to [https://claude.ai/settings/billing](https://claude.ai/settings/billing) and reload the page. Note the current balance again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Green/Red decision
|
||||||
|
|
||||||
|
### Green (balance unchanged)
|
||||||
|
|
||||||
|
The Agent SDK credit balance did not decrease. The turn billed against the Pro/Max subscription pool as expected. TUI-mode is working correctly.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
- Keep `CLAUDE_TUI_MODE=true` on this host.
|
||||||
|
- Monitor the balance periodically for the first week to catch any delayed attribution.
|
||||||
|
- Resume normal traffic.
|
||||||
|
|
||||||
|
### Red (Agent SDK credit balance decreased)
|
||||||
|
|
||||||
|
The Agent SDK credit balance decreased. The subscription pool is not being used for TUI-mode turns on this host, despite `cc_entrypoint=cli` being set. This may indicate a backend routing change on Anthropic's side, a TTY detection failure, or a policy change.
|
||||||
|
|
||||||
|
**Actions — immediate:**
|
||||||
|
1. Unset `CLAUDE_TUI_MODE` (or set to any value other than `"true"`) in the service unit:
|
||||||
|
- systemd: edit `/etc/ocp/ocp.env` (or the unit's `Environment=` line), then `sudo systemctl daemon-reload && sudo systemctl restart ocp.service`
|
||||||
|
- launchd: edit the plist `EnvironmentVariables` section, then `launchctl bootout gui/$(id -u)/dev.ocp.proxy && launchctl bootstrap gui/$(id -u) <plist-path>`
|
||||||
|
2. Restart OCP and confirm the `/health` response no longer shows TUI-mode active.
|
||||||
|
3. If you share this OCP with family or other Max users: freeze their access temporarily until you understand the billing impact.
|
||||||
|
4. Consider pivoting to OLP multi-provider (see [OLP](https://github.com/dtzp555-max/olp)) which can spread load across other providers to avoid the Agent SDK credit drain.
|
||||||
|
|
||||||
|
Per ALIGNMENT.md Rule 2 / ADR 0007 § Kill-switch: "Per the constitution, the response is to drop the Anthropic provider rather than escalate spoofing."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ongoing monitoring — self-classification mini-canary
|
||||||
|
|
||||||
|
To detect future drift (e.g. a claude CLI upgrade that changes TTY-detection behavior), you can run a periodic one-liner that sends a tiny TUI turn with `OCP_TUI_ENTRYPOINT=auto` (so claude self-classifies rather than having OCP pin the value) and alerts if the transcript self-classification is not `cli`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with OCP temporarily configured OCP_TUI_ENTRYPOINT=auto
|
||||||
|
# Then check the most recent transcript:
|
||||||
|
# Glob the most recent transcript across all project dirs (robust to claude's
|
||||||
|
# cwd-encoding rule; run this right after the auto-mode mini-canary turn).
|
||||||
|
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||||
|
RESULT=$(grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1)
|
||||||
|
echo "Self-classified entrypoint: $RESULT"
|
||||||
|
if echo "$RESULT" | grep -q '"entrypoint":"cli"'; then
|
||||||
|
echo "OK — subscription pool"
|
||||||
|
else
|
||||||
|
echo "ALERT — not cli; check TTY and billing"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's own `t$A` startup function determine the value from the actual TTY state (see ADR 0007 § Billing-classifier labeling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
|
||||||
|
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
|
||||||
|
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# TUI-Mode Flip and Rollback Runbook
|
||||||
|
|
||||||
|
**Purpose:** Step-by-step instructions for enabling (`CLAUDE_TUI_MODE=true`) or disabling TUI-mode on real OCP deployments managed by **systemd** (Linux) or **launchd** (macOS).
|
||||||
|
|
||||||
|
Run the [615-canary](./615-canary.md) runbook after any flip to confirm billing pool routing is correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical pitfalls — read first
|
||||||
|
|
||||||
|
### systemd: `daemon-reload` is required after editing the unit
|
||||||
|
|
||||||
|
Editing the unit file (or EnvironmentFile) and then doing `systemctl restart ocp.service` **without** `daemon-reload` will restart the process with the **old** environment from the cached unit. Always run `daemon-reload` after editing any unit file.
|
||||||
|
|
||||||
|
### launchd: `launchctl kickstart -k` does NOT reload plist env
|
||||||
|
|
||||||
|
`launchctl kickstart -k gui/$(id -u)/dev.ocp.proxy` kills the running process and re-launches it, but it **re-uses the launchd-cached environment** — not the current plist file. If you edited the plist's `EnvironmentVariables` section, you must do a full `bootout` + `bootstrap` cycle for the change to take effect. `kickstart` is not sufficient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flip — enable TUI-mode
|
||||||
|
|
||||||
|
### systemd (Linux, e.g. Raspberry Pi, VPS)
|
||||||
|
|
||||||
|
**Option A — EnvironmentFile (recommended for clean separation)**
|
||||||
|
|
||||||
|
If your unit uses `EnvironmentFile=/etc/ocp/ocp.env` (or similar):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Edit the environment file
|
||||||
|
sudo nano /etc/ocp/ocp.env
|
||||||
|
# Add or update:
|
||||||
|
# CLAUDE_TUI_MODE=true
|
||||||
|
#
|
||||||
|
# If OCP binds to 0.0.0.0 AND you trust the network:
|
||||||
|
# OCP_TUI_ALLOW_LAN=1
|
||||||
|
# (WARNING: TUI-mode is single-user only — only enable OCP_TUI_ALLOW_LAN=1
|
||||||
|
# if you fully trust every caller that can reach the OCP port on your network)
|
||||||
|
|
||||||
|
# 2. Reload the unit definition and restart
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart ocp.service
|
||||||
|
|
||||||
|
# 3. Verify
|
||||||
|
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||||
|
# Expected: "tuiMode": true (or similar TUI indicator in the health response)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — inline Environment= in the unit file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Edit the unit file
|
||||||
|
sudo systemctl edit --full ocp.service
|
||||||
|
# Add or update in the [Service] section:
|
||||||
|
# Environment=CLAUDE_TUI_MODE=true
|
||||||
|
|
||||||
|
# 2. Reload and restart
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart ocp.service
|
||||||
|
|
||||||
|
# 3. Verify
|
||||||
|
systemctl show ocp.service --property=Environment
|
||||||
|
# Expected: Environment=CLAUDE_TUI_MODE=true ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### launchd (macOS)
|
||||||
|
|
||||||
|
Locate the OCP plist. The standard label is `dev.ocp.proxy`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the plist path
|
||||||
|
ls ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit the plist:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop the service first (bootout)
|
||||||
|
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||||
|
|
||||||
|
# 2. Edit the plist — add CLAUDE_TUI_MODE to EnvironmentVariables
|
||||||
|
# Use your editor of choice:
|
||||||
|
nano ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the plist, in the `<key>EnvironmentVariables</key>` `<dict>` block, add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>CLAUDE_TUI_MODE</key>
|
||||||
|
<string>true</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
If `OCP_TUI_ALLOW_LAN=1` is also needed (only if OCP binds to `0.0.0.0` and you trust the network):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>OCP_TUI_ALLOW_LAN</key>
|
||||||
|
<string>1</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 3. Bootstrap (reload from disk + start)
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||||
|
|
||||||
|
# 4. Verify
|
||||||
|
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confirm env was actually loaded** (not just set in your shell):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps aux | grep server.mjs | grep -v grep
|
||||||
|
# Get the PID, then:
|
||||||
|
# macOS: ps -E -p <PID> | tr ' ' '\n' | grep CLAUDE_TUI_MODE
|
||||||
|
# Expected: CLAUDE_TUI_MODE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback — disable TUI-mode
|
||||||
|
|
||||||
|
Rollback is the same procedure as flip, but you **remove** `CLAUDE_TUI_MODE` or set it to any value other than `"true"` (e.g. `false`, or simply omit it).
|
||||||
|
|
||||||
|
After rollback, OCP returns to the default `callClaude` / `callClaudeStreaming` stream-json path — byte-for-byte identical to the pre-TUI code path. No other change is required.
|
||||||
|
|
||||||
|
### systemd rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A — EnvironmentFile
|
||||||
|
sudo nano /etc/ocp/ocp.env
|
||||||
|
# Remove or comment out:
|
||||||
|
# CLAUDE_TUI_MODE=true
|
||||||
|
# OCP_TUI_ALLOW_LAN=1 (if set)
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart ocp.service
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||||
|
# Expected: "tuiMode": false (or the field absent)
|
||||||
|
```
|
||||||
|
|
||||||
|
### launchd rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop
|
||||||
|
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||||
|
|
||||||
|
# 2. Edit plist — remove the CLAUDE_TUI_MODE and OCP_TUI_ALLOW_LAN entries from EnvironmentVariables
|
||||||
|
|
||||||
|
# 3. Bootstrap
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||||
|
|
||||||
|
# 4. Verify
|
||||||
|
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Billing impact of staying on the default (non-TUI) path after 2026-06-15
|
||||||
|
|
||||||
|
If you do NOT flip to TUI-mode and keep `CLAUDE_TUI_MODE` unset (the default), OCP continues using `claude -p --output-format stream-json`, which sets `cc_entrypoint=sdk-cli`. After 2026-06-15, every OCP request on the default path will draw from the Agent SDK credit pool (approximately $20/month on a Pro plan, or $100/month on a Max plan) rather than the Pro/Max subscription. The subscription pool usage (5-hour and 7-day windows) will be unaffected, but the Agent SDK credit balance will drain with each request.
|
||||||
|
|
||||||
|
If you want to continue using OCP without TUI-mode after 2026-06-15, budget for the Agent SDK credit cost accordingly — or switch to [OLP](https://github.com/dtzp555-max/olp) for multi-provider fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify after any flip
|
||||||
|
|
||||||
|
1. Check `/health` shows the expected `tuiMode` state.
|
||||||
|
2. Run the [615-canary](./615-canary.md) to confirm billing pool routing.
|
||||||
|
3. If TUI-mode is ON: check `ocp logs 10` for any TUI spawn errors (`tui_spawn_failed`, tmux errors).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
|
||||||
|
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
|
||||||
|
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||||
|
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
# TUI-mode (OCP-first) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
|
||||||
|
|
||||||
|
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse` → `completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
|
||||||
|
|
||||||
|
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1–S6, T1–T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why OCP-first / scope decisions (read before coding)
|
||||||
|
|
||||||
|
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
|
||||||
|
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
|
||||||
|
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
|
||||||
|
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
|
||||||
|
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
|
||||||
|
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
|
||||||
|
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | New/Modified |
|
||||||
|
|------|----------------|--------------|
|
||||||
|
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
|
||||||
|
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
|
||||||
|
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
|
||||||
|
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018`–`:1023` helpers, `:1467` dispatch, boot block) |
|
||||||
|
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
|
||||||
|
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
|
||||||
|
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
|
||||||
|
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
|
||||||
|
|
||||||
|
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
|
||||||
|
|
||||||
|
### Task 0: Harvest real fixtures from PI231
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
|
||||||
|
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
|
||||||
|
|
||||||
|
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
|
||||||
|
|
||||||
|
Run (on PI231):
|
||||||
|
```bash
|
||||||
|
SID=$(uuidgen)
|
||||||
|
mkdir -p ~/.ocp-tui/work
|
||||||
|
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
|
||||||
|
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
|
||||||
|
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
|
||||||
|
```
|
||||||
|
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
|
||||||
|
|
||||||
|
Run (from the workstation):
|
||||||
|
```bash
|
||||||
|
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
|
||||||
|
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
|
||||||
|
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
|
||||||
|
# material before committing:
|
||||||
|
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
|
||||||
|
```
|
||||||
|
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the fixtures**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
|
||||||
|
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs` (new Suite "TUI transcript")
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `test-features.mjs`:
|
||||||
|
```js
|
||||||
|
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||||
|
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
|
||||||
|
|
||||||
|
test("encodeCwd replaces every slash incl. leading", () => {
|
||||||
|
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
|
||||||
|
});
|
||||||
|
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
||||||
|
assertEqual(
|
||||||
|
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
||||||
|
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
|
||||||
|
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Create `lib/tui/transcript.mjs`:
|
||||||
|
```js
|
||||||
|
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||||
|
// and returns the latest assistant turn's text once the turn is terminal.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 — interactive session transcript at
|
||||||
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||||
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Project-dir encoding: every "/" -> "-" (including the leading slash).
|
||||||
|
export function encodeCwd(cwd) {
|
||||||
|
return cwd.replace(/\//g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPath(home, cwd, sessionId) {
|
||||||
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
|
||||||
|
Expected: PASS for both cases.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||||
|
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
|
||||||
|
assertEqual(evs.length, 2);
|
||||||
|
assertEqual(evs[1].b, 2);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on turn_duration", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||||
|
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine false on ordinary assistant/text lines", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||||
|
});
|
||||||
|
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||||
|
{ type: "user", message: { content: "..." } },
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
|
||||||
|
];
|
||||||
|
assertEqual(extractLatestAssistantText(evs), "AB");
|
||||||
|
});
|
||||||
|
test("real complete fixture yields non-empty text and is terminal", () => {
|
||||||
|
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
|
||||||
|
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
|
||||||
|
Expected: FAIL — exports not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||||
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||||
|
export function parseTranscriptLines(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line marks the assistant turn complete when it is the turn_duration system
|
||||||
|
// event, or an assistant message that stopped to hand off to a tool.
|
||||||
|
export function isTerminalLine(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||||
|
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
|
||||||
|
return sr === "tool_use";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||||
|
export function extractLatestAssistantText(events) {
|
||||||
|
let text = "";
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || ev.type !== "assistant") continue;
|
||||||
|
const content = ev.message && ev.message.content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text);
|
||||||
|
if (parts.length) text = parts.join("");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
writeFileSync(p, [
|
||||||
|
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||||
|
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||||
|
assertEqual(out, "hello world");
|
||||||
|
});
|
||||||
|
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
|
||||||
|
assertEqual(out, "partial");
|
||||||
|
});
|
||||||
|
test("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/missing.jsonl`; // file never appears
|
||||||
|
let threw = false;
|
||||||
|
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
|
||||||
|
catch { threw = true; }
|
||||||
|
assert(threw, "must throw on empty timeout");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||||
|
Expected: FAIL — export not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation** (append)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Block until the session transcript is terminal (turn_duration / tool_use) or
|
||||||
|
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||||
|
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||||
|
// partial text; on cap with no text at all, throws.
|
||||||
|
//
|
||||||
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||||
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
|
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
|
||||||
|
const deadline = Date.now() + wallclockMs;
|
||||||
|
let lastText = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
const events = parseTranscriptLines(readFileSync(p, "utf8"));
|
||||||
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
|
if (events.some(isTerminalLine)) return lastText;
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
if (lastText) return lastText;
|
||||||
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||||
|
Expected: all 3 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-2 — Session driver (`lib/tui/session.mjs`)
|
||||||
|
|
||||||
|
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
|
||||||
|
|
||||||
|
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/session.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
|
const killed = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assertEqual(SESSION_PREFIX, "ocp-tui-");
|
||||||
|
assertEqual(n, 2);
|
||||||
|
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||||
|
Expected: FAIL — module/export missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Create `lib/tui/session.mjs`:
|
||||||
|
```js
|
||||||
|
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||||
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||||
|
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
|
||||||
|
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
|
||||||
|
|
||||||
|
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||||
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||||
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||||
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
let killed = 0;
|
||||||
|
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/session.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/session.mjs`
|
||||||
|
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
|
||||||
|
if (process.env.OCP_TUI_LIVE === "1") {
|
||||||
|
test("runTuiTurn drives a real interactive turn and returns text", async () => {
|
||||||
|
const { runTuiTurn } = await import("./lib/tui/session.mjs");
|
||||||
|
const out = await runTuiTurn({
|
||||||
|
prompt: "Reply with exactly the word PONG and nothing else.",
|
||||||
|
model: "claude-haiku-4-5-20251001",
|
||||||
|
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
|
||||||
|
home: process.env.HOME,
|
||||||
|
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||||
|
wallclockMs: 120000,
|
||||||
|
});
|
||||||
|
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
|
||||||
|
|
||||||
|
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||||
|
Expected: FAIL — `runTuiTurn` not exported yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
|
||||||
|
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
|
||||||
|
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||||
|
|
||||||
|
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
|
||||||
|
|
||||||
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
|
// belt-and-braces with --disallowedTools "mcp__*".
|
||||||
|
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||||
|
return [
|
||||||
|
shq(claudeBin),
|
||||||
|
"--model", shq(model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
"--strict-mcp-config",
|
||||||
|
"--disallowedTools", shq("mcp__*"),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTuiTurn({
|
||||||
|
prompt, model, claudeBin, home, cwd,
|
||||||
|
wallclockMs = 120000, tmux = defaultTmux,
|
||||||
|
}) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||||
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||||
|
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
if (home) env.HOME = home;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
|
||||||
|
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
|
buildTuiCmd(claudeBin, model, sessionId)], { env });
|
||||||
|
await sleep(BOOT_MS);
|
||||||
|
|
||||||
|
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
|
||||||
|
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
|
||||||
|
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "1"]);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
await sleep(DIALOG_MS);
|
||||||
|
|
||||||
|
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
|
||||||
|
// touches the command line (no shell injection from prompt text), then a
|
||||||
|
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
|
||||||
|
// does not submit; the Enter key event does).
|
||||||
|
spawnSync("sh", ["-c",
|
||||||
|
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
|
||||||
|
{ env, encoding: "utf8" });
|
||||||
|
await sleep(PASTE_SETTLE_MS);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
|
// 4. Read the answer from the native transcript.
|
||||||
|
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
|
||||||
|
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
|
||||||
|
} finally {
|
||||||
|
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
|
||||||
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** (PI231, live)
|
||||||
|
|
||||||
|
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||||
|
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"` → `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/session.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-3 — Wiring into `server.mjs`
|
||||||
|
|
||||||
|
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
|
||||||
|
|
||||||
|
### Task 6: env consts + `streamStringAsSSE` DRY refactor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524`–`:1539` into a helper near `:1023`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258`–`:275`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
|
||||||
|
// Authority: docs/adr/0007-tui-interactive-mode.md.
|
||||||
|
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||||
|
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
||||||
|
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||||
|
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
|
||||||
|
function streamStringAsSSE(res, id, model, content) {
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
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 }] });
|
||||||
|
const CHUNK = 80;
|
||||||
|
const codepoints = Array.from(content);
|
||||||
|
for (let i = 0; i < codepoints.length; i += CHUNK) {
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
|
||||||
|
}
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524`–`:1539`) at the helper** (DRY — behavior identical)
|
||||||
|
|
||||||
|
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
|
||||||
|
```js
|
||||||
|
if (stream) {
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
streamStringAsSSE(res, id, model, cached.response);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full suite to verify no regression**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | tail -3`
|
||||||
|
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs
|
||||||
|
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: `callClaudeTui` + dispatch gates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// TUI-mode upstream: drive an interactive claude session, return the assistant
|
||||||
|
// text as a string — same contract as callClaude(), so all downstream
|
||||||
|
// (singleflight, cache write-back, completionResponse) is unchanged.
|
||||||
|
// System messages are rendered inline as [System] blocks by messagesToPrompt;
|
||||||
|
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
|
||||||
|
// flag that could perturb cc_entrypoint classification.
|
||||||
|
function callClaudeTui(model, messages, conversationId, keyName) {
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||||
|
recordModelRequest(cliModel, prompt.length);
|
||||||
|
return runTuiTurn({
|
||||||
|
prompt, model: cliModel, claudeBin: CLAUDE,
|
||||||
|
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
|
||||||
|
}).then((text) => {
|
||||||
|
recordModelSuccess(cliModel, 0);
|
||||||
|
return text;
|
||||||
|
}).catch((err) => {
|
||||||
|
recordModelError(cliModel, false);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563`–`:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
|
||||||
|
|
||||||
|
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
|
||||||
|
```js
|
||||||
|
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
||||||
|
```
|
||||||
|
Then change `await callClaude(model, messages, conversationId, req._authKeyName)` → `await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551`–`:1553`, branch TUI streaming to buffer-then-replay:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (stream) {
|
||||||
|
if (TUI_MODE) {
|
||||||
|
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
|
||||||
|
const t0Usage = Date.now();
|
||||||
|
try {
|
||||||
|
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||||
|
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||||
|
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
|
}
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
streamStringAsSSE(res, id, model, content);
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
|
||||||
|
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||||
|
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default: real stream-json streaming, unchanged.
|
||||||
|
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
|
||||||
|
|
||||||
|
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
|
||||||
|
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
|
||||||
|
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
|
||||||
|
|
||||||
|
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
|
||||||
|
```bash
|
||||||
|
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
|
||||||
|
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
|
||||||
|
```
|
||||||
|
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs
|
||||||
|
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
|
||||||
|
- Create: `docs/adr/0007-tui-interactive-mode.md`
|
||||||
|
- Modify: `README.md`, `CHANGELOG.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (TUI_MODE) {
|
||||||
|
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
|
||||||
|
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write ADR 0007** — `docs/adr/0007-tui-interactive-mode.md`
|
||||||
|
|
||||||
|
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format` ⇒ `sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
|
||||||
|
|
||||||
|
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
|
||||||
|
|
||||||
|
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
|
||||||
|
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration & canary (post-implementation, on PI231)
|
||||||
|
|
||||||
|
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
|
||||||
|
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
|
||||||
|
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
|
||||||
|
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
|
||||||
|
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (against spec + the OCP-first execution review)
|
||||||
|
|
||||||
|
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 6–7), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
|
||||||
|
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
|
||||||
|
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
|
||||||
|
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 4–5 match imports in Task 6–8; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 6–7; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
|
||||||
|
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
|
||||||
@@ -382,11 +382,25 @@ export function getCacheStats() {
|
|||||||
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||||
const inflightMap = new Map();
|
const inflightMap = new Map();
|
||||||
|
|
||||||
export function singleflight(hash, fn) {
|
// `retryIf` (optional, audit finding M1): a predicate applied on the FOLLOWER path only.
|
||||||
|
// When a follower joins an existing flight and the shared promise rejects with an error for
|
||||||
|
// which retryIf(err) is true (in practice: the LEADER's client disconnected while queued —
|
||||||
|
// an error that is personal to the leader, not a verdict about the upstream), the follower
|
||||||
|
// does NOT inherit that rejection. Instead it re-enters singleflight with its OWN fn: it
|
||||||
|
// either becomes the new leader (the map entry is already deleted — see the finally below,
|
||||||
|
// which runs before any follower's catch because it is attached upstream of the promise the
|
||||||
|
// followers await) or joins a flight another retrying follower just created. The leader's
|
||||||
|
// own rejection is never retried here — its error belongs to it (leader path returns the
|
||||||
|
// bare promise). Callers that pass no retryIf get the exact pre-M1 share-everything behavior.
|
||||||
|
export function singleflight(hash, fn, retryIf) {
|
||||||
const existing = inflightMap.get(hash);
|
const existing = inflightMap.get(hash);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.requesters++;
|
existing.requesters++;
|
||||||
return existing.promise;
|
if (!retryIf) return existing.promise;
|
||||||
|
return existing.promise.catch((err) => {
|
||||||
|
if (!retryIf(err)) throw err;
|
||||||
|
return singleflight(hash, fn, retryIf);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||||
const promise = Promise.resolve().then(fn).finally(() => {
|
const promise = Promise.resolve().then(fn).finally(() => {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
|
||||||
|
|
||||||
|
// A bind address is "loopback" only if it cannot be reached from another host.
|
||||||
|
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
|
||||||
|
// network-exposed and must trigger the TUI LAN gate.
|
||||||
|
export function isLoopbackBind(addr) {
|
||||||
|
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
|
||||||
|
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Pure, dependency-injected primitives for the `-p` spawn-token resolution + HOME-isolation
|
||||||
|
// layer. Extracted from server.mjs (findings F3 / F5 / F6, 2026-07-07) so the concurrency,
|
||||||
|
// caching and expiry logic is unit-testable WITHOUT booting the server or mocking execFileSync /
|
||||||
|
// child_process.spawn / fs. server.mjs owns all I/O (macOS keychain exec, process spawn, fs);
|
||||||
|
// this module owns only pure decision logic.
|
||||||
|
//
|
||||||
|
// ALIGNMENT NOTE: none of this touches the OAuth wire machinery (no endpoint / header / body).
|
||||||
|
// OCP still NEVER performs a refresh_token grant itself — these helpers only READ + GATE a token
|
||||||
|
// that some other process (the operator's real claude, or a spawned claude under the real HOME)
|
||||||
|
// refreshes. That property is load-bearing (issue #112) and preserved.
|
||||||
|
|
||||||
|
// Promise-chain mutex. `acquire()` resolves to a `release()` fn; the NEXT `acquire()` does not
|
||||||
|
// resolve until the current holder calls its `release()`. Serializes async critical sections
|
||||||
|
// without busy-waiting. release() is idempotent.
|
||||||
|
export function createSerialMutex() {
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
return {
|
||||||
|
acquire() {
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((r) => { release = r; });
|
||||||
|
const prev = tail;
|
||||||
|
tail = tail.then(() => gate);
|
||||||
|
// Hand the caller its release fn only after the previous holder has released.
|
||||||
|
return prev.then(() => {
|
||||||
|
let released = false;
|
||||||
|
return function releaseMutex() { if (!released) { released = true; release(); } };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short-TTL memo. `get(produce, now)` returns the cached value while `now - storedAt < ttlMs`,
|
||||||
|
// otherwise calls `produce()` and re-stores. A miss that produces null/undefined is STILL stored
|
||||||
|
// (so a genuinely-absent source is not re-probed on every call within the TTL window). `now` is
|
||||||
|
// injectable for testing.
|
||||||
|
export function createTtlCache({ ttlMs }) {
|
||||||
|
let value;
|
||||||
|
let at = -Infinity;
|
||||||
|
let has = false;
|
||||||
|
return {
|
||||||
|
get(produce, now = Date.now()) {
|
||||||
|
if (has && now - at < ttlMs) return value;
|
||||||
|
value = produce();
|
||||||
|
at = now;
|
||||||
|
has = true;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
clear() { has = false; value = undefined; at = -Infinity; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure expiry gate. Returns true when `creds` carries a known expiry that is at/within `bufferMs`
|
||||||
|
// of `now`. Creds WITHOUT `expiresAt` (e.g. long-lived env tokens) are never treated as expiring.
|
||||||
|
// This gate is applied to the CACHED creds on EVERY use — which is precisely why a short-TTL
|
||||||
|
// keychain cache (createTtlCache) cannot reintroduce the #146 forever-stale-token regression: the
|
||||||
|
// cache bounds how often we re-READ the keychain, but the expiry decision is recomputed per use.
|
||||||
|
export function isTokenExpiring(creds, now = Date.now(), bufferMs = 300000) {
|
||||||
|
return !!(creds && creds.expiresAt && now + bufferMs >= creds.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order candidate keychain labels so the last-known-good label is tried first (avoids the
|
||||||
|
// wrong-label miss that doubles the `security` exec count on the hot path). Pure: performs no
|
||||||
|
// read. Returns a fresh array; input is not mutated.
|
||||||
|
export function orderLabelsLastGoodFirst(labels, lastGood) {
|
||||||
|
if (!lastGood || !labels.includes(lastGood)) return labels.slice();
|
||||||
|
return [lastGood, ...labels.filter((l) => l !== lastGood)];
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||||
|
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}]}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||||
|
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Please run /login · API Error: 401 Invalid authentication credentials"}]}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"Say PONG and nothing else."}]}}
|
||||||
|
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"PONG"}]}}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// TUI-path concurrency limiter (audit finding C-4).
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
|
||||||
|
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
|
||||||
|
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
|
||||||
|
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
|
||||||
|
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||||
|
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
|
||||||
|
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
|
||||||
|
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
|
||||||
|
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
|
||||||
|
// cold-boot + up to 120s wallclock).
|
||||||
|
//
|
||||||
|
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
|
||||||
|
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
|
||||||
|
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
|
||||||
|
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
|
||||||
|
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
|
||||||
|
// backpressure signal rather than silent OOM.
|
||||||
|
//
|
||||||
|
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
||||||
|
|
||||||
|
// Thrown by acquire() when the caller-supplied AbortSignal fires before a slot was granted
|
||||||
|
// (audit finding F2 — a client that disconnects while queued must never receive a slot; the
|
||||||
|
// queue entry is spliced out, not just flagged, so `queued` accounting stays exact). Distinct
|
||||||
|
// `name` lets callers (server.mjs acquireClaudeSlot) tell "client went away" apart from
|
||||||
|
// "queue is full" without string-matching the message.
|
||||||
|
export class SemaphoreAbortError extends Error {
|
||||||
|
constructor(message) { super(message); this.name = "SemaphoreAbortError"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TuiSemaphore {
|
||||||
|
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
||||||
|
constructor(limit, { maxQueue } = {}) {
|
||||||
|
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||||
|
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
|
||||||
|
// hits it, small enough that a pathological flood can't grow the queue without bound.
|
||||||
|
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
|
||||||
|
this._inflight = 0;
|
||||||
|
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
|
||||||
|
}
|
||||||
|
|
||||||
|
get inflight() { return this._inflight; }
|
||||||
|
get queued() { return this._waiters.length; }
|
||||||
|
|
||||||
|
// Runtime-adjust the concurrency limit (audit finding F1 — a PATCH /settings maxConcurrent
|
||||||
|
// change must actually take effect, not just be ignored until every currently-inflight task
|
||||||
|
// happens to finish). Lowering the limit is handled lazily by release() (see below) — it
|
||||||
|
// simply stops re-granting until inflight drains under the new, lower limit. Raising the
|
||||||
|
// limit has immediate headroom, so we wake as many queued waiters as now fit.
|
||||||
|
setLimit(limit) {
|
||||||
|
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||||
|
while (this._inflight < this.limit && this._waiters.length > 0) {
|
||||||
|
const next = this._waiters.shift();
|
||||||
|
this._inflight++;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
||||||
|
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
||||||
|
// `signal` (optional AbortSignal, F2) lets the caller cancel a QUEUED wait — e.g. wired to
|
||||||
|
// a client's socket "close" event so a request that disconnects before a slot is granted
|
||||||
|
// is removed from the queue instead of eventually being handed a slot for a dead socket.
|
||||||
|
// If `signal` is already aborted, reject immediately without ever touching the queue.
|
||||||
|
acquire(signal) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.reject(new SemaphoreAbortError("acquire aborted before requesting a slot"));
|
||||||
|
}
|
||||||
|
if (this._inflight < this.limit) {
|
||||||
|
this._inflight++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (this._waiters.length >= this.maxQueue) {
|
||||||
|
return Promise.reject(new Error(
|
||||||
|
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
||||||
|
`(${this.maxQueue}) is full`));
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let waiter; // the FIFO entry — captured so onAbort can find + splice exactly this one
|
||||||
|
const onAbort = () => {
|
||||||
|
const idx = this._waiters.indexOf(waiter);
|
||||||
|
if (idx === -1) return; // already granted a slot (shifted out by release()/setLimit) — too late to cancel
|
||||||
|
this._waiters.splice(idx, 1); // remove, not just flag — keeps `queued` accounting exact
|
||||||
|
reject(new SemaphoreAbortError("acquire aborted while queued"));
|
||||||
|
};
|
||||||
|
waiter = () => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
this._waiters.push(waiter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release a slot. Always frees the caller's own slot first, then re-grants it to the next
|
||||||
|
// waiter ONLY if the (post-decrement) inflight count is still under the current limit (F1
|
||||||
|
// fix). This is what makes a runtime-lowered limit actually bite: if the limit was lowered
|
||||||
|
// while over-subscribed, releases stop re-granting and inflight drains toward the new limit
|
||||||
|
// instead of a freed slot being handed straight back out at the old, higher occupancy.
|
||||||
|
release() {
|
||||||
|
if (this._inflight > 0) this._inflight--;
|
||||||
|
if (this._inflight < this.limit) {
|
||||||
|
const next = this._waiters.shift();
|
||||||
|
if (next) {
|
||||||
|
this._inflight++;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
||||||
|
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
||||||
|
// `signal` (optional, F2) is forwarded to acquire() so a queued run() can be cancelled.
|
||||||
|
async run(fn, signal) {
|
||||||
|
await this.acquire(signal);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
this.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
|
||||||
|
|
||||||
|
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
|
||||||
|
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
|
||||||
|
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
|
||||||
|
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
|
||||||
|
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
|
||||||
|
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||||
|
tuiStats.lastEntrypoint = observed ?? null;
|
||||||
|
const mismatch = expectedMode === "cli" && observed !== "cli";
|
||||||
|
if (mismatch) tuiStats.entrypointMismatches++;
|
||||||
|
return mismatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
|
||||||
|
// config + live counters, returns the exact object embedded in /health. New fields only —
|
||||||
|
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
|
||||||
|
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
entrypointMode, // cli | auto | off
|
||||||
|
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
|
||||||
|
entrypointMismatches: tuiStats.entrypointMismatches,
|
||||||
|
inflight: semaphore.inflight, // current concurrent TUI turns
|
||||||
|
queued: semaphore.queued, // turns waiting for a slot
|
||||||
|
maxConcurrent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||||
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||||
|
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||||
|
//
|
||||||
|
// Trust handling: rather than answer the trust-folder dialog interactively (which
|
||||||
|
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
|
||||||
|
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
|
||||||
|
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readTuiTranscript } from "./transcript.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
|
||||||
|
// verification instance stood up alongside production — a real pattern used during
|
||||||
|
// PR #144/#146 verification) would boot-reap and potentially kill-server the OTHER
|
||||||
|
// instance's LIVE sessions: the coexistence guard below only ever spared foreign
|
||||||
|
// PRODUCT prefixes (olp-tui-*), never a second ocp-tui-* instance on a different port.
|
||||||
|
//
|
||||||
|
// Fix: scope the prefix to the instance's own listen port. The port is the natural
|
||||||
|
// stable per-instance discriminator on one host (two OCP instances cannot share a
|
||||||
|
// port), so `ocp-tui-<port>-` uniquely namespaces this instance's sessions and makes
|
||||||
|
// a same-host sibling OCP instance look exactly like a foreign product (olp-tui-*) to
|
||||||
|
// the coexistence guard — its `ocp-tui-<otherPort>-*` sessions never match our own
|
||||||
|
// prefix and are therefore never reaped/kill-server'd by us.
|
||||||
|
//
|
||||||
|
// LEGACY_SESSION_PREFIX / LEGACY_SESSION_NAME_RE describe the OLD bare-prefix shape
|
||||||
|
// (pre-this-fix), retained ONLY for the boot-time legacy-zombie migration handled in
|
||||||
|
// reapStaleTuiSessions (see comment there). No code path in this version ever CREATES
|
||||||
|
// a legacy-shaped session name again — sessionPrefixForPort() is the only session-name
|
||||||
|
// prefix constructor used going forward.
|
||||||
|
export const LEGACY_SESSION_PREFIX = "ocp-tui-";
|
||||||
|
// Exact legacy shape: LEGACY_SESSION_PREFIX + sessionId.slice(0, 8), where sessionId is
|
||||||
|
// a randomUUID() — so the suffix is always exactly 8 lowercase hex characters with NO
|
||||||
|
// further separator. The new port-scoped shape always inserts a "-" between the port
|
||||||
|
// digits and the 8-hex suffix (see sessionPrefixForPort), so this regex can never match
|
||||||
|
// a new-shape name: a new-shape suffix is `<port digits>-<8 hex>` (contains a literal
|
||||||
|
// "-"), which `[0-9a-f]{8}$` anchored immediately after the prefix cannot satisfy.
|
||||||
|
export const LEGACY_SESSION_NAME_RE = /^ocp-tui-[0-9a-f]{8}$/;
|
||||||
|
|
||||||
|
// Build this instance's own session-name prefix, scoped by its listen port so a
|
||||||
|
// second OCP instance on the same host (different port) is never mistaken for "ours".
|
||||||
|
export function sessionPrefixForPort(port) {
|
||||||
|
return `ocp-tui-${port}-`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
|
const defaultTmux = (args, opts = {}) =>
|
||||||
|
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
|
// Kill ONLY our own stale sessions. Scoped to sessionPrefixForPort(port) so a co-hosted
|
||||||
|
// OLP test instance's `olp-tui-*` sessions — AND a co-hosted second OCP instance's
|
||||||
|
// `ocp-tui-<otherPort>-*` sessions — are never touched (F7 fix).
|
||||||
|
//
|
||||||
|
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
|
||||||
|
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
|
||||||
|
// returns the instant the server forks the pane, so node never becomes its parent and
|
||||||
|
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
|
||||||
|
// here that parent is the tmux server). `kill-session` destroys the session but the server
|
||||||
|
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
|
||||||
|
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
|
||||||
|
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
|
||||||
|
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
|
||||||
|
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
|
||||||
|
// reparents its surviving children to init (PID 1), which reaps them immediately.
|
||||||
|
//
|
||||||
|
// `port` (required) is this instance's own listen port (server.mjs's PORT / lib/constants.mjs
|
||||||
|
// DEFAULT_PORT resolution) — the SPOT for "which sessions are ours."
|
||||||
|
//
|
||||||
|
// `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix
|
||||||
|
// shape (LEGACY_SESSION_NAME_RE) are ALSO treated as ours for kill-session purposes. This is
|
||||||
|
// the boot-time legacy migration: an operator upgrading past this fix could otherwise be left
|
||||||
|
// with orphaned bare-prefix zombie sessions from the PREVIOUS (pre-fix) process generation of
|
||||||
|
// this SAME instance, since no live instance of the new version ever creates that shape again
|
||||||
|
// — a legacy-shaped session found at boot is therefore presumed to be this instance's own
|
||||||
|
// leftover, not a stranger's. Passed true ONLY from the one-time boot-reap call site in
|
||||||
|
// server.mjs; the periodic idle-reap sweep does NOT set it, so a lingering legacy session
|
||||||
|
// during steady-state is conservatively treated as foreign (correctly blocking kill-server)
|
||||||
|
// rather than assumed to be ours on every 15-minute tick. Residual (accepted, documented):
|
||||||
|
// if a genuinely-still-running PRE-FIX OCP instance is coexisting on the same host at the
|
||||||
|
// exact moment a new instance boots, its live legacy-shaped session could be reaped — the
|
||||||
|
// same class of residual risk the audit finding itself accepts ("no live instance of the new
|
||||||
|
// version creates them"); this PR does not regress that scenario, it only removes the far
|
||||||
|
// more common same-version collision (the actual F7 finding).
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false } = {}) {
|
||||||
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const ownPrefix = sessionPrefixForPort(port);
|
||||||
|
let killed = 0;
|
||||||
|
let othersRemain = false;
|
||||||
|
for (const name of names) {
|
||||||
|
const isOwn = name.startsWith(ownPrefix);
|
||||||
|
const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name);
|
||||||
|
if (isOwn || isLegacyOwn) {
|
||||||
|
tmux(["kill-session", "-t", name]);
|
||||||
|
killed++;
|
||||||
|
} else {
|
||||||
|
othersRemain = true; // a session we do NOT own (olp-tui-*, a sibling ocp-tui-<otherPort>-*,
|
||||||
|
// or — outside includeLegacy — a legacy-shaped name) — never kill-server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
|
||||||
|
// kill-server is what actually reaps (server exit reparents survivors to init); a
|
||||||
|
// per-session kill cannot, since node is not the zombies' parent.
|
||||||
|
if (!othersRemain) {
|
||||||
|
tmux(["kill-server"]);
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||||
|
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||||
|
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Capture the visible tmux pane as plain text (for readiness / paste verification).
|
||||||
|
function tuiCapturePane(tmux, tmuxName) {
|
||||||
|
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||||
|
return (r && typeof r.stdout === "string") ? r.stdout : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once claude's input bar is rendered and ready for keystrokes.
|
||||||
|
function tuiInputReady(pane) {
|
||||||
|
return /\? for shortcuts/.test(pane);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||||
|
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
|
||||||
|
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
|
||||||
|
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
|
||||||
|
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
|
||||||
|
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
|
||||||
|
// (b) the prompt's own leading text appears in the pane (short/literal paste).
|
||||||
|
function tuiPromptLanded(pane, prompt) {
|
||||||
|
const flatPane = pane.replace(/\s+/g, " ");
|
||||||
|
if (flatPane.includes("[Pasted text")) return true;
|
||||||
|
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||||
|
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||||
|
// C-4/#133: threshold lowered 3 → 2. A prompt whose first non-blank line is 1–2
|
||||||
|
// chars ("hi", "ok") previously NEVER matched (needle.length >= 3) and never
|
||||||
|
// surfaced "[Pasted text", so EVERY short prompt 5s-failed with tui_paste_not_landed
|
||||||
|
// (live-reproduced: "hi"). The input box starts EMPTY (the curly-quote placeholder
|
||||||
|
// is excluded by the affirmative-signal design above), so a >=2-char needle present
|
||||||
|
// in the pane is the pasted prompt, not placeholder noise — false-positive risk is
|
||||||
|
// low. We keep >=2 rather than >=1 because a single visible char is more likely to
|
||||||
|
// collide with incidental glyphs in claude's chrome (borders, the "❯" prompt mark);
|
||||||
|
// 2 chars is the floor that lands real prompts while staying conservative.
|
||||||
|
return needle.length >= 2 && flatPane.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntil(fn, { timeoutMs, intervalMs }) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-quote escaper for sh -c arguments.
|
||||||
|
function shq(s) {
|
||||||
|
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
|
||||||
|
// the trust-folder dialog never appears. Verified-live trust shape:
|
||||||
|
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
|
||||||
|
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
|
||||||
|
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
|
||||||
|
// Must run BEFORE the session boots so claude reads the trusted record at startup.
|
||||||
|
export function ensureTuiCwdTrusted(home, cwd) {
|
||||||
|
if (!home || !cwd) return;
|
||||||
|
const path = `${home}/.claude.json`;
|
||||||
|
let j, mode;
|
||||||
|
try {
|
||||||
|
j = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
mode = statSync(path).mode & 0o777;
|
||||||
|
} catch { return; }
|
||||||
|
j.projects = j.projects || {};
|
||||||
|
const entry = j.projects[cwd] || {};
|
||||||
|
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
|
||||||
|
entry.hasTrustDialogAccepted = true;
|
||||||
|
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
|
||||||
|
j.projects[cwd] = entry;
|
||||||
|
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
|
||||||
|
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
|
||||||
|
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
|
||||||
|
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
|
||||||
|
// onboarding completion is an A-path precondition (the host already runs claude).
|
||||||
|
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
|
||||||
|
// a file we fully own, removing the real-config-mutation concern entirely.
|
||||||
|
try {
|
||||||
|
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
|
||||||
|
renameSync(tmp, path);
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
|
||||||
|
// token + an explicit OCP_TUI_HOME override:
|
||||||
|
//
|
||||||
|
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
|
||||||
|
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
|
||||||
|
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
|
||||||
|
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
|
||||||
|
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
|
||||||
|
// the token alone is insufficient.
|
||||||
|
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
|
||||||
|
// keeps exactly that home).
|
||||||
|
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
|
||||||
|
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
|
||||||
|
//
|
||||||
|
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
|
||||||
|
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
|
||||||
|
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
|
||||||
|
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
|
||||||
|
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
|
||||||
|
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
|
||||||
|
return realHome; // legacy real-home default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the HOME claude runs under. Three modes:
|
||||||
|
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||||
|
// in the real ~/.claude.json. The legacy default when no env token is set.
|
||||||
|
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
|
||||||
|
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
|
||||||
|
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
|
||||||
|
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
|
||||||
|
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
|
||||||
|
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
|
||||||
|
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
|
||||||
|
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
|
||||||
|
//
|
||||||
|
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
|
||||||
|
// env token passed + a broken ~/.claude/.credentials.json present → 401.
|
||||||
|
// env token passed + credentials.json moved aside → real answer.
|
||||||
|
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
|
||||||
|
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
|
||||||
|
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
|
||||||
|
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
|
||||||
|
// corruption incident at the root: with no credentials file, claude never runs the token-
|
||||||
|
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
|
||||||
|
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
|
||||||
|
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
|
||||||
|
// is no credentials file to fork and no refresh ever happens.)
|
||||||
|
//
|
||||||
|
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
|
||||||
|
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
|
||||||
|
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
|
||||||
|
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
|
||||||
|
// mode above avoids this entirely.
|
||||||
|
//
|
||||||
|
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
|
||||||
|
// Run BEFORE the session boots.
|
||||||
|
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
|
||||||
|
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||||
|
try {
|
||||||
|
const claudeDir = `${tuiHome}/.claude`;
|
||||||
|
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||||
|
if (!envTokenMode) {
|
||||||
|
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
|
||||||
|
// missing. Env-token mode deliberately skips this — no credentials file at all.
|
||||||
|
const link = `${claudeDir}/.credentials.json`;
|
||||||
|
if (!existsSync(link)) {
|
||||||
|
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
|
||||||
|
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
|
||||||
|
// a credential-isolated home should not inherit the operator's account/config state);
|
||||||
|
// in legacy mode carry the onboarded real config minus the user's project history.
|
||||||
|
const seedPath = `${tuiHome}/.claude.json`;
|
||||||
|
if (!existsSync(seedPath)) {
|
||||||
|
let base = {};
|
||||||
|
if (!envTokenMode) {
|
||||||
|
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||||
|
}
|
||||||
|
base.hasCompletedOnboarding = true;
|
||||||
|
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||||
|
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
|
||||||
|
ensureTuiCwdTrusted(tuiHome, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
|
// belt-and-braces with --disallowedTools "mcp__*".
|
||||||
|
// 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) {
|
||||||
|
// 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:
|
||||||
|
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
|
||||||
|
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
|
||||||
|
// billing pool (set below per entrypointMode).
|
||||||
|
//
|
||||||
|
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: OCP is a PROXY, not a Claude Code
|
||||||
|
// session. The proxied client (OpenClaw / an IDE) owns its own context and memory; the HOST's
|
||||||
|
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||||
|
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||||
|
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||||
|
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
||||||
|
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
||||||
|
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
||||||
|
const sets = [
|
||||||
|
`HOME=${shq(ehome)}`,
|
||||||
|
"DISABLE_AUTOUPDATER=1",
|
||||||
|
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
|
||||||
|
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||||
|
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
||||||
|
];
|
||||||
|
// CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the
|
||||||
|
// same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01),
|
||||||
|
// so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it,
|
||||||
|
// the TUI claude falls back to authenticating via <HOME>/.claude/.credentials.json, whose
|
||||||
|
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
|
||||||
|
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
|
||||||
|
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
|
||||||
|
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
|
||||||
|
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
|
||||||
|
//
|
||||||
|
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
|
||||||
|
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
|
||||||
|
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
|
||||||
|
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
|
||||||
|
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
|
||||||
|
//
|
||||||
|
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
|
||||||
|
// is added — behaviour is byte-for-byte unchanged from before this fix.
|
||||||
|
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||||
|
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
|
||||||
|
|
||||||
|
// Tool surface.
|
||||||
|
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
|
||||||
|
// built-in tools stay on, acceptable for single-user A-path.
|
||||||
|
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
|
||||||
|
// (--allowedTools [+ --mcp-config]), so a SINGLE-USER / trusted TUI deployment can
|
||||||
|
// run a tool-using agent (e.g. an OpenClaw assistant that needs Bash/Read/Write/MCP)
|
||||||
|
// on the subscription pool. ALWAYS uses --allowedTools (CLAUDE_SKIP_PERMISSIONS /
|
||||||
|
// --dangerously-skip-permissions is intentionally removed: claude v2.1.x shows an
|
||||||
|
// interactive bypass-acceptance screen in headless tmux that nothing can answer →
|
||||||
|
// the turn hangs until the wallclock cap, bricks the pane; not recoverable without a
|
||||||
|
// human at a keyboard). Use scratch-home settings.json additionalDirectories instead.
|
||||||
|
let toolArgs;
|
||||||
|
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
|
||||||
|
toolArgs = [];
|
||||||
|
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||||
|
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
|
||||||
|
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
|
||||||
|
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
|
||||||
|
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
|
||||||
|
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
|
||||||
|
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
|
||||||
|
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
|
||||||
|
} else {
|
||||||
|
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effort: pass --effort EXPLICITLY. Without it, the pane's claude inherits a
|
||||||
|
// HOME-dependent effortLevel — real-home mode inherits the operator's
|
||||||
|
// ~/.claude/settings.json (whatever they set for their own interactive use),
|
||||||
|
// env-token scratch mode inherits claude's built-in default (prepareTuiHome never
|
||||||
|
// writes effortLevel) — so latency silently depends on which HOME mode
|
||||||
|
// resolveTuiHome() picked AND on an unrelated operator setting. Pinning it here
|
||||||
|
// removes both. Measured (docs/plans/2026-07-13-tui-latency): explicit low cuts
|
||||||
|
// direct-spawn TTFT p50 10.35s → 6.17s (−40%) and collapses the spread ~15×;
|
||||||
|
// banner-verified to stay on the subscription pool (`· Claude Max`).
|
||||||
|
// OCP_TUI_EFFORT=inherit restores the pre-flag argv byte-for-byte (no --effort).
|
||||||
|
// An unknown value falls back to the default rather than reaching claude's argv:
|
||||||
|
// a typo'd --effort value must not risk a spawn-time usage error in the pane.
|
||||||
|
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; // claude 2.1.207 --help
|
||||||
|
const effortRaw = (process.env.OCP_TUI_EFFORT || "low").trim().toLowerCase();
|
||||||
|
let effortArgs;
|
||||||
|
if (effortRaw === "inherit") {
|
||||||
|
effortArgs = [];
|
||||||
|
} else if (EFFORT_LEVELS.includes(effortRaw)) {
|
||||||
|
effortArgs = ["--effort", effortRaw];
|
||||||
|
} else {
|
||||||
|
console.error(`[tui] invalid OCP_TUI_EFFORT=${JSON.stringify(process.env.OCP_TUI_EFFORT)}; using "low" (valid: ${EFFORT_LEVELS.join("|")}, or "inherit" to omit the flag)`);
|
||||||
|
effortArgs = ["--effort", "low"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
envPrefix,
|
||||||
|
shq(claudeBin),
|
||||||
|
"--model", shq(model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
...toolArgs,
|
||||||
|
...effortArgs,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full per-request TUI lifecycle:
|
||||||
|
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||||
|
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||||
|
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||||
|
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||||
|
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||||
|
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||||
|
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||||
|
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||||
|
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||||
|
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||||
|
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||||
|
// marker or wall-clock cap.
|
||||||
|
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||||
|
// 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).
|
||||||
|
export async function runTuiTurn({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
claudeBin,
|
||||||
|
home,
|
||||||
|
realHome,
|
||||||
|
cwd,
|
||||||
|
port,
|
||||||
|
wallclockMs = 120000,
|
||||||
|
entrypointMode = "cli",
|
||||||
|
tmux = defaultTmux,
|
||||||
|
}) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
|
||||||
|
// for why this instance's own listen port is the namespace discriminator.
|
||||||
|
const tmuxName = sessionPrefixForPort(port) + sessionId.slice(0, 8);
|
||||||
|
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||||
|
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||||
|
|
||||||
|
// Env-token-only mode: the env token is set AND claude runs in an isolated home
|
||||||
|
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
|
||||||
|
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
|
||||||
|
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
|
||||||
|
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
|
||||||
|
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
|
||||||
|
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
|
||||||
|
|
||||||
|
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||||
|
// cwd — before claude boots.
|
||||||
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
|
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||||
|
|
||||||
|
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||||
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const env = { ...process.env };
|
||||||
|
env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||||
|
// Capture the result: if tmux new-session fails (status !== 0) there is no
|
||||||
|
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
|
||||||
|
// into a non-existent session or issue a billing request without a verified
|
||||||
|
// interactive context. The finally teardown is still harmless (kill-session
|
||||||
|
// is a no-op when the session never existed).
|
||||||
|
const spawnResult = tmux(
|
||||||
|
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
|
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||||
|
{ env },
|
||||||
|
);
|
||||||
|
if (!spawnResult || spawnResult.status !== 0) {
|
||||||
|
throw new Error("tui_spawn_failed: tmux session not created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||||
|
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||||
|
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||||
|
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||||
|
if (!ready) {
|
||||||
|
// (readiness timed out; relying on paste-verify)
|
||||||
|
console.error("[tui] input_not_ready", tmuxName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||||
|
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||||
|
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||||
|
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
|
||||||
|
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
|
||||||
|
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
|
||||||
|
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
|
||||||
|
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
|
||||||
|
// per-session tmuxName, so concurrent turns never collide.
|
||||||
|
tmux(["load-buffer", "-b", tmuxName, promptFile]);
|
||||||
|
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
|
||||||
|
|
||||||
|
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
|
||||||
|
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
|
||||||
|
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
|
||||||
|
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
|
||||||
|
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
|
||||||
|
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
|
||||||
|
if (!landed) {
|
||||||
|
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit (separate Enter key event).
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
|
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||||
|
// Returns { text, entrypoint } from readTuiTranscript.
|
||||||
|
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||||
|
} finally {
|
||||||
|
// 5. Teardown — always, even on throw.
|
||||||
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||||
|
// and returns the latest assistant turn's text once the turn is terminal.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
||||||
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||||
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||||
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
||||||
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
||||||
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
||||||
|
// TODO: add a CI fixture-contract test (a captured real transcript) so schema drift
|
||||||
|
// in the claude JSONL format fails loudly rather than silently degrading.
|
||||||
|
export function findTranscriptPath(home, sessionId) {
|
||||||
|
if (!home || !sessionId) return null;
|
||||||
|
const root = `${home}/.claude/projects`;
|
||||||
|
let dirs;
|
||||||
|
try { dirs = readdirSync(root); } catch { return null; }
|
||||||
|
for (const d of dirs) {
|
||||||
|
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||||
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||||
|
export function parseTranscriptLines(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line marks the assistant turn complete when EITHER:
|
||||||
|
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
||||||
|
// (e.g. 2.1.159), OR
|
||||||
|
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
||||||
|
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
||||||
|
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
||||||
|
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
||||||
|
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
||||||
|
// then returns only partial text (issue #130, cloud/server-side symptom).
|
||||||
|
//
|
||||||
|
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||||
|
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||||
|
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||||
|
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||||
|
// cross-version completion detection without bringing that bug back.)
|
||||||
|
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||||
|
export function isTerminalLine(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||||
|
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
|
||||||
|
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||||
|
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
||||||
|
//
|
||||||
|
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
||||||
|
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
||||||
|
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
||||||
|
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
||||||
|
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
||||||
|
// author must add user-line scoping here. See spec §7.2.
|
||||||
|
export function extractLatestAssistantText(events) {
|
||||||
|
let text = "";
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || ev.type !== "assistant") continue;
|
||||||
|
const content = ev.message && ev.message.content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text);
|
||||||
|
if (parts.length) text = parts.join("");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
|
||||||
|
// or null if absent. Lets callers assert the subscription-classified path.
|
||||||
|
//
|
||||||
|
// Resolution order (C-3, issue #133):
|
||||||
|
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
|
||||||
|
// end-of-turn classifier emitted by builds that produce turn_duration
|
||||||
|
// (e.g. claude-2.1.104/2.1.157 on PI231).
|
||||||
|
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
|
||||||
|
// (assistant / user / attachment / system) — present on BOTH emitting and
|
||||||
|
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
|
||||||
|
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
|
||||||
|
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
|
||||||
|
// turn and go blind. The entrypoint value is identical across line types within
|
||||||
|
// a single interactive session (fixture-confirmed: every line in
|
||||||
|
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
|
||||||
|
// yields the same classifier. Last-writer-wins on the fallback.
|
||||||
|
export function verifyEntrypoint(events) {
|
||||||
|
let fallback = null;
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || typeof ev !== "object") continue;
|
||||||
|
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
|
||||||
|
return ev.entrypoint; // authoritative — short-circuit
|
||||||
|
}
|
||||||
|
if (ev.entrypoint != null) fallback = ev.entrypoint;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
|
||||||
|
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
|
||||||
|
// it renders the error as ordinary assistant text in the transcript. The specific
|
||||||
|
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
|
||||||
|
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
|
||||||
|
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
|
||||||
|
// records a model SUCCESS — so a hard auth error is silently served (and cached for
|
||||||
|
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
|
||||||
|
// (2026-06-10) are:
|
||||||
|
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
|
||||||
|
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
|
||||||
|
//
|
||||||
|
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
|
||||||
|
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
|
||||||
|
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
|
||||||
|
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
|
||||||
|
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
|
||||||
|
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
|
||||||
|
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
|
||||||
|
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
|
||||||
|
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
|
||||||
|
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
|
||||||
|
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
|
||||||
|
// R-1 case and the conservative choice is to let them through.
|
||||||
|
//
|
||||||
|
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
|
||||||
|
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
|
||||||
|
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
|
||||||
|
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
|
||||||
|
// slightly longer future banner, while still rejecting multi-sentence prose. A
|
||||||
|
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
|
||||||
|
// rejected on length alone.
|
||||||
|
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
|
||||||
|
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
|
||||||
|
// (no "API Error:" core).
|
||||||
|
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
|
||||||
|
// This rejects answers that quote a 4xx but are not auth banners, e.g.
|
||||||
|
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
|
||||||
|
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
|
||||||
|
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
|
||||||
|
// backticked/quoted text signals an answer that is QUOTING the error rather than
|
||||||
|
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
|
||||||
|
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
|
||||||
|
// breaker for short instructional answers.
|
||||||
|
//
|
||||||
|
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
|
||||||
|
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
|
||||||
|
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
|
||||||
|
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
|
||||||
|
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
|
||||||
|
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
|
||||||
|
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
|
||||||
|
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
|
||||||
|
// PASS: "HTTP 401 means unauthorized." (no API Error core)
|
||||||
|
// PASS: "The capital of France is Paris." (nothing matches)
|
||||||
|
//
|
||||||
|
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
|
||||||
|
// the default auth-banner detector with their own newline- or `||`-separated JS regex
|
||||||
|
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
|
||||||
|
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
|
||||||
|
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
|
||||||
|
|
||||||
|
// Whole-message length cap for the default auth-banner detector. Real banners are
|
||||||
|
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
|
||||||
|
const TUI_ERR_MAX_LEN = 100;
|
||||||
|
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
|
||||||
|
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
|
||||||
|
// Auth keyword — the message must be about authentication, not just quote a 4xx.
|
||||||
|
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
|
||||||
|
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
|
||||||
|
const TUI_ERR_CODE_CHAR = /[`'"]/;
|
||||||
|
|
||||||
|
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
|
||||||
|
// (all four signals above). Conservative — any signal failing => false (PASS).
|
||||||
|
function isDefaultAuthFailureBanner(trimmed) {
|
||||||
|
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
|
||||||
|
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
|
||||||
|
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
|
||||||
|
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
|
||||||
|
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
|
||||||
|
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
|
||||||
|
// the request path).
|
||||||
|
function compileTuiErrorPatterns(raw) {
|
||||||
|
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
const out = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
|
||||||
|
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
|
||||||
|
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
|
||||||
|
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
|
||||||
|
// - non-empty → operator regex override REPLACES the default.
|
||||||
|
// - empty/ws → detection disabled (escape hatch).
|
||||||
|
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
|
||||||
|
if (typeof text !== "string") return null;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (patternsRaw == null) {
|
||||||
|
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
|
||||||
|
const patterns = compileTuiErrorPatterns(patternsRaw);
|
||||||
|
if (patterns.length === 0) return null;
|
||||||
|
for (const re of patterns) {
|
||||||
|
if (re.test(trimmed)) return trimmed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until the session transcript is terminal (turn_duration / final
|
||||||
|
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
|
||||||
|
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
|
||||||
|
// - text: latest assistant text.
|
||||||
|
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
|
||||||
|
// - truncated: FALSE when a terminal marker was reached (the turn completed);
|
||||||
|
// TRUE when the wall-clock cap was hit with partial text but NO
|
||||||
|
// terminal marker (the turn is INCOMPLETE — what we have is a
|
||||||
|
// cut-off prefix). (C-2, issue #133.)
|
||||||
|
//
|
||||||
|
// Why `truncated` matters: previously the terminal-marker path and the
|
||||||
|
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
|
||||||
|
// callClaudeTui could not tell a complete answer from a truncated one and cached +
|
||||||
|
// returned the partial as finish_reason:stop (silent success). The caller now
|
||||||
|
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
|
||||||
|
// The field is additive — existing call sites that ignore it keep working.
|
||||||
|
//
|
||||||
|
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
|
||||||
|
//
|
||||||
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||||
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
|
// 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 }) {
|
||||||
|
const deadline = Date.now() + wallclockMs;
|
||||||
|
let lastText = "";
|
||||||
|
let lastEntrypoint = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const resolved = p || findTranscriptPath(home, sessionId);
|
||||||
|
if (resolved && existsSync(resolved)) {
|
||||||
|
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||||
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
|
const ep = verifyEntrypoint(events);
|
||||||
|
if (ep != null) lastEntrypoint = ep;
|
||||||
|
// Terminal marker reached → the turn is COMPLETE.
|
||||||
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
|
||||||
|
// so the caller rejects it (don't cache / don't count as success). No text at all
|
||||||
|
// → throw (nothing to return).
|
||||||
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
|
||||||
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
|
}
|
||||||
+9
-1
@@ -2,6 +2,14 @@
|
|||||||
"$schema": "./models.schema.json",
|
"$schema": "./models.schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"models": [
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "claude-opus-4-8",
|
||||||
|
"displayName": "Claude Opus 4.8",
|
||||||
|
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||||
|
"reasoning": true,
|
||||||
|
"contextWindow": 200000,
|
||||||
|
"maxTokens": 16384
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-7",
|
"id": "claude-opus-4-7",
|
||||||
"displayName": "Claude Opus 4.7",
|
"displayName": "Claude Opus 4.7",
|
||||||
@@ -36,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-4-7",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-4-6",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -573,21 +573,42 @@ Usage:
|
|||||||
ocp restart Restart the Claude proxy service
|
ocp restart Restart the Claude proxy service
|
||||||
ocp restart gateway Restart the OpenClaw gateway
|
ocp restart gateway Restart the OpenClaw gateway
|
||||||
(briefly disconnects all Telegram/Discord bots)
|
(briefly disconnects all Telegram/Discord bots)
|
||||||
|
|
||||||
|
Note (macOS): restart does a full launchctl bootout + bootstrap, NOT
|
||||||
|
`kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables,
|
||||||
|
so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually
|
||||||
|
takes effect. `kickstart -k` only re-execs the process and reuses launchd's
|
||||||
|
cached env, so env edits would be silently ignored. (Linux systemctl already
|
||||||
|
re-reads its EnvironmentFile on restart.)
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# macOS only: reload a launchd agent via bootout + bootstrap so plist
|
||||||
|
# EnvironmentVariables are re-read (kickstart -k would reuse the cached env).
|
||||||
|
# Args: <uid> <label> <plist-path>. Returns 0 iff bootstrap succeeds.
|
||||||
|
_launchd_reload() {
|
||||||
|
local uid="$1" label="$2" plist="$3"
|
||||||
|
[[ -f "$plist" ]] || return 1
|
||||||
|
# bootout may legitimately fail if the agent is not currently loaded — that's fine,
|
||||||
|
# we only require the subsequent bootstrap to succeed (the load that re-reads env).
|
||||||
|
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
|
||||||
|
launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
cmd_restart() {
|
cmd_restart() {
|
||||||
if [[ "${1:-}" == "gateway" ]]; then
|
if [[ "${1:-}" == "gateway" ]]; then
|
||||||
echo "Restarting gateway..."
|
echo "Restarting gateway..."
|
||||||
openclaw gateway restart 2>&1
|
openclaw gateway restart 2>&1
|
||||||
else
|
else
|
||||||
echo "Restarting proxy..."
|
echo "Restarting proxy..."
|
||||||
# Try current service name, then legacy, then manual restart
|
# Try current service name, then legacy, then manual restart.
|
||||||
|
# macOS: bootout+bootstrap (re-reads plist EnvironmentVariables — see cmd_restart_help).
|
||||||
|
# Linux: systemctl --user restart already re-reads its EnvironmentFile.
|
||||||
local uid
|
local uid
|
||||||
uid=$(id -u)
|
uid=$(id -u)
|
||||||
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
|
if _launchd_reload "$uid" "dev.ocp.proxy" "$HOME/Library/LaunchAgents/dev.ocp.proxy.plist"; then
|
||||||
true
|
true
|
||||||
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
|
elif _launchd_reload "$uid" "ai.openclaw.proxy" "$HOME/Library/LaunchAgents/ai.openclaw.proxy.plist"; then
|
||||||
true
|
true
|
||||||
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
||||||
true
|
true
|
||||||
|
|||||||
+9
-5
@@ -506,9 +506,11 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
||||||
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
|
# The server advertises anonymousKey in /health ONLY when the admin has set
|
||||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
||||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
# advertising exposes the shared key to any LAN-reachable device; issue #109).
|
||||||
|
# Localhost callers always receive it regardless. When the field is absent,
|
||||||
|
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
|
||||||
if [[ -z "$key" ]]; then
|
if [[ -z "$key" ]]; then
|
||||||
local anon_key
|
local anon_key
|
||||||
anon_key=$(echo "$health_json" | python3 -c "
|
anon_key=$(echo "$health_json" | python3 -c "
|
||||||
@@ -632,11 +634,12 @@ PYEOF
|
|||||||
{
|
{
|
||||||
echo ""
|
echo ""
|
||||||
echo "# OCP LAN (added by ocp connect)"
|
echo "# OCP LAN (added by ocp connect)"
|
||||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
echo "export OPENAI_BASE_URL='$base_url/v1'"
|
||||||
if [[ -n "$key" ]]; then
|
if [[ -n "$key" ]]; then
|
||||||
echo "export OPENAI_API_KEY=$key"
|
echo "export OPENAI_API_KEY='$key'"
|
||||||
fi
|
fi
|
||||||
} >> "$rc_file"
|
} >> "$rc_file"
|
||||||
|
chmod 600 "$rc_file" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
echo " Shell config:"
|
echo " Shell config:"
|
||||||
@@ -669,6 +672,7 @@ PYEOF
|
|||||||
echo "OPENAI_API_KEY=$key"
|
echo "OPENAI_API_KEY=$key"
|
||||||
fi
|
fi
|
||||||
} > "$env_dir/ocp.conf"
|
} > "$env_dir/ocp.conf"
|
||||||
|
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
||||||
echo ""
|
echo ""
|
||||||
echo " System-level (systemd):"
|
echo " System-level (systemd):"
|
||||||
echo " ✓ $env_dir/ocp.conf"
|
echo " ✓ $env_dir/ocp.conf"
|
||||||
|
|||||||
+10
-7
@@ -208,31 +208,34 @@ async function cmdTest() {
|
|||||||
async function cmdRestart(args) {
|
async function cmdRestart(args) {
|
||||||
const target = (args || "").trim().toLowerCase();
|
const target = (args || "").trim().toLowerCase();
|
||||||
const { execSync } = await import("node:child_process");
|
const { execSync } = await import("node:child_process");
|
||||||
|
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
||||||
|
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
|
||||||
|
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
|
||||||
try {
|
try {
|
||||||
if (target === "gateway") {
|
if (target === "gateway") {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
execSync(macGateway, { timeout: 15000 });
|
||||||
return "✓ Gateway restarted";
|
return "✓ Gateway restarted";
|
||||||
} else if (target === "all") {
|
} else if (target === "all") {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
execSync(macProxy, { timeout: 15000 });
|
||||||
// Gateway restart will kill this plugin too, so do it last
|
// Gateway restart will kill this plugin too, so do it last
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
execSync(macGateway, { timeout: 15000 });
|
||||||
return "✓ Proxy + Gateway restarted";
|
return "✓ Proxy + Gateway restarted";
|
||||||
} else {
|
} else {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
execSync(macProxy, { timeout: 15000 });
|
||||||
return "✓ Proxy restarted";
|
return "✓ Proxy restarted";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Try systemd for Linux
|
// Linux: systemd user services
|
||||||
try {
|
try {
|
||||||
if (target === "gateway") {
|
if (target === "gateway") {
|
||||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||||
return "✓ Gateway restarted";
|
return "✓ Gateway restarted";
|
||||||
} else {
|
} else {
|
||||||
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
|
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
|
||||||
return "✓ Proxy restarted";
|
return "✓ Proxy restarted";
|
||||||
}
|
}
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
|
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ocp",
|
"name": "ocp",
|
||||||
"version": "3.12.0",
|
"version": "3.16.2",
|
||||||
"description": "Slash commands for the OpenClaw Proxy",
|
"description": "Slash commands for the OpenClaw Proxy",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"openclaw": {
|
"openclaw": {
|
||||||
"type": "plugin",
|
"type": "plugin",
|
||||||
"id": "ocp",
|
"id": "ocp",
|
||||||
"pluginManifest": "openclaw.plugin.json"
|
"pluginManifest": "openclaw.plugin.json",
|
||||||
|
"extensions": ["./index.js"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.16.4",
|
"version": "3.21.1",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||||
// is stable enough for our hand-written templates in setup.mjs.
|
// is stable enough for our hand-written templates in setup.mjs.
|
||||||
|
|
||||||
|
// 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.
|
||||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||||
|
|
||||||
export function parsePlistEnv(plistContent) {
|
export function parsePlistEnv(plistContent) {
|
||||||
|
|||||||
+1240
-183
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
|||||||
// PROXY_ANONYMOUS_KEY — same pattern
|
// PROXY_ANONYMOUS_KEY — same pattern
|
||||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||||
|
|
||||||
|
// ── Inject-value helpers ─────────────────────────────────────────────────
|
||||||
|
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
||||||
|
function xmlEscape(v) {
|
||||||
|
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
// Validate an injected service value: no control chars (a newline would inject a
|
||||||
|
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
||||||
|
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
||||||
|
function assertSafeInjectValue(name, v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
if (/[\x00-\x1f]/.test(String(v))) {
|
||||||
|
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all three INJECT values before they are written into any service unit.
|
||||||
|
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
||||||
|
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
||||||
|
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
||||||
|
|
||||||
// ── Models: derived from models.json (single source of truth) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -119,18 +141,26 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check claude auth (quick test)
|
// Check claude auth (quick test)
|
||||||
try {
|
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
||||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
||||||
encoding: "utf-8",
|
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
||||||
timeout: 30000,
|
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
||||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
||||||
}).trim();
|
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
||||||
if (out.length > 0) {
|
} else {
|
||||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
try {
|
||||||
|
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 30000,
|
||||||
|
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||||
|
}).trim();
|
||||||
|
if (out.length > 0) {
|
||||||
|
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||||
|
warn("Make sure you're logged in: claude login");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
|
||||||
warn("Make sure you're logged in: claude login");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||||
@@ -403,17 +433,17 @@ if (!DRY_RUN) {
|
|||||||
<key>EnvironmentVariables</key>
|
<key>EnvironmentVariables</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
<string>${PORT}</string>
|
<string>${xmlEscape(PORT)}</string>
|
||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
||||||
<key>CLAUDE_BIN</key>
|
<key>CLAUDE_BIN</key>
|
||||||
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||||
<key>OCP_ADMIN_KEY</key>
|
<key>OCP_ADMIN_KEY</key>
|
||||||
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||||
<key>PROXY_ANONYMOUS_KEY</key>
|
<key>PROXY_ANONYMOUS_KEY</key>
|
||||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
+1991
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user