feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE (−41%) (#158)

* feat(tui): warm pane pool — single-use pre-booted panes, opt-in via OCP_TUI_POOL_SIZE

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>

* fix(tui): kill a cancelled boot's pane when it settles + make async tests actually count

Folds in the independent review's remaining nit — and, in proving the nit's fix, uncovers
two defects in the test suite itself.

## The nit (latent M1b, second costume)

`_cancelBooting` kills BY NAME, but the tmux session only EXISTS once `bootPane` has run —
and `bootPane` is queued on a microtask. So a caller doing `refill()` then `drain()` in the
SAME synchronous block leaves `_cancelBooting` with nothing to kill (a no-op); it bumps the
generation, and the boot microtask then CREATES the session, succeeds, and — under the old
bare `return` on a stale generation — walked away from a LIVE authenticated `claude` that
nothing owns. Reproduced:

  reverted: drain() kills nothing (no session yet) -> boot creates it -> ORPHAN: ['p1']
  fixed   : drain() kills nothing (no session yet) -> boot creates it -> boot kills it -> []

Not reachable from any current call site, so this is defense-in-depth — but ADR 0008 and the
reap-tick comment in server.mjs BOTH explicitly contemplate a boot-time pre-warm, which is
exactly the shape that reaches it. Killing an already-dead session is a harmless no-op, so
the fix is idempotent whichever way the race lands.

## Defect 1 in the suite: async tests were never awaited (44 of them)

Writing the regression guard exposed this. `test()` called `fn()`, got a promise back, and
IMMEDIATELY printed ✓ and incremented `passed` — without awaiting it. For all 44 tests written
as `test("...", async () => {...})`:
  - ✓ meant "did not throw SYNCHRONOUSLY", not "passed";
  - a failed assertion escaped as an unhandled rejection, crashing the process (CI stays red on
    the non-zero exit) but never being COUNTED — so the summary could print "0 failed" and be wrong.
The suite's headline number was therefore not evidence for ANY async test, including this PR's own
M1a/M1b guards. `test()` now settles an async body before counting it, and the summary awaits them.

## Defect 2, exposed the instant defect 1 was fixed: a false guard

`"a boot that resolves AFTER a drain kills its own pane ... no orphan process left behind"` asserted
`killed.length === 1` — i.e. that kill was CALLED once. But `_cancelBooting`'s kill-by-name on a
not-yet-existent session is a NO-OP that still increments that counter. So "kill was called once" and
"a live session is orphaned" were both true at the same time: a test named for the absence of an
orphan was passing while the orphan was present. Now asserts LIVENESS (`live.size === 0`) — the only
honest question.

## Evidence

  fix present : 295 passed, 0 failed, exit 0
  fix reverted: 293 passed, 2 failed  <- BOTH liveness guards fire (the old kill-count guard did not)

Also: `dropped`'s doc comment now lists `cancelled` (a cancelled in-flight boot lands there via _drop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-07-13 16:51:12 +10:00
committed by GitHub
co-authored by taodeng Claude Fable 5
parent e7ce9899f3
commit 9f5bc3264a
9 changed files with 1371 additions and 72 deletions
+186 -62
View File
@@ -73,6 +73,36 @@ const defaultTmux = (args, opts = {}) =>
// `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."
//
// ── POOL/REAPER INVARIANT (warm pane pool — lib/tui/pool.mjs) ───────────────────────────
// A warm pooled pane is one of OUR OWN `ocp-tui-<port>-*` sessions that is ALIVE AND IDLE
// BY DESIGN — and the periodic sweep runs precisely when the instance is idle, i.e. exactly
// when the pool is full. Without an exemption the sweep would kill every warm pane on every
// tick (and kill-server on top). The exemption is `spare`: a set of EXACT session names the
// caller declares live. Three properties, all load-bearing:
//
// 1. A LIVE POOLED PANE IS NEVER REAPED — INCLUDING ONE THAT IS STILL BOOTING. It is in
// `spare` (the pool's live registry), so it is skipped by name. The booting case is not
// a footnote, it is the one that bit us: bootTuiPane creates the tmux session
// SYNCHRONOUSLY and only then waits up to POOL_BOOT_MS for the input bar, so a pooled
// session can be live for ~20 s before its boot resolves. The pool therefore mints the
// pane's NAME up front and holds it in `_bootingPane`, so liveNames() can name — and
// spare — a session whose boot has not finished. (An earlier version tracked only a
// COUNT of in-flight boots; the sweep could not name that session and killed it.)
// 2. A LEAKED/ORPHANED POOLED PANE IS STILL REAPED. Membership is by EXACT NAME from a
// live in-memory registry — NOT by "looks pooled" (name shape). A pane the pool no
// longer owns (handed out, dropped, cancelled, or left behind by a previous process
// generation — whose registry died with it) is absent from `spare` and is killed like
// any other stale session. Fail-safe: forgetting to pass `spare` reaps MORE, never less.
// 3. KILL-SERVER NEVER KILLS A LIVE POOL PANE. A spared session suppresses kill-server
// exactly as a foreign session does (it is a live child of the tmux server). The
// consequence — that a permanently-full pool would permanently disable the defunct-
// zombie reaping that ONLY kill-server can do — is resolved in server.mjs by DRAINING
// the pool immediately before the sweep, so `spare` is empty on the normal tick and
// kill-server still fires. `spare` is the belt-and-braces: a reap call site that
// forgets to drain still cannot kill a live pane.
//
// `spare` (default: none) — iterable of session names, or a Set. Ignored when the pool is off.
//
// `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix
// 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
@@ -88,14 +118,20 @@ const defaultTmux = (args, opts = {}) =>
// 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 } = {}) {
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false, spare = null } = {}) {
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);
const spared = spare instanceof Set ? spare : new Set(spare || []);
let killed = 0;
let othersRemain = false;
let sparedLive = 0;
for (const name of names) {
// Property 1+2: exemption is by EXACT NAME from the pool's live registry. A pooled-
// LOOKING name that is not in the registry is an orphan and falls through to the
// normal kill path below.
if (spared.has(name)) { sparedLive++; continue; }
const isOwn = name.startsWith(ownPrefix);
const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name);
if (isOwn || isLegacyOwn) {
@@ -109,7 +145,11 @@ export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy =
// 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) {
//
// Property 3: a SPARED session is a live child of this tmux server, so kill-server would
// kill it — it therefore suppresses kill-server exactly as a foreign session does. On the
// normal sweep the pool is drained first, so sparedLive is 0 and kill-server still fires.
if (!othersRemain && sparedLive === 0) {
tmux(["kill-server"]);
}
return killed;
@@ -119,6 +159,12 @@ export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy =
// 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
// Readiness cap for a POOL pre-boot. Deliberately far more generous than BOOT_MS: BOOT_MS is
// tight because a client is blocked on it, whereas a warm-pane boot happens in the background
// with nobody waiting. Observed live at size=2: a refill booting alongside an in-flight turn
// exceeded 4000 ms and was discarded (tui_pool_boot_failed), quietly costing hit rate for a
// pane that was merely slow, not broken. Scales with OCP_TUI_BOOT_MS if an operator raises it.
export const POOL_BOOT_MS = BOOT_MS * 5;
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
@@ -412,38 +458,60 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
].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,
// Is a pane alive AND still sitting at its input bar? Used by the warm pool to decide,
// at hand-out time, whether a pre-booted pane is still usable (a dead/degraded pane must
// become a MISS → cold path, never a hung turn). capture-pane exits non-zero when the
// session no longer exists, so this covers "pane gone" and "pane not ready" in one call.
export function tuiPaneHealthy(tmux, tmuxName) {
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
if (!r || r.status !== 0 || typeof r.stdout !== "string") return false;
return tuiInputReady(r.stdout);
}
// Pool pane names carry a "p" marker after the port-scoped prefix:
// turn pane: ocp-tui-<port>-<8hex> (unchanged)
// pool pane: ocp-tui-<port>-p<8hex>
// Purely for operator legibility (`tmux ls` shows which panes are warm). It is NOT the
// reaper's exemption mechanism — that is the exact-name spare set (see the POOL/REAPER
// INVARIANT above), so a pooled-LOOKING orphan is still reaped. Both shapes start with
// sessionPrefixForPort(port), so both remain reapable as "ours", and neither can match
// LEGACY_SESSION_NAME_RE.
export function poolPaneName(port, sessionId) {
return sessionPrefixForPort(port) + "p" + sessionId.slice(0, 8);
}
// Boot ONE interactive `claude` pane and wait for its input bar. Shared by the cold
// request path (runTuiTurn) and the warm pool (lib/tui/pool.mjs) so a pooled pane is
// spawned with byte-for-byte the same argv, HOME, cwd and trust preparation as a
// cold-booted one — the pool must not become a second, drifting spawn path.
//
// Each pane gets its OWN fresh randomUUID() --session-id, fixed at boot. That is what
// keeps a pooled pane single-use-safe: its transcript holds exactly one exchange.
//
// requireReady: the cold path tolerates a readiness timeout (it falls through and lets
// the paste-verify decide — pre-existing behaviour, unchanged). The POOL sets it, because
// a pane that never reached its input bar is worthless as a warm pane and must not be
// enlisted: throw, let the pool count a bootFailure, and leave the request path to
// cold-boot as usual.
// bootMs: max wait for the input bar. Defaults to BOOT_MS (the REQUEST path's cap, which is
// deliberately tight — a client is blocked on it). The POOL passes POOL_BOOT_MS instead: a
// background pre-boot has nobody waiting on it, and capping it at the request-path's 4 s
// made real refills fail (observed live: a refill booting alongside an in-flight turn took
// >4 s and was discarded, silently lowering the hit rate). Slow != broken for a pre-boot.
// `sessionId` / `name` (both optional): the caller may supply the pane's identity instead of
// letting bootTuiPane mint it. The POOL does, because it must know the tmux session's NAME
// before this function runs — the session is created synchronously below, well before the
// readiness wait returns, so a pool that only learned the name on resolve could neither spare
// the session from the reaper nor kill it on shutdown. Supplying BOTH also keeps the name's
// hex suffix equal to the session-id's, so `tmux ls` correlates to the transcript file.
export async function bootTuiPane({
model, claudeBin, home, realHome, cwd, port, entrypointMode = "cli",
tmux = defaultTmux, sessionId = null, name = null, requireReady = false, bootMs = BOOT_MS,
}) {
const sessionId = randomUUID();
const sid = 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 tmuxName = name || (sessionPrefixForPort(port) + sid.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)
@@ -460,42 +528,97 @@ export async function runTuiTurn({
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
// 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 wait rather than paste into a non-existent
// session or issue a billing request without a verified interactive context.
const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode)],
{ env },
);
if (!spawnResult || spawnResult.status !== 0) {
throw new Error("tui_spawn_failed: tmux session not created");
}
// Wait until claude's input bar is actually ready (not a blind sleep).
// bootMs is the MAX readiness wait, not a fixed delay.
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
{ timeoutMs: bootMs, intervalMs: READY_POLL_MS });
if (!ready) {
if (requireReady) {
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
throw new Error("tui_pane_not_ready: input bar did not appear within " + bootMs + "ms");
}
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
console.error("[tui] input_not_ready", tmuxName);
}
return { name: tmuxName, sessionId: sid, model, ehome, bootedAt: Date.now() };
}
// Full per-request TUI lifecycle:
// 1. Take a WARM pane from the pool if one is available for this model (opt-in;
// OCP_TUI_POOL_SIZE=0 => always null => steps 2-3 below are exactly today's path).
// A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it
// serves this one turn, and it is killed in the finally like any other pane.
// 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux
// session in the scratch cwd, poll capture-pane until the `? for shortcuts` input bar
// appears (bootTuiPane). BOOT_MS is the max wait, not a fixed delay.
// 3. Write prompt to a 0600 temp file (no shell injection from prompt content).
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
// 5. Block on the native JSONL transcript (located by THIS pane's session-id) until
// terminal marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw), and kick a background
// pool refill so the next request finds a warm pane.
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
export async function runTuiTurn({
prompt,
model,
claudeBin,
home,
realHome,
cwd,
port,
wallclockMs = 120000,
entrypointMode = "cli",
tmux = defaultTmux,
pool = null, // TuiPanePool | null — null (default) === today's cold-boot-only path
onPane = null, // optional observer: ({ warm }) => void, for logging/metrics
}) {
// 1. Warm pane, or cold boot. A MISS is never an error — it is exactly today's path.
let pane = pool ? pool.acquire(model) : null;
const warm = !!pane;
// Kick the refill IMMEDIATELY (not after the turn): the replacement pane then boots
// CONCURRENTLY with this turn and is warm by the time the next request arrives. Also
// runs on a MISS — acquire() has just retargeted the pool to this model, so the miss
// that cold-boots today warms the pool for the next caller. Fire-and-forget; it takes
// no TuiSemaphore slot (see pool.refill's SLOT ACCOUNTING note).
if (pool) pool.refill();
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
if (!pane) {
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux });
}
const tmuxName = pane.name;
const sessionId = pane.sessionId; // THIS pane's own session-id — one session, one turn
const ehome = pane.ehome || home || process.env.HOME;
// Write prompt to a temp file (mode 0600) so the content never touches argv.
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
const promptFile = `${tmpDir}/prompt.txt`;
writeFileSync(promptFile, prompt, { mode: 0o600 });
try {
// 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),
@@ -521,11 +644,12 @@ export async function runTuiTurn({
// Submit (separate Enter key event).
tmux(["send-keys", "-t", tmuxName, "Enter"]);
// 4. Block on the native transcript (resolved by session-id) until terminal.
// 5. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
// Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
} finally {
// 5. Teardown — always, even on throw.
// 6. Teardown — always, even on throw. A pooled pane is torn down here exactly like a
// cold-booted one: SINGLE-USE, never returned to the pool (see pool.mjs).
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
}