Backlog item #3 of docs/plans/2026-07-13-tui-latency/README.md. Every TUI request currently cold-boots a tmux+claude pane. This adds an OPT-IN pool of pre-booted panes. Recorded as ADR 0008 (docs/adr/0008-tui-warm-pane-pool.md), which extends ADR 0007. MEASURED (this host, Sonnet 4.6, --effort low, through a real OCP instance; a sample counts only if HTTP 200 AND the body carries the demanded marker): pool off (main code) n= 6 p50 10.17s [9164 9499 9760 10572 10774 11281] pool on, warm hits n=12 p50 6.00s [5286 5289 5520 5584 5621 5969 6040 6098 6280 7846 8036 11053] pool on, warm hits (post- n= 6 p50 5.62s [4729 4753 5236 6004 7548 9548] review-fix re-run) -> -4.17s / -41%. 12 hits / 1 miss / 0 bootFailures over 13 requests (and 6/1/0 on the post-fix re-run). Robust to counting the miss: n=13 p50 -> -40.6%. The plan doc predicted only -1.0s (the boot). It is ~4.2s because the cold path also pays ~2.9s INSIDE the first turn beyond claude's own reported turn_duration — post- input-bar init that an idle pane has already finished. Phase decomposition of the cold path (n=6 medians): prep 2ms | tmux spawn 27ms | boot->input-ready 1232ms | paste 8ms | paste-verify 426ms | submit->terminal 8458ms | teardown 8ms = 10162ms total, vs native turn_duration 5539ms => 4490ms of OCP-side overhead, of which the pool recovers ~1.26s of boot and ~2.9s of in-claude cold start. (The 426ms paste-verify is one 400ms poll tick; a real paste lands in ~80ms. Not addressed here — separate item.) DESIGN - SINGLE-USE panes. A pooled pane serves exactly ONE turn, then is killed and replaced in the background. Each carries its OWN fresh --session-id fixed at boot, so one session still holds one exchange. This is what keeps transcript.mjs's extractLatestAssistantText correct; its warning about a future warm pool reusing a session is answered in-place (comment updated) and left standing for anyone who later wants a second turn on a pane — that would be a cross-request TEXT LEAK and needs user-line scoping in the transcript reader first. - Pool keyed by model; --model is fixed at spawn. A miss falls back to the cold path with zero behaviour change. The pool warms the most recently requested model, so the first request after start (and after a model switch) is always a cold miss. - REAPER COEXISTENCE (the crux). An idle warm pane IS ours, and the periodic sweep runs precisely when we are idle. reapStaleTuiSessions() takes a `spare` set of EXACT live session names, and server.mjs DRAINS the pool immediately before the sweep: 1. a live pooled pane is never reaped — INCLUDING one still BOOTING (see below); 2. an orphaned pooled pane IS still reaped — membership is by exact name from a live in-memory registry, never by name shape, so a pane from a dead process generation has nothing claiming it. Omitting `spare` reaps MORE, never less (fail-safe); 3. kill-server is suppressed while any pane is spared — hence the drain, so the sweep still flushes <defunct> claude zombies (the only mechanism that can). - THE POOL TRACKS ITS IN-FLIGHT BOOT BY NAME, NOT AS A COUNT. bootTuiPane creates the tmux session SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS (20s) for the input bar, so a pooled session can be LIVE for ~20s before its boot resolves. Tracking boots as a count meant the pool could not name that session, which caused two real bugs (found in review, reproduced, fixed, and now regression-tested): * the reap sweep KILLED the booting pane (it could not be spared), left the pool empty with nothing scheduled, and logged the exact tui_pool_boot_failed WARN operators are told to alert on — for a completely healthy drain; * graceful shutdown ORPHANED a live authenticated idle `claude`: gracefulShutdown calls process.exit(0) in the SAME TICK as the drain (TUI panes are tmux children, so activeProcesses is empty and the wait-for-children path exits immediately), so cleanup deferred to a .then() never ran. Fix: the pool mints each pane's identity up front ({sessionId, name}) and holds it in _bootingPane. liveNames() includes it; drain() kills it SYNCHRONOUSLY. A generation counter distinguishes "cancelled by us" from "genuinely failed", so a drain never inflates bootFailures and resume() reliably starts a fresh boot. Deriving the name from the session-id also makes `tmux ls` correlate to the transcript file. - SLOT ACCOUNTING. Refill boots take NO TuiSemaphore slot (those bound real turns and would be starved); they cannot leak one either, since they never hold one. Refills are SERIALIZED, one boot at a time — live at size=2, two cold boots racing an in-flight turn overran the readiness cap and a refill was discarded. A genuinely failed boot does not re-kick the chain (backoff; a broken claude must not respawn forever). Background boots get a more generous readiness cap (POOL_BOOT_MS = 5x BOOT_MS): BOOT_MS is tight because a client is blocked on it, which is not true of a pre-boot. - BOUNDED COST. A warm pane is a LIVE idle claude process held whether or not a request arrives. Peak processes = pool size + OCP_TUI_MAX_CONCURRENT + 1 booting replacement. Size clamped to POOL_MAX_SIZE=4; garbage values disable rather than guess. Panes have a 10-min TTL and a health check at hand-out (dead/degraded pane => miss, never a hang). Missing collaborators throw at CONSTRUCTION, not on a live request (refill() is called synchronously from the request path). DEFAULT OFF (OCP_TUI_POOL_SIZE=0). This is a stable production path and the pool holds standing processes, so the operator opts in. With the pool off, runTuiTurn takes the IDENTICAL code path as before (the `pool ? pool.acquire() : null` branch yields null, and tuiPool is null so no observer is attached and no new log line is emitted) — that is what establishes the default path is unchanged. A pool-off control run (n=6, p50 9.40s) is consistent with the 10.17s baseline but had 2/6 samples >12s, so it is corroboration, NOT proof: n=6 cannot establish "unregressed" on its own. The code-path equivalence can. BANNER: NO SPAWN ARGUMENT CHANGED. buildTuiCmd is byte-identical to main (verified by extracting the function body from both revisions and comparing). Live banner captured from two real POOLED panes anyway: "Sonnet 4.6 with low effort · Claude Max" — the subscription pool, never "API Usage Billing". /health: `tui.pool` added (null when off), incl. `cancelled` (boots WE killed — not a fault; do not alert on it). The tui block is ADR-0007-owned and post-dates ADR 0006's v3.16.4 grandfather snapshot; the addition is purely additive — every pre-existing key keeps a byte-identical value. Authorization recorded in ADR 0008. ALIGNMENT: Class B / ADR 0007 + ADR 0008 (OCP-owned TUI spawn machinery). cli.js does NOT perform this operation — there is no cli.js citation and none is required: this is not an Anthropic API surface, it is OCP's own process management around the claude CLI, exactly as the existing tmux session lifecycle and reaper already are (ALIGNMENT.md Rule 2). TESTS: 294 passed / 0 failed (was 267). +27 covering acquire/hit/miss, single-use (a pane is never handed out twice), bounded + serialized refill, TTL + health-check drops, model retarget, drain/resume, boot-failure backoff, identity linkage, all three reaper invariants incl. post-drain kill-server restoration, and — the coverage gap that let both bugs ship — FIVE mid-boot tests: the booting pane is nameable/spareable, the sweep's drain kills it and resume starts a fresh boot with no bogus WARN, shutdown kills it synchronously (asserted WITHOUT awaiting, since process.exit runs in the same tick), a stale settle cannot clear a newer boot's slot, and a model switch cancels an in-flight boot for the old model. Live verification (temporary 20s reap interval, reverted): sweep drained both panes -> reaped -> refilled with NEW panes; a foreign tmux session survived untouched; with no foreign session kill-server fired and the pool still recovered and served the next request. Both review bugs reproduced against a PRIVATE tmux server (-L pr3repro, so the reaper's internal kill-server could not touch the host) before and after the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 KiB
ADR 0008 — TUI Warm Pane Pool
Date: 2026-07-13 Status: Proposed Extends: ADR 0007 (TUI interactive mode). This ADR does not change ADR 0007's billing-pool argument, security posture, or kill-switch — it adds a latency optimization inside the TUI spawn machinery ADR 0007 owns.
Context
TUI mode (ADR 0007) serves every request by cold-booting a fresh tmux session running an
interactive claude, submitting one prompt, reading the native transcript, and killing the
session. That cold boot is paid on every request.
docs/plans/2026-07-13-tui-latency/ measured the
TUI path and listed a warm pane pool as backlog item #3, costed at "~1.0 s" (the observed
boot-to-input-bar time). Instrumenting the real request path showed that estimate is ~4×
too low. Phase decomposition of the cold path (n=6 medians, Sonnet 4.6, --effort low,
through a real OCP instance):
| Phase | Median |
|---|---|
| prep (trust cwd, write prompt file) | 2 ms |
tmux new-session |
27 ms |
| boot → input bar ready | 1232 ms |
paste (load-buffer + paste-buffer) |
8 ms |
| paste-verify poll | 426 ms |
| submit → transcript terminal | 8458 ms |
| teardown | 8 ms |
| total | 10162 ms |
claude's own reported turn_duration |
5539 ms |
| OCP-side overhead | 4490 ms |
The submit → terminal phase exceeds claude's own turn_duration by ~2.9 s. That gap is
post-input-bar initialization inside claude — work that a pane which has merely sat idle
for a few seconds has already completed. A direct spike confirmed it: an identical pane, idle
12 s before receiving the same prompt, completed its turn in a median 5537 ms versus 7980 ms
cold.
So a warm pane recovers ~1.26 s of boot and ~2.9 s of in-claude cold start — not the
~1.0 s the plan predicted.
The reason this was worth a pool rather than a "keep one session and reuse it" cache is a
hazard already flagged in the code. lib/tui/transcript.mjs returns the last text-bearing
assistant entry in the whole transcript file, which is correct only under OCP's
one-session-per-request model, and it says so:
"If a future warm-pool ever reuses a session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that author must add user-line scoping here."
Reusing a pane for a second turn puts two exchanges in one transcript and would leak the earlier turn's text into the later turn's answer — a cross-request data leak, not merely a bug.
Decision
Add an opt-in pool of pre-booted, single-use claude panes, OCP_TUI_POOL_SIZE (default
0 = off, max 4). Implementation: lib/tui/pool.mjs.
1. Panes are SINGLE-USE. This is the load-bearing rule.
A pooled pane serves exactly one turn, then is killed and replaced in the background. Each
pane is booted with its own fresh --session-id, fixed at spawn, and the turn locates its
transcript by that id.
This preserves one-session-per-request exactly, so the transcript.mjs hazard above does not
arise and no user-line scoping was needed. The warning in transcript.mjs is deliberately
left standing, now annotated: it still binds anyone who later wants a pane to serve a second
turn, or to reset a session with /clear and reuse it. Neither is permitted without first
adding user-line scoping to the transcript reader.
Rejected alternative — reuse a pane for N turns, /clear between — is strictly cheaper
(no re-boot per request) and was rejected on exactly this basis. The latency win is not worth a
cross-request text-leak surface guarded only by a /clear that we cannot verify landed.
2. The pool is keyed by model, and a MISS is always safe.
--model is fixed at spawn, so a pane can only serve the model it booted with. A pool miss
falls back to the existing cold-boot path with zero behavioural difference. There is no
boot-time pre-warm and no configured model: OCP cannot know which model the next caller wants,
so the pool warms the most recently requested model. Consequence, stated plainly: the
first request after start, and the first after any model switch, is always a cold miss.
3. The pool and the session reaper coexist by an explicit invariant.
This is the subtle part. reapStaleTuiSessions() kills every session matching this instance's
ocp-tui-<port>- prefix, and issues tmux kill-server when no foreign session remains (the
only mechanism that can reap <defunct> claude zombies — the pane's claude is a child of
the tmux server, not of node). A warm pooled pane is one of our own sessions, alive and
idle by design — and the periodic sweep runs precisely when the instance is idle, i.e.
exactly when the pool is full.
The invariant, stated in a comment above reapStaleTuiSessions and pinned by tests:
- A live pooled pane is never reaped — including one that is still BOOTING. The reaper
takes a
spareset of exact session names supplied by the pool's live registry. - An orphaned pooled pane IS still reaped. Membership is by exact name from a live
in-memory registry, never by name shape. A pane the pool no longer owns — handed out,
dropped, cancelled, or left behind by a previous process generation (whose registry died with
it) — is absent from
spareand is killed like any other stale session. Fail-safe: omittingsparereaps more, never less. Pool panes are namedocp-tui-<port>-p<hex>purely for operator legibility; that shape is not the exemption mechanism. kill-serveris suppressed while any pane is spared (it would kill a live child of the tmux server). Therefore the pool is DRAINED immediately before every sweep, sospareis empty on the normal tick andkill-serverstill fires. Without the drain, a permanently-full pool would permanently disable zombie reaping — the pool would silently break the thing the sweep exists to do. The drain costs one pane re-boot per tick (15 min).
The spare mechanism is belt-and-braces given the drain: it makes it impossible for a reap call
site that forgets to drain to kill a live pane.
4. The pool tracks its in-flight boot BY NAME, not as a count.
bootTuiPane creates the tmux session synchronously and only then waits (up to
POOL_BOOT_MS, 20 s) for the input bar. So a pooled tmux session can be live for ~20 s before
its boot resolves. A pool that tracked in-flight boots as a count could not name that
session, and this produced two real bugs (both caught in review, both now regression-tested):
- the periodic sweep killed the booting pane (it could not be spared), then left the pool
empty with nothing scheduled, and logged the exact
tui_pool_boot_failedwarning operators are told to alert on — for a completely healthy drain; - graceful shutdown orphaned a live, authenticated, idle
claude:gracefulShutdowncallsprocess.exit(0)in the same tick as the drain (TUI panes are tmux children, so node'sactiveProcessesset is empty and the "wait for children" path exits immediately), so any cleanup deferred to a.then()never ran.
The pool therefore mints each pane's identity up front ({sessionId, name}, name derived
from the session-id so tmux ls correlates to the transcript file) and holds it in
_bootingPane. liveNames() includes it; drain() kills it synchronously. A generation
counter distinguishes "cancelled by us" from "genuinely failed", so a drain never inflates
bootFailures and resume() reliably starts a fresh boot.
5. Refills take no concurrency slot, and are serialized.
A refill boot deliberately does not take a TuiSemaphore slot: those slots bound concurrent
turns and belong to real requests, and charging a background pre-boot against them would let
the pool starve the traffic it exists to speed up. It cannot leak a slot either, since it never
holds one. Boots are serialized (one at a time): two cold boots racing an in-flight turn were
observed to overrun even the generous pool readiness cap. A genuinely failed boot does not
re-kick the chain (backoff — a broken claude must not respawn forever).
Background boots get a more generous readiness cap (POOL_BOOT_MS = 5 × BOOT_MS): BOOT_MS is
tight because a client is blocked on it, which is not true of a pre-boot. Slow ≠ broken.
Consequences
Cost — standing processes, paid whether or not a request arrives
A warm pane is a live idle claude process. Peak process count is
OCP_TUI_POOL_SIZE + OCP_TUI_MAX_CONCURRENT + 1 (booting replacement). This is the whole
reason the pool is default-off: an operator must opt into holding processes for traffic that
may never come. Size is clamped to POOL_MAX_SIZE = 4; an unparseable value disables the
pool rather than guessing.
Panes carry a 10-minute TTL and are health-checked at hand-out; a dead or degraded pane becomes a miss (cold path), never a hung turn.
Benefit
Measured end-to-end through a real OCP instance (Sonnet 4.6, --effort low):
p50 10.17 s (n=6, pool off) → 6.00 s (n=12 warm hits) — −4.2 s / −41%.
The floor is unchanged
The pool does not touch the ~6 s TTFT floor documented in the latency plan (claude always prefills the full Claude Code system prompt). TUI mode remains unsuitable for interactive / real-time consumers; it is for batch and background work. This ADR does not change that conclusion.
Observability
/health's tui block gains a pool sub-object (null when off): size, warm, booting,
model, hits, misses, boots, bootFailures, cancelled, dropped. A climbing
bootFailures means panes are not reaching their input bar — the pool then degrades safely to
the cold path, but latency reverts to the un-pooled numbers. A steadily climbing dropped is
normal (the 15-min sweep drains and re-boots the pool on every tick, by design — see
Decision 3).
ALIGNMENT authorization
- Class B / OCP-owned. The warm pool is process management around the
claudeCLI — the same category as the existing tmux session lifecycle and the defunct-session reaper it extends.cli.jsdoes not perform this operation, and nocli.jscitation applies; the authority is ADR 0007 (which owns the TUI spawn machinery) plus this ADR. This isALIGNMENT.mdRule 2's Class B citation requirement, discharged explicitly rather than by silence. - The
/healthextension adds sub-fields to thetuiblock. That block is owned by ADR 0007 and post-dates ADR 0006's v3.16.4 grandfather snapshot, so it is not part of the frozen B.2 inventory. The change is additive — every pre-existing/healthfield keeps a byte-identical value, andpoolisnullunless the operator opts in — which is the behaviour-preserving bar ADR 0006 sets. This ADR records that authorization. - No spawn argument changed.
buildTuiCmdis byte-identical; the pool calls it with the same arguments. Banner-verified on live pooled panes:· Claude Max, neverAPI Usage Billing(the--baretrap documented in the latency plan).
What a future contributor must not undo
- Do not let a pane serve a second turn (or
/clear-and-reuse one) without first adding user-line scoping tolib/tui/transcript.mjs. That is a cross-request text leak, not a perf tweak. See Decision 1. - Do not remove the drain-before-sweep. It is what keeps
kill-serverzombie reaping alive. See Decision 3. - Do not go back to counting in-flight boots. The pool must be able to name a session that exists but has not finished booting. See Decision 4.