Maintainer-approved P2 restructure. Principle: what a new user needs in the first
10 minutes stays in README; the operations manual moves to docs/. Content MOVED,
not rewritten — an independent verifier swept all 20 original sections, 60+
distinctive facts, and all table rows against the new corpus: zero content loss.
New files (verbatim moves + two mandated dedup merges):
- docs/lan-mode.md (396) — LAN setup, key management, quotas, anonymous access,
deployment/security model + honest limits, client connect
- docs/tui-mode.md (196) — full TUI section + the four giant env-cell essays as
prose subsections; opens with the single-user SECURITY
warning + the PAUSED billing-split status banner
- docs/troubleshooting.md (136) — full troubleshooting; canonical 401/credential-
isolation explanation (union of the 4 prior copies)
- docs/upgrading.md (79) — upgrade paths, snapshots, rollback, auto-sync
README keeps: pitch (byte-identical), new TOC, 62-line Quickstart, How It Works
verbatim (incl. #171 billing-status note + workload fit), the three release_kit-
pinned tables (Available Models / API Endpoints / all 37 Environment Variables
rows — 4 giant TUI cells now one-line pointers), All Commands, slim Troubleshooting
(bootstrap quirks retained per release_kit bootstrap_quirk_policy), summary stubs
linking each moved doc, Repository Layout (+4 doc rows), Governance, Support.
Dedup (canonical copies): sdk-cli vs subscription-pool table → docs/tui-mode.md;
credential-isolated-home / permanent-401 → docs/troubleshooting.md#tui-401.
Link retargets: docs/runbooks/615-canary.md, docs/runbooks/tui-flip-rollback.md,
setup.mjs (one banner string; node --check clean). 126 links across the touched
files verified resolving; repo-wide grep shows no reference to a removed anchor.
npm test: 332 passed / 0 failed.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
25 KiB
Part of OCP — subscription-pool (TUI) mode: serve requests through interactive claude so they bill the Pro/Max subscription pool instead of the metered Agent SDK path.
Subscription-pool (TUI) mode
SECURITY — read before enabling.
TUI-mode is single-user / single-operator only.clauderuns with the OCP process owner's filesystem access regardless ofHOMEsetting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. Never enableCLAUDE_TUI_MODE=trueon a multi-user OCP.
What it is and why
⚠️ Status (as of 2026-07): the billing split below is PAUSED. Anthropic announced it for 2026-06-15, then paused it on the effective date — "For now, nothing has changed: Claude Agent SDK,
claude -p, and third-party app usage still draw from your subscription's usage limits" (official help article). While the pause holds, OCP's default-ppath bills the subscription and TUI-mode is a hedge, not a necessity. The table describes the announced regime, kept here because Anthropic says a reworked change will return (with advance notice) — everything in this section is ready to flip on that day.
The announced routing keys claude invocations by cc_entrypoint:
| Launch method | cc_entrypoint |
Billing pool (announced regime, currently paused) |
|---|---|---|
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 under that regime. 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)
# 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.
- Real streaming is opt-in (
OCP_TUI_STREAM=1), and off by default. By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. SetOCP_TUI_STREAM=1andstream:trueturns emit real SSEdelta.contentchunks asclauderenders them, sourced fromclaude's ownMessageDisplayhook (byte-faithful raw markdown, on the subscription pool, no-p). Two honest caveats: granularity is block-level — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the first byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is failed rather than served (watchtui.streamDivergenceson/health). Evidence: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 runsclaudewithCLAUDE_CODE_DISABLE_CLAUDE_MDS+CLAUDE_CODE_DISABLE_AUTO_MEMORY, so aCLAUDE.mdon 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_TOKENin 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 spawnedclaudewhenCLAUDE_CODE_OAUTH_TOKENis present. With the env token set andOCP_TUI_HOMEunset, OCP runs claude in a credential-isolated home ($HOME/.ocp-tui/home) that has nocredentials.json— so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stalecredentials.jsonfrom shadowing the token and ends the refresh-token corruption behind the permanentPlease run /login · API Error: 401(full two-layer root cause, live proof, and fix in Troubleshooting § the permanent TUI-mode 401). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home'scredentials.json(byte-for-byte the previous behaviour). (The token is visible inpson 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
claudeis a child of the tmux server (not OCP), so OCP cannot reap it directly;claudezombies can otherwise accumulate as<defunct>over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuingtmux kill-server— but only when no foreign tmux session remains (it never disrupts a co-hostedolp-tui-*instance). See ADR 0007 PR-C amendment. - Default path unchanged. Unset
CLAUDE_TUI_MODEand restart →callClaude/callClaudeStreamingare 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(default2), independent ofCLAUDE_MAX_CONCURRENT. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactiveclaudesessions at once. - Optional warm pane pool (
OCP_TUI_POOL_SIZE, default off). Pre-boots panes so a request skips the cold boot — measured p5010.17s→6.00s(−41%). Pooled panes are single-use (one turn, then killed and replaced in the background), each carrying its own fresh--session-id, so one session still means one exchange and no earlier-turn text can leak into a later answer. They are namedocp-tui-<port>-p<hex>and coexist with the reaper by design: the sweep drains the pool first, then reaps (sokill-serverstill flushes<defunct>zombies), then the pool refills in the background. Drain→reap→resume is synchronous, so no request can land mid-sweep; a request arriving while the pool is still re-booting simply misses it and cold-boots. A live pooled pane is never reaped — including one that is still booting, whose tmux session already exists — while an orphaned one (left by a previous process generation) still is.
⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
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:
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 under the announced split, if it re-lands — a lost TTY flipping cc_entrypoint from cli to sdk-cli would still return answers but land in the metered pool). The block is always present (with enabled:false when TUI-mode is off):
"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
"pool": { // warm pane pool — null when OCP_TUI_POOL_SIZE=0 (the default)
"size": 2, // target warm panes (OCP_TUI_POOL_SIZE)
"warm": 2, // panes ready right now — each is a LIVE idle claude process
"booting": 0, // replacement panes currently pre-booting
"model": "claude-sonnet-4-6", // the model being warmed (the most recently requested one)
"hits": 12, // requests served by a warm pane
"misses": 1, // requests that fell back to the cold boot (the 1st is always one)
"boots": 14, // panes successfully pre-booted
"bootFailures": 0, // pre-boots that genuinely never reached the input bar — WATCH this
"cancelled": 4, // in-flight boots OCP killed on purpose (drain / model switch) — not faults
"dropped": 8 // panes discarded unused (drain sweep / expired / unhealthy)
}
}
Alert on entrypointMismatches > 0 (or lastEntrypoint !== "cli"): it means a turn drew from the metered Agent SDK pool instead of the subscription. inflight / queued show how close the TUI path is to its concurrency cap.
With the pool on, hits / misses is the hit rate (a steady single-model consumer should sit near 100% after the first request), and warm is your standing idle-process cost. A climbing bootFailures means panes are not reaching their input bar — the pool then degrades safely to the cold path, but latency reverts to the un-pooled numbers. cancelled counts boots OCP killed on purpose (a drain, a model switch) and is not a fault signal — do not alert on it. A steadily climbing dropped is likewise normal: the 15-min reap sweep drains and re-boots the pool on every tick so kill-server can still flush <defunct> zombies.
Kill-switch
unset CLAUDE_TUI_MODE
# restart OCP
The stream-json path is restored immediately. No other change is needed.
Operator checklist for the (paused) billing split
Status: the 2026-06-15 split never took effect — Anthropic paused it on the effective date (see the status note at the top of this section). Nothing needs flipping while the pause holds. The checklist is retained verbatim as the runbook for if/when a reworked change lands (Anthropic has promised advance notice).
Under the announced regime, every host serving traffic must be flipped to TUI-mode and canary-verified before the effective date, or it will bill the metered Agent SDK credit pool instead of the subscription.
- Flip/rollback runbook — how to set
CLAUDE_TUI_MODE=trueon systemd (Linux) and launchd (macOS) hosts. Covers thedaemon-reloadrequirement (systemd) and thebootout+bootstrapcycle requirement (launchd —launchctl kickstart -kdoes not reload plist env). - 615-canary runbook — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after.
entrypoint:cliin the transcript (thecc_entrypointbilling 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 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.
TUI-mode environment variables
The README Environment Variables table lists these as one-line pointers; the full behaviour of each lives here.
OCP_TUI_STREAM — real SSE streaming (opt-in)
OCP_TUI_STREAM default 0 (off). When =1, stream:true requests emit real SSE delta.content chunks as claude generates them, instead of buffering the turn and replaying it. Deltas come from claude's own MessageDisplay hook (registered with --settings on the ordinary interactive spawn — banner-verified to stay on the subscription pool, · Claude Max). Granularity is block-level, not token-level. The transcript remains authoritative: the streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and only the transcript text is cached. A turn whose stream cannot be reconciled with the transcript is refused (SSE error frame, not cached) and counted as tui.streamDivergences on /health. A total hook failure (e.g. --settings stops registering it after a claude version bump) is a different, silent failure mode — every streamed turn still succeeds, fully buffered, with no divergence and no error — so it is counted separately as tui.streamZeroDeltaTurns (streamed turns where the hook fired zero times) and logged as tui_stream_zero_deltas; watch it alongside streamDivergences. Default off — the buffered path is unchanged and remains the stable default. ⚠️ Tool-using turns: the transcript keeps only the model's last assistant message, so if the model narrates before calling a tool ("I'll check that file…") and that narration exceeds OCP_TUI_STREAM_HOLDBACK, it has already been streamed and cannot be retracted — the turn is then refused rather than served (measured live: Opus narrated 475 chars before a Bash call). If your deployment lets the model use tools (the TUI default, and anything with OCP_TUI_FULL_TOOLS=1), either raise OCP_TUI_STREAM_HOLDBACK above the typical narration length — the narration then stays held back and is correctly discarded, at the cost of a later first chunk — or leave streaming off. Streaming is best suited to tool-light chat proxying. See ADR 0007 (2026-07-13 amendment).
Two related streaming knobs:
OCP_TUI_STREAM_DIR(default$HOME/.ocp-tui/stream) — directory holding the staticMessageDisplayhook script + settings file, and the per-session delta sink (<session-id>.jsonl, removed at turn teardown). One sink per session-id — this is what keeps concurrent TUI turns (OCP_TUI_MAX_CONCURRENT≥ 2) from interleaving one client's deltas into another's stream.OCP_TUI_STREAM_POLL_MS(default100) — interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing.
OCP_TUI_STREAM_HOLDBACK
OCP_TUI_STREAM_HOLDBACK default 100. (TUI-mode, streaming) Characters withheld before the first chunk reaches the client. Two jobs. (1) It keeps the auth-banner gate alive under streaming, via a guarantee with two required halves: (i) nothing is emitted for a message until its trimmed accumulation exceeds 100 chars — past the default banner detector's reach, since real banners are ≤100 chars — and (ii) once a message boundary follows an emit, nothing further is ever emitted for the rest of the turn, and the turn is refused outright. Half (i) alone only covers a turn's first message; half (ii) is what covers an error banner rendered as a later message (e.g. after tool-using prose). Raise the holdback if you replace the detector via CLAUDE_TUI_ERROR_PATTERNS with patterns that can match longer messages — that only affects half (i); OCP warns at boot if you do. (2) It is the knob for tool-using turns — see the OCP_TUI_STREAM caveat above. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does.
OCP_TUI_POOL_SIZE — warm pane pool
OCP_TUI_POOL_SIZE default 0 (off). Number of pre-booted warm claude panes kept ready, so a request does not pay the cold boot. 0 disables the pool entirely — the request path is then exactly the cold-boot path. Max 4; an unparseable value disables it rather than guessing. Measured on a Mac mini (Sonnet 4.6, --effort low): end-to-end p50 10.17s (n=6, pool off) → 6.00s (n=12 warm hits) — −4.2 s / −41% — the pool recovers both the ~1.2 s boot and ~2.9 s of post-input-bar init that a pane which has been idle a moment has already finished. Cost: each warm pane is a live idle claude process held whether or not a request ever arrives (peak processes ≈ pool size + OCP_TUI_MAX_CONCURRENT + 1 booting replacement) — which is why it is opt-in. Panes are single-use: one turn, then killed and replaced in the background. The first request after start (and after any model switch) is always a cold miss — the pool warms the most recently requested model, since OCP cannot know which model the next caller wants. See plans/2026-07-13-tui-latency/.
OCP_TUI_FULL_TOOLS — full tool surface (single-user only)
OCP_TUI_FULL_TOOLS default (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 ADR 0007.
Other TUI-mode variables
OCP_TUI_MAX_CONCURRENT(default2) — Max concurrent interactive TUI turns. Independent ofCLAUDE_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 toCLAUDE_TUI_WALLCLOCK_MSwallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns queue (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment.OCP_TUI_ENTRYPOINT(defaultcli) — Billing-classifier labeling:cli(default) pinscc_entrypoint=clideterministically;autolets claude self-classify via TTY detection;offleaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see the "Billing-classifier labeling" section above and ADR 0007.OCP_TUI_EFFORT(defaultlow) — Effort level passed to the interactiveclaudeas an explicit--effortflag:low(default),medium,high,xhigh,max, orinheritto omit the flag (the pre-flag behaviour: the pane inherits a HOME-dependent effort — the operator's~/.claude/settings.jsoneffortLevelin real-home mode, claude's built-in default in env-token scratch mode). Explicitlowcuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inheritedxhigh(seeplans/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 tolow.OCP_TUI_HOME(default (auto)) —HOMEclaude runs under. When unset, OCP picks it for you: ifCLAUDE_CODE_OAUTH_TOKENis set → a credential-isolated scratch home$HOME/.ocp-tui/home(nocredentials.json, env-token auth — recommended); if no env token → the operator's real home (legacy sharedcredentials.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, nocredentials.jsonwritten); 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. If you previously set this to the real home (or any home containing acredentials.json) and hit a permanent 401, unset it — see Troubleshooting § the permanent TUI-mode 401.CLAUDE_TUI_WALLCLOCK_MS(default120000) — Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns.OCP_TUI_CWD(default$HOME/.ocp-tui/work) — Scratch working directory where interactive claude sessions run. Transcripts land under<HOME>/.claude/projects/<encoded-cwd>/. Created automatically.CLAUDE_CODE_OAUTH_TOKEN— the recommended TUI credential; when set (andOCP_TUI_HOMEunset) it selects the credential-isolated home. Full precedence and the 401 root cause it prevents are in Troubleshooting § the permanent TUI-mode 401.