Files
olp/docs/superpowers/plans/2026-05-30-tui-mode-A-path-implementation-plan.md
taodengandClaude Opus 4.8 0e334f9cac docs(plan): A-path TUI-mode implementation plan + maintainer decisions
Writing-plans output (PR-0..PR-3) grounded against real code; plan-review
verdict ready-with-fixes (all code anchors verified accurate to the line).

Plan covers: PR-0 TUI-only ISOLATION seed (default path byte-for-byte
unchanged), PR-1 transcript reader (dual-signal completion, no quiescence
in v1, string adapted to IR [{delta},{stop}] chunks in PR-3 branch — fits
both getOrCompute array + getOrComputeStreaming consumers with zero server
edits), PR-2 tmux session driver (T6 flags, T3 submit recipe, reaper), PR-3
CLAUDE_TUI_MODE wiring + ADR 0016 + README. Plus cross-cutting contracts,
B-gate spike track (T2/T4/T5, parallel, non-blocking for A), PI231 test
strategy (scratch only, prod :4567 untouched), jaekwon-park co-author.

Maintainer decisions appended resolving plan-review P1-P5 + open questions:
- P1 (PR-2 interface blocker): reuse isolationCtx.ephemeralRoot+reqId —
  no default-path call-site edits, preserves unchanged invariant
- P2: reaper boot anchor = server.mjs:2417 isMain block (not :2334)
- P3: A also uses --strict-mcp-config (defense-in-depth); preflight /mcp
  advisory-at-startup for A, hard-gate for B
- P5: wall-clock cap = env CLAUDE_TUI_WALLCLOCK_MS default 120000 (config
  not constant, T5-tunable)
- warm-pool + large-paste explicitly scoped OUT of initial A deliverable

Plan ready to implement: PR-0 -> PR-1 -> PR-2 -> PR-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:50:44 +10:00

50 KiB
Raw Permalink Blame History

TUI-mode — Deployment-A Implementation Plan (PR-0 … PR-3)

  • Date: 2026-05-30
  • Status: Implementation plan (pre-code). Derived verbatim from the final design spec docs/superpowers/specs/2026-05-30-tui-mode-production-design.md (3 review passes + spikes S1/S2/S3 + pre-code gates T1/T3/T6). Decisions in the spec are NOT re-litigated here.
  • Scope: Deployment A only (single-user / OCP canary). Deployment B (multi-tenant) is DEFERRED behind spikes T2 (body-capture tools:[]) + T4 (concurrency). B's gating hooks (--tools "", --strict-mcp-config, --disallowedTools "mcp__*", per-spawn MCP-disable verification) are wired in PR-2 but B is not enabled — no per-key guest path ships in this plan.
  • Authority of record (to be created in PR-3): ADR 0016 (or ADR 0009 Amendment 2) — see PR-3.
  • Iron Rules in force: 10 (independent reviewer), 11 (minimum reviewable unit — one PR per layer), 12 (prior-art search done = the spikes). ALIGNMENT.md Rule 1 (cite authority) + Rule 2 (no inventing CLI behavior) + Rule 5 (release-kit).
  • Author credit (binding, §13): every implementing commit carries Co-Authored-By: jaekwon-park <…> (pull the real email/handle from OCP PR #101 before committing — do NOT invent). ADR 0016 names PR #101 + jaekwon-park in its acknowledgment section. Add jaekwon-park to CONTRIBUTORS and notify on PR #101 at ship time.

0. Ground-truth code anchors (verified against the real tree)

Everything below cites the exact function/line the change hooks into. Re-verify line numbers at edit time (the files churn).

Surface Location (verified) Role in TUI-mode
spawn(irRequest, authContext, isolationCtx) (public contract) lib/providers/anthropic.mjs:1164 → delegates to _spawnAndStream PR-3 branches here on CLAUDE_TUI_MODE. Default falls through to _spawnAndStream (stream-json) UNCHANGED.
_spawnAndStream(irRequest, authContext, spawnImpl, isolationCtx) anthropic.mjs:872 The default transport. Not modified by TUI-mode (PR-3 adds a sibling branch in the public spawn, it does not touch _spawnAndStream).
buildCliArgs(model, systemPrompt) anthropic.mjs:834 (returns --model … --output-format stream-json --verbose --no-session-persistence --system-prompt …) TUI driver builds its own argv (no -p, no --output-format); it does NOT reuse buildCliArgs. Cited as the contrast surface.
extractSystemPrompt(irRequest) anthropic.mjs:123 (always prefixes OLP_SYSTEM_PROMPT_WRAPPER :109) REUSED unchanged by the TUI driver to compute the --system-prompt value.
irToAnthropic(irRequest) anthropic.mjs:601 (serializes user/assistant/tool; skips system) REUSED unchanged — produces the prompt body text the TUI driver writes to the prompt file (§6 recipe).
ISOLATION named export anthropic.mjs:1667 (ephemeralEnvOverrides{HOME}, credentialMounts, requiredHomePaths:['.claude'], hasInnerSandbox:false) PR-0 EXTENDS with a TUI-only seed hook.
prepareIsolatedEnvironment({provider,keyId,reqId}) lib/sandbox/manager.mjs:203 → returns {ephemeralRoot, envOverrides, hardenedArgs, wrapForLayer3, cleanup} PR-0 consumes the new seed step; PR-2 driver calls it to get ephemeralRoot. Note the test bypass at :223 (returns _legacyShape() under test-features.mjs unless globalThis.__OLP_FORCE_ISOLATION_IN_TEST).
Buffered spawn call site server.mjs:1347 (prepareIsolatedEnvironment) → :1355 (for await … hopProviderPlugin.spawn(...)) inside collectAllChunks() (:1299); result cached via cacheStore.getOrCompute(keyId, hopCacheKey, collectAllChunks) at :1445 computeFn returns an ARRAY of IR chunks. TUI transport must yield [{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}] so this path is unchanged.
Streaming spawn call site server.mjs:1564 (prepareIsolatedEnvironment) → :1570 (for await … streamPlugin.spawn(...)) inside sourceWithRelease(); coordinated via cacheStore.getOrComputeStreaming(keyId, streamCacheKey, sourceFactory, …) at :1587 sourceFactory returns an ASYNC GENERATOR of IR chunks. TUI transport yields the same 2-chunk shape → SSE replay (irChunkToOpenAISSE at server.mjs:1764) is byte-identical to the stream-json path. This is the §3.1 single-buffered-then-replay mechanism.
irChunkToOpenAISSE, SSE_DONE imported server.mjs:38; used :1764, :1772 REUSED unchanged for stream:true replay.
max_tokens parse lib/ir/openai-to-ir.mjs:182 (sets ir.max_tokens) Accepted into IR, dropped at CLI boundary (§4.5). Same for temperature :190, top_p :198, stop :206 (§4.6).
validateKey / owner_tier / providers_enabled lib/keys.mjs:414; tiers `'owner' 'guest'

Cache contract crux (load-bearing for PR-1). server.mjs does NOT expect a string from the transport. It expects IR chunks — an array (buffered, getOrCompute) or an async generator (streaming, getOrComputeStreaming). The TUI transcript reader (PR-1) resolves a single string; the TUI driver/provider-branch (PR-2/PR-3) is responsible for the thin adapter string → [delta, stop] so both existing cache paths consume it with zero modification. This is the concrete meaning of spec §3.2 "returns a resolved response string adapted to the getOrCompute/singleflight cache contract."


Cross-cutting contracts (define these FIRST; every PR conforms)

C1. Transport interface (so node-pty can slot later — spec §8)

A single interface in lib/tui/session.mjs; tmux is the only implementation in this plan; node-pty is a stubbed adapter behind the same interface.

interface TuiTransport {
  // create the session bound to ephemeralRoot, spawn `claude` interactive, settle to input box
  open({ bin, args, env, cwd, ephemeralRoot, reqId }): Promise<SessionHandle>
  // submit one prompt body (T3 recipe: file → send-keys -- "$(cat f)" → separate Enter)
  submit(handle, promptText): Promise<void>
  // teardown: kill session + nothing else (ephemeral root rm is the manager.cleanup's job, but
  //   the driver MUST also kill the session in a trap/finally — §8)
  close(handle): Promise<void>
  // startup-time orphan reaper (kill restart-surviving sessions) — §5.5
  reapOrphans(): Promise<{ killed: string[] }>
}

tmuxTransport implements all four. nodePtyTransport is a stub that throws NOT_IMPLEMENTED (present so the interface boundary is real and reviewable). The transcript reader (C2) and IR mapping never import the transport — they only consume the deterministic transcript path, so swapping transports later touches nothing else.

C2. Transcript-reader interface (PR-1 owns it; transport-agnostic)

computeTranscriptPath({ ephemeralRoot, cwd, sessionId }): string   // §4.1 formula, pure
readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs, pollMs }):
  Promise<{ text: string, durationMs?: number, messageCount?: number }>   // resolves the assistant text
  // throws TuiCompletionError on guard-(B) terminal conditions (tool_use / wall-clock cap) — §4.4

readTurnResult is the dual-signal completion engine. It never imports tmux/node-pty. It is unit-tested entirely against captured JSONL fixtures.

C3. CLAUDE_TUI_MODE flag semantics (binding)

  • Unset / not "1" → default path. Byte-for-byte unchanged from today: _spawnAndStream (stream-json), ISOLATION with NO seed, no .claude.json written, no new on-disk sensitive data. This is a hard requirement (spec §7.1) and is the regression invariant (C4).
  • CLAUDE_TUI_MODE=1 → TUI transport: ephemeral home seeded (PR-0), tmux interactive claude (PR-2), transcript-read completion (PR-1), provider branch (PR-3).
  • The flag is read once in the provider spawn() branch (PR-3) — process.env.CLAUDE_TUI_MODE === '1'. It is the ONLY toggle. No config-file alternative in this plan.
  • Sub-flags (A-only, all default-off, all gated under CLAUDE_TUI_MODE=1): CLAUDE_TUI_WARM_POOL (§7.2 — out of scope for this plan; not implemented, only namespace-reserved).

C4. Default-path-unchanged invariant + how to test it

  • Invariant: with CLAUDE_TUI_MODE unset, no code path added by PR-0..PR-3 executes. ISOLATION returns the same shape, _spawnAndStream is the only transport, no .claude.json is seeded.
  • Test (regression guard, runs in every PR): the full existing test-features.mjs suite stays green. Additionally PR-0 adds an explicit assertion: prepareIsolatedEnvironment for the anthropic provider with CLAUDE_TUI_MODE unset produces an ephemeral root containing no .claude.json (only the symlinked .credentials.json + .claude/ dir, as today). PR-3 adds: spawn() with the flag unset calls _spawnAndStream (assert via the existing __setSpawnImpl seam — the mock spawn is invoked, the TUI driver is NOT).

PR-0 — ISOLATION extend (TUI-only .claude.json seed)

1. Goal

Seed a minimal .claude.json (onboarding/trust/bypass markers ONLY) into the ephemeral home only when CLAUDE_TUI_MODE is active, so a fresh-home interactive claude drops straight to the input box instead of hanging on first-run onboarding — while the default stream-json path's bootstrap stays byte-for-byte unchanged.

2. Files touched

  • lib/providers/anthropic.mjs — extend the ISOLATION block (:1667).
  • lib/sandbox/manager.mjs — add the opt-in seed step to prepareIsolatedEnvironment (:203), gated so it is a no-op unless the caller requests it.
  • test-features.mjs — new suite (seed-on / seed-off / permissions).
  • (no new file in PR-0)

3. Concrete changes

3a. ISOLATION gains a seed descriptor (NOT a function that reads the real home unconditionally). Add to the anthropic ISOLATION object an OPTIONAL field describing the TUI seed, e.g.:

// anthropic.mjs ISOLATION (extend, after requiredHomePaths)
tuiSeed: {                         // consumed ONLY when prepareIsolatedEnvironment is called with { tui:true }
  relPath: '.claude.json',         // written under ephemeralRoot
  mode: 0o600,                     // §5.5 — same care as the bearer
  // builder is pure-ish: it reads the real ~/.claude.json ONCE to copy oauthAccount/userID,
  // strips `projects`, and stamps onboarding/trust/bypass markers + a pre-trusted cwd.
  build: ({ cwd }) => ({ /* hasCompletedOnboarding:true, oauthAccount, userID,
                           bypassPermissionsModeAccepted:true,
                           projects: { [cwd]: { hasTrustDialogAccepted:true, … } } */ }),
}
  • Authority/contract note: ADR 0002 Amendment 9's credentialMounts is deliberately a static list (not a function) for auditability; the seed is a NEW optional field, so PR-0 must add a one-paragraph Amendment-9 note (in ADR 0002, co-merged or referenced) stating the seed reads the real ~/.claude.json exactly once to copy oauthAccount/userID, writes mode-600, and carries no MCP-disable weight (T6 negative control, spec §5.2 / §7.1). The seed is onboarding/trust/bypass ONLY.
  • The seed does NOT disable managed MCP (T6 negative control). PR-0 must NOT add claudeAiMcpEverConnected manipulation or any MCP field. A code comment cites spec §5.2 + T6.

3b. prepareIsolatedEnvironment gains a tui opt-in param. Change the signature to prepareIsolatedEnvironment({ provider, keyId, reqId, tui = false }) (manager.mjs:203). After the existing Layer-2 symlink loop (:318), add a guarded block:

if (tui && isolation?.tuiSeed) {
  // chmod 700 the ephemeralRoot (§5.5), write isolation.tuiSeed.build({cwd}) JSON
  // at join(ephemeralRoot, tuiSeed.relPath) with { mode: tuiSeed.mode }, never log contents.
}
  • Default path is untouched: existing call sites at server.mjs:1347 and :1564 pass NO tui flag → tui=false → seed block is skipped → identity behavior. This satisfies C4. The TUI driver (PR-2) is the ONLY caller that passes tui:true.
  • chmod 700 the ephemeral root (§5.5) is applied inside the tui block so the default path's permission semantics are also unchanged. (The default path created the root via mkdirSync at :250; PR-0 does not alter that.)
  • Per-keyId isolation is already structurally given by the /tmp/olp-spawn/<safeKeyId>/<safeReqId>/home path (manager.mjs:247). PR-0 adds an assertion/comment that the parent <safeKeyId> dir is not world-traversable (chmod 700 on the chain) — §5.5.
  • Test bypass interaction (manager.mjs:223): the existing test-runner bypass returns _legacyShape(). PR-0's seed tests MUST set globalThis.__OLP_FORCE_ISOLATION_IN_TEST = true to exercise the real path, then unset it in finally (this seam already exists).

4. Unit tests + fixtures (test-features.mjs)

  • seed-off (default-path invariant, C4): call prepareIsolatedEnvironment({provider:anthropic, keyId, reqId}) (no tui) under __OLP_FORCE_ISOLATION_IN_TEST → assert ephemeralRoot has .claude/.credentials.json symlink + .claude/ dir and NO .claude.json.
  • seed-on: call with { tui:true } → assert .claude.json exists, is mode 600, parses as JSON, contains hasCompletedOnboarding:true + bypassPermissionsModeAccepted:true + a pre-trusted projects[cwd], and contains NO mcpServers/claudeAiMcpEverConnected field (negative assertion — T6).
  • root permissions: assert ephemeralRoot is mode 700 on the tui:true path.
  • no-real-home-mutation: assert the real ~/.claude.json is not written/modified (read-only copy).
  • Fixture: a minimal fake ~/.claude.json (via a temp HOME or an injected reader seam) carrying a dummy oauthAccount/userID so the test never touches the operator's real account file.
  • Full existing suite stays green (regression).

5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch only)

Run on PI231 scratch (prod OLP on :4567 untouched):

  • Drive prepareIsolatedEnvironment({tui:true}) against a scratch keyId/reqId; ls -la the ephemeral root.
  • Pass criteria: .claude.json present, mode 600; root mode 700; symlinked .credentials.json present; cat the seed shows onboarding/trust/bypass markers and no MCP fields; the real ~/.claude.json mtime unchanged.
  • Launch interactive claude by hand bound to that ephemeral HOME and confirm it does not hang on onboarding (drops to input box). (This is the load-bearing reason PR-0 exists.)

6. Acceptance criteria (binding, testable)

  • With tui unset, ephemeral home is byte-identical to today (no .claude.json). ✔ regression test + PI231.
  • With tui:true, seed is written mode-600, root mode-700, onboarding/trust/bypass present, MCP fields absent.
  • No change to default stream-json spawn behavior; full suite green.

7. Reviewer (Iron Rule 10)

Fresh-context reviewer opens spec §7.1 + §5.2 (T6 negative control) + ADR 0002 Amendment 9 and confirms: (a) the seed is gated on the opt-in tui param so the default path is unchanged; (b) the seed carries NO MCP-disable field (T6); (c) mode-600 seed + mode-700 root + per-keyId isolation per §5.5; (d) the Amendment-9 note documenting the new tuiSeed field is present. A review that does not name the §5.2 negative control is not a valid approval.

8. Authority citation (commit + PR body)

claude CLI v2.1.158 § first-run onboarding (theme/login pickers) + $HOME-redirect behavior (ADR 0002 Amendment 9 anthropic ISOLATION pin); ADR 0002 Amendment 9 (ISOLATION contract); spec §7.1 + §5.2; PI231 ephemeral-home spike docs/spikes/2026-05-29-ephemeral-home.md. State explicitly: the seed does NOT disable managed MCP — that is the spawn-argv flag in PR-2 (T6).

Risk / rollback

Independently revertable (revert reinstates the pre-seed ISOLATION; default path was never touched). Default-off: nothing reaches users — the seed only fires when a caller passes tui:true, and no caller does until PR-2/PR-3.


PR-1 — Transcript reader (lib/tui/transcript.mjs)

1. Goal

A transport-agnostic reader that computes the deterministic transcript path, polls for lazy file creation, detects turn completion via the mandatory dual-signal guard (turn_duration OR tool_use OR wall-clock cap; NO quiescence in v1), extracts the assistant text, and resolves a single response string.

2. Files touched

  • NEW lib/tui/transcript.mjs.
  • test-features.mjs — new transcript-reader suite.
  • Fixtures dir (NEW) docs/spikes/fixtures/tui/ — captured real JSONL (see §4).

3. Concrete changes (exports + signatures)

  • export function computeTranscriptPath({ ephemeralRoot, cwd, sessionId })pure. Implements §4.1: <ephemeralRoot>/.claude/projects/<CWD_ENCODED>/<sessionId>.jsonl where CWD_ENCODED = cwd with every /- including the leading slash (/tmp/x-tmp-x). No filesystem access. (OLP generates sessionId and cwd, so the path is known before spawn.)
  • export async function readTurnResult({ transcriptPath, sinceUserContent, wallClockCapMs = 120_000, pollMs = 500, toolUseIsTerminal = true }):
    • Lazy-create poll: the file is created on first message, not at spawn (§4.1). Tolerate ENOENT; poll every pollMs until the file exists or wallClockCapMs elapses (then throw TuiCompletionError('completion-marker timeout')).
    • Dual-signal completion (§4.4, MANDATORY):
      • (A) happy path: a line {"type":"system","subtype":"turn_duration"} for this turn appears → done. Carries durationMs + messageCount. Do NOT rely on file-tail byte ordering (§4.3 trap): re-scan the file, find the matching user line for sinceUserContent, collect all subsequent assistant/text blocks.
      • (B) co-equal terminal guard (mandatory, never-hang): if the last assistant message carries stop_reason:"tool_use" → throw TuiCompletionError('tool-use turn unsupported in TUI-mode') (maps to clean 502). If wallClockCapMs fires → throw TuiCompletionError('completion-marker timeout').
      • NO quiescence cut in v1 (§4.4 ⚠️): do NOT abort on "file size-stable for N seconds" — a long Opus/extended-thinking turn legitimately produces no growth. Quiescence is added only after spike T5. (Comment cites §4.4 explicitly so a future contributor does not "helpfully" add it.)
      • Do NOT key off stop_reason:"end_turn" alone (§4.3 trap — appears on both thinking and text blocks).
    • Assistant-text extraction (§4.2): JSON.parse per line (native log → escaping-clean). Response = concatenation of text-type content blocks from assistant messages emitted since the matching user line. Return { text, durationMs, messageCount }.
    • Trailing-newline normalization (§3.2 / §6 caveat): the input box strips the source's single trailing newline. sinceUserContent matching MUST normalize the trailing newline before comparing source-prompt vs the transcript user line, or the "matching user line" lookup (and any cache-key reasoning) sees a spurious mismatch.
  • Cache-contract adapter note (does NOT live in PR-1, but PR-1's return shape is designed for it): readTurnResult resolves a string; the PR-2/PR-3 layer wraps it as [{type:'delta',role:'assistant',content:text},{type:'stop',finish_reason:'stop'}]. PR-1's JSDoc states this adapter contract and points at server.mjs:1299 (buffered array) + server.mjs:1558 (streaming generator) so the reviewer sees the two consumers. max_tokens/sampling graceful-drop boundary (§4.5/§4.6): PR-1 documents that these IR fields never reach this layer (interactive claude has no flag); nothing to do — they are dropped at the CLI-args boundary in PR-2/PR-3. PR-1 adds a comment asserting finish_reason is always 'stop' (no length mapping, since max_tokens is not enforced).

4. Unit tests + fixtures

Fixtures (capture REAL JSONL on PI231 — do not hand-fabricate the shapes):

  • text-turn.jsonl — a normal end_turn text answer ending in a turn_duration line.
  • refusal-turn.jsonl — a refusal that still emits turn_duration (T1: durationMs≈3221).
  • tool-use-no-marker.jsonlMANDATORY (T1): a tool_use turn whose last assistant line is stop_reason:"tool_use" with NO turn_duration line. This is the hang case guard (B) must catch.
  • out-of-order.jsonl — a text block flushed by byte-position AFTER turn_duration though turn_duration has the later timestamp (§4.3 trap) — proves the reader does not rely on file-tail ordering.
  • multiturn.jsonl — two user lines so sinceUserContent selection is exercised (a toolUseResult:true user line within a turn must NOT be mistaken for a new submit — §6 step 5).

Tests:

  • computeTranscriptPath exact-string equality incl. leading-slash encoding.
  • happy path returns concatenated text + durationMs/messageCount.
  • refusal path returns refusal text (still completes).
  • tool-use fixture → throws TuiCompletionError (never hangs) — assert with a short wallClockCapMs that the throw is the tool_use detection, not the timeout (distinguish the two error messages).
  • wall-clock cap fires on a never-completing fixture (truncated file with no marker) → throws within cap.
  • out-of-order fixture → correct text (no reliance on last byte).
  • trailing-newline normalization: sinceUserContent with trailing \n still matches the transcript user line.
  • no-quiescence assertion: a fixture that is size-stable for > pollMs but has not completed does NOT abort before the wall-clock cap (proves quiescence is excluded).
  • Full existing suite stays green (PR-1 adds a new module + new tests only; touches no existing path).

5. PI231 integration checkpoint (maintainer-supervised)

  • Capture the 5 fixtures above from real claude v2.1.158 runs on PI231 scratch (this is also how the fixtures are sourced). Commit them under docs/spikes/fixtures/tui/.
  • Pass criteria: readTurnResult against each freshly-captured fixture returns the same text a human reads in the transcript; the tool-use capture throws TuiCompletionError and never blocks; cap fires deterministically on a manually-truncated fixture.

6. Acceptance criteria (binding)

  • Deterministic path matches §4.1 exactly.
  • Dual-signal completion: completes on turn_duration; never hangs on tool-use or a missing marker (guard B); no quiescence cut.
  • Escaping-clean text extraction; trailing-newline normalized.
  • Pure reader: zero tmux/node-pty import; fully fixture-testable.

7. Reviewer (Iron Rule 10)

Fresh-context reviewer opens spec §4.1–§4.4 (and §3.2 cache-contract / trailing-newline) and confirms: (a) the path formula incl. leading-slash; (b) the dual-signal guard is present AND quiescence is explicitly excluded with a §4.4 citation; (c) the tool-use-no-marker fixture exists and the test proves a non-hanging terminal throw; (d) the resolved-string return is documented against the getOrCompute/getOrComputeStreaming consumers. A review missing the tool-use-no-marker check is not valid.

8. Authority citation

claude CLI v2.1.158 § native session transcript JSONL (turn_duration is an undocumented internal-log behavior — pin to v2.1.158, re-verify per CLI/Ink bump); spec §4 (S2 PASS) + §4.4 (T1 PARTIAL); fixtures captured PI231 2026-05-30. No OpenAI-spec surface (reader is internal). ALIGNMENT Rule 2: the reader consumes a behavior claude actually emits — no invented format.

Risk / rollback

New file + new tests only; revert deletes the module and tests, default path untouched. Riskiest sub-step is the dual-signal guard's tool-use detection (the hang vector) — fully covered by the mandatory fixture.


PR-2 — Session driver (lib/tui/session.mjs)

1. Goal

A tmux-backed interactive-claude driver behind the transport interface (C1): spawn with the T6 flag set, submit via the T3 recipe, auto-answer dialogs, run the per-spawn MCP-disable verification gate, guarantee teardown via trap/finally, and reap orphan sessions on startup — producing a single buffered response (via PR-1's reader) adapted to IR chunks for both cache paths.

2. Files touched

  • NEW lib/tui/session.mjs (tmux transport + node-pty stub + the driver runTuiTurn).
  • lib/sandbox/manager.mjs — driver calls prepareIsolatedEnvironment({…, tui:true}) (the param added in PR-0).
  • test-features.mjs — driver suite (with a mock transport — no real tmux/claude in unit tests).
  • (server wiring is PR-3, NOT here)

3. Concrete changes

3a. Transport interface + tmux implementation (C1).

  • export const tmuxTransport implementing open/submit/close/reapOrphans.
  • export const nodePtyTransport — stub throwing NOT_IMPLEMENTED (interface placeholder, §8 decision: tmux first).
  • Session naming: olp-tui-<keyId>-<reqId> so reapOrphans can pattern-match.

3b. Spawn argv (T6 flag set, §5.2) — the driver builds its OWN args (NOT buildCliArgs).

claude --model <m> --session-id <uuid> --system-prompt "<extractSystemPrompt(ir)>"
       --strict-mcp-config                 // T6 load-bearing: 0 managed-MCP (no --mcp-config supplied)
       --disallowedTools "mcp__*"          // deny MCP-namespaced tools
       [--tools "" ]                        // B-only built-in lockdown — WIRED, gated off for A (see 3g)
       // NO -p, NO --output-format  → real TTY → cc_entrypoint=cli
env:   CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1   // defense-in-depth
       + carry-forward: CLAUDE_CODE_DISABLE_CLAUDE_MDS=1, unset ANTHROPIC_* (reuse buildSpawnEnv semantics)
       + HOME=<ephemeralRoot> (from prepareIsolatedEnvironment envOverrides)
  • --system-prompt value comes from extractSystemPrompt(ir) (anthropic.mjs:123) — REUSED. Prompt body comes from irToAnthropic(ir) (anthropic.mjs:601) — REUSED (written to the prompt file, 3d).
  • --bare is forbidden (§5.2 — breaks OAuth). Comment cites it.
  • --model from ir.model; --session-id is the OLP-generated UUID also fed to computeTranscriptPath.

3c. Per-spawn MCP-disable verification gate (§5.2 (4) preflight semantics). After open() settles, assert 0 dirs matching $HOME/.cache/claude-cli-nodejs/*/mcp-logs-claude-ai-* under the ephemeral root. Do NOT run /mcp inside the serving session (§5.2: it writes a transcript line, consumes a turn, corrupts the reader's matching-user-line semantics). For A's canary the cache-dir assertion is the in-band check; the /mcp-empty assertion belongs to a separate preflight session at startup / CLI upgrade (wire the preflight hook here but it is owner-tier advisory for A; it becomes a hard gate for B). On assertion failure: tear down + clean 502.

3d. Submit recipe (T3 PASS — binding for acceptance, §6).

  1. Write irToAnthropic(ir) to a file under the ephemeral root (NEVER interpolate into a shell line — backticks/$()/&&/quotes get mangled by the shell, §6 step 1).
  2. tmux send-keys -t <S> -- "$(cat promptfile)" — the leading -- end-of-options guard is required (prompt starting with -). Embedded \n are soft line-breaks; do NOT submit. Do NOT use send-keys -l for the body (§6 step 2).
  3. Settle ~1.52s for Ink render / paste-collapse (§6 step 3). (Production: poll the pane for input-box-ready / paste-collapse before Enter, or scale settle to payload size — §6 caveat.)
  4. Submit Enter as a SEPARATE tmux KEY TOKEN: tmux send-keys -t <S> Enter — never a literal "\n" appended to text (Ink #15553, §6 step 4).
  5. Verify via TRANSCRIPT (not capture-pane): exactly one user-role line whose content equals source minus its single trailing newline (a second user line with toolUseResult:true is in-turn tool output, not a second submit — §6 step 5). Large pastes collapse to a [Pasted text …] placeholder so pane-scraping is impossible — transcript-read is mandatory.
  6. Retry Enter (key token) up to ~4× as a defensive guard (§6 step 6).

3e. Dialog auto-answer (S3 footgun, §6). With the PR-0 seed (trust + bypass pre-seeded) neither dialog should appear. Defensive handling if they do: trust-folder defaults to "1. Yes, I trust" → bare Enter confirms; the bypass-permissions dialog defaults cursor to "1. No, exit" — a naive Enter kills the session → must send Down then Enter to land on "2. Yes, I accept". Prefer the pre-seed; keep the Down+Enter recipe as fallback.

3f. Teardown (trap-guaranteed, §8) + orphan reaper (§5.5).

  • close() + ephemeral-root cleanup MUST run in a finally (NOT best-effort) — S3 noted best-effort rm left empty home_* dirs with stray cred symlinks. The driver wraps the whole turn in try { … } finally { await transport.close(handle); await isolationCtx.cleanup(); }.
  • tmuxTransport.reapOrphans() runs at server startup (called from PR-3's boot path): list olp-tui-* tmux sessions surviving a restart, kill each + rm -rf its ephemeral root (these still hold the owner OAuth via the mounted ephemeral home — §5.5). This is the restart-time backstop complementing the steady-state finally.

3g. B-gate hooks wired but inert (scope discipline). --tools "" (built-in lockdown) and the /mcp-empty hard gate are present in the code path but only activated for owner_tier === 'guest', which no A/canary request is. A comment + the ADR state: B does not launch until T2 (body-capture tools:[]) passes; serialized after T2; concurrent only after T4 (§5.2 gate semantics). PR-2 ships the flags; PR-3/B-enablement flips them on. No guest key is provisioned in this plan.

3h. Single buffered response + SSE replay (§3.1). The driver's public entry, e.g. export async function runTuiTurn({ ir, authContext, keyId, reqId, transport = tmuxTransport }), returns the resolved string from PR-1's readTurnResult. The IR-chunk adapter string → [{type:'delta',role:'assistant',content},{type:'stop',finish_reason:'stop'}] is applied by PR-3's provider branch so both getOrCompute (buffered array) and getOrComputeStreaming (async generator) consume it unchanged — for stream:true the existing irChunkToOpenAISSE replay (server.mjs:1764) emits the completed text as one burst of delta(s) + [DONE] AFTER the turn finishes. True token streaming is NOT possible (§3.1) — capture-pane partial-text tapping is explicitly rejected (large pastes collapse to [Pasted text …]).

4. Unit tests + fixtures

  • mock transport (no real tmux/claude): assert the driver builds the exact T6 argv set (--strict-mcp-config, --disallowedTools "mcp__*", no -p, no --output-format, no --bare, env CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1).
  • submit recipe shape: prompt written to a file; send-keys -- "$(cat …)" issued; Enter is a SEPARATE token; on a simulated missed-Enter the retry fires ≤4×.
  • dialog fallback: simulated bypass dialog → driver sends Down+Enter (not bare Enter).
  • teardown: assert close + cleanup fire in finally on both happy and thrown paths (inject a throw mid-turn).
  • reaper: seed fake olp-tui-* session records into the mock transport → reapOrphans kills them + rms roots.
  • guest-gating: with owner_tier:'guest' the argv gains --tools ""; with 'owner' it does not (B-hook wired-but-inert proof).
  • string→IR-chunk adapter produces [delta, stop] with finish_reason:'stop'.
  • T3 regression negative control (documented, runs on PI231 not in unit): a newline-as-text submit silently fails (Ink #15553) — guards against a future refactor reintroducing -l.
  • Full existing suite green; default path (flag-unset) never reaches this module.

5. PI231 integration checkpoint (maintainer-supervised; /tmp scratch + tmux only)

  • Real multiline-code request (fenced code block + shell-special chars, ~50 lines per T3) through runTuiTurn against real claude v2.1.158 on PI231 scratch.
  • Pass criteria: response text is correct and byte-for-byte intact; exactly ONE user submit in the transcript; cc_entrypoint=cli verified (transcript turn_duration line entrypoint=cli / --debug metadata); MCP-disable gate passes (0 mcp-logs-claude-ai-* dirs); the real ~/.claude is untouched (mtime check on ~/.claude.json + ~/.claude/projects); session is killed + ephemeral root removed on completion (no stray home_*); reaper kills a deliberately-orphaned session on the next startup.
  • Re-run the T3 negative control (newline-as-text fails to submit) to confirm the Ink #15553 control still holds on this CLI version.

6. Acceptance criteria (binding)

  • T6 flag set applied; MCP-disable gate asserts 0 managed-MCP (cache-dir evidence) per spawn.
  • T3 submit: multiline/shell-special payload submits byte-for-byte, exactly one submit, transcript-verified.
  • Trap-guaranteed teardown (no stray ephemeral roots / cred symlinks) + startup orphan reaper.
  • Single buffered response; stream:true is SSE-replay (no token streaming). B hooks wired but inert.
  • cc_entrypoint=cli confirmed; real ~/.claude untouched.

7. Reviewer (Iron Rule 10)

Fresh-context reviewer opens spec §5.2 (T6) + §6 (T3) + §3.1 + §5.5 + §8 and confirms: (a) --strict-mcp-config with NO --mcp-config is the disable mechanism (not seed-editing); (b) --bare is NOT used; (c) the T3 recipe is file→send-keys -- "$(cat)"→separate Enter (not -l, not literal \n); (d) teardown is finally-based + a startup reaper exists; (e) B hooks (--tools "", /mcp hard gate) are present but gated to guest and B is documented as blocked on T2/T4; (f) response is single-buffered with SSE replay, no token streaming. A review that does not open the live claude --help for --strict-mcp-config/--disallowedTools on v2.1.158 is not valid.

8. Authority citation

claude CLI v2.1.158 § --strict-mcp-config, § --disallowedTools, § --system-prompt, § --session-id, § --model (live --help on PI231); env CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1 (binary-confirmed); tmux 3.3a § send-keys/send-keys -l/key-tokens (Ink #15553 control); spec §5.2 (T6 PASS), §6 (T3 PASS), §3.1, §5.5, §8. ALIGNMENT Rule 2: every flag is one claude accepts — no invented flag.

Risk / rollback

Riskiest PR. Independently revertable (deletes the module + the tui:true caller; PR-0/PR-1 inert without it). Default-off: no server path invokes runTuiTurn until PR-3, and even then only under CLAUDE_TUI_MODE=1.


PR-3 — Provider wiring + ADR + README

1. Goal

Add the CLAUDE_TUI_MODE branch in the anthropic provider spawn() so a flagged request routes to the TUI driver and yields IR chunks; default stays stream-json. Land ADR 0016 as authority of record and the README docs (quirks + non-honored params + grey-area framing).

2. Files touched

  • lib/providers/anthropic.mjs — branch in public spawn() (:1164); call reapOrphans from a boot hook (or export an init the server calls).
  • server.mjs — call the orphan reaper at startup (near bootstrapSandbox, :82/boot path); pass tui:true to prepareIsolatedEnvironment ONLY on the TUI branch (the branch lives in the provider, so the simplest wiring is: the provider's TUI branch calls prepareIsolatedEnvironment({…, tui:true}) itself; if the existing architecture composes isolation in server.mjs before spawn, PR-3 adds a flag-gated tui pass-through there — decide per the under-spec note below).
  • docs/adr/0016-tui-mode.md — NEW (or ADR 0009 Amendment 2).
  • README.md — env-var table, Troubleshooting, API/Configuration notes.
  • CHANGELOG.md — Unreleased entry (no version bump mid-Phase per CLAUDE.md phase_rolling_mode).
  • CONTRIBUTORS — add jaekwon-park.
  • test-features.mjs — branch-selection tests.

3. Concrete changes

3a. spawn() branch (anthropic.mjs:1164).

export async function* spawn(irRequest, authContext, isolationCtx) {
  if (process.env.CLAUDE_TUI_MODE === '1') {
    // import { runTuiTurn } from '../tui/session.mjs'
    const text = await runTuiTurn({ ir: irRequest, authContext, keyId, reqId, … });
    yield { type: 'delta', role: 'assistant', content: text };
    yield { type: 'stop', finish_reason: 'stop' };
    return;
  }
  yield* _spawnAndStream(irRequest, authContext, _spawnImpl, isolationCtx);  // UNCHANGED default
}
  • The default branch (_spawnAndStream) is byte-for-byte unchanged. C4 invariant holds.
  • The 2-chunk yield is exactly what collectAllChunks (server.mjs:1299) buffers into an array for getOrCompute, and what sourceWithRelease (server.mjs:1558) yields for getOrComputeStreaming → SSE replay. No server change to the cache paths.
  • keyId/reqId access: the provider spawn() currently receives (irRequest, authContext, isolationCtx) — it does NOT receive keyId/reqId. The TUI driver needs them (for ephemeral root + session name). Under-spec — see §"Open implementation questions". Options: (i) thread keyId/reqId into the TUI branch via isolationCtx (the manager already has safeKeyId/safeReqId and ephemeralRoot), so the driver reuses isolationCtx.ephemeralRoot rather than re-preparing; (ii) pass a tui:true to prepareIsolatedEnvironment at the server call site (flag-gated) and let the driver consume the returned ephemeralRoot. Recommended: (i) — the provider's TUI branch reads isolationCtx.ephemeralRoot + a reqId carried on isolationCtx, and PR-0's seed runs because the server passes tui: (process.env.CLAUDE_TUI_MODE==='1') to prepareIsolatedEnvironment at server.mjs:1347 and :1564. This keeps the seed/ephemeral-root creation in the manager (one owner) and the tmux drive in the provider. Maintainer to confirm the threading before PR-2 finalizes its runTuiTurn signature.

3b. Orphan reaper at startup. Call tmuxTransport.reapOrphans() from the server boot path (alongside bootstrapSandbox, server.mjs:82 import region / router init at :2334+), gated on CLAUDE_TUI_MODE==='1' so default deployments incur zero tmux dependency.

3c. max_tokens / sampling graceful-drop (§4.5/§4.6) — already the behavior; just assert + document. The TUI argv carries no --max-tokens/--temperature/etc. (interactive claude has none). ir.max_tokens (openai-to-ir.mjs:182), temperature, top_p, stop are accepted into IR and silently dropped at the argv boundary — same posture as the stream-json path. No error. Document in README (3e).

3d. ADR 0016 (authority of record). New ADR: Context (2026-06-15 billing split + ADR 0009 Amd 1 premise), Decision (TTY-backed TUI transport behind CLAUDE_TUI_MODE, default stays stream-json), the spike record (S1/S2/S3 + T1/T3/T6 results; T2/T4/T5 open), the §5.2 security model + §5.5 credential coupling, the §3.1 no-token-streaming decision, the §4.5/§4.6 dropped-param decision, A-vs-B gate semantics (no B before T2; serialized after T2; concurrent after T4). Acknowledgment section names OCP PR #101 + jaekwon-park (adopted: interactive-TUI-for-subscription idea; redesigned: transcript-read not hook-file, no --dangerously-skip-permissions, structural tool-stripping for B). Supersede note on ADR 0009 Amendment 1's billing-pool lane (§Status of the spec).

3e. README. Per CLAUDE.md release_kit.new_feature_doc_expectations:

  • Environment Variables table: CLAUDE_TUI_MODE (default unset/off; opt-in TTY path; grey-area, billing-favorable, post-2026-06-15-inference), CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL (set by TUI driver). Reserve-note CLAUDE_TUI_WARM_POOL as A-only future.
  • Troubleshooting / TUI-mode §: onboarding-hang quirk (fresh ephemeral home → seed required, PR-0); OAuth-login requirement (one claude login on the host; member keys hold OLP keys not OAuth); NO true token-streaming (§3.1 — stream:true is replay-after-completion, one burst); max_tokens/sampling params not honored (§4.5–§4.6); honest grey-area framing (§10.2 — opt-in, no anti-fingerprinting, drop-on-ban).
  • Do NOT hand-edit the Supported Providers table (sourced from models-registry.json).

4. Unit tests + fixtures

  • CLAUDE_TUI_MODE unset → spawn() invokes _spawnAndStream (assert via __setSpawnImpl mock spawn is called; runTuiTurn is NOT). C4 regression.
  • CLAUDE_TUI_MODE='1'spawn() invokes runTuiTurn (inject a mock driver returning a fixed string) and yields [delta, stop] with finish_reason:'stop'.
  • buffered path: a flagged request through the (mocked) provider produces a well-formed OpenAI JSON body (drive getOrCompute's array consumer).
  • streaming path: a flagged stream:true request replays as SSE delta(s) + [DONE] (drive irChunkToOpenAISSE).
  • dropped-param: a request with max_tokens/temperature succeeds and ignores them (no error).
  • reaper boot hook is a no-op when flag unset.
  • Full existing suite green.

5. PI231 integration checkpoint (maintainer-supervised)

  • On PI231 scratch (prod :4567 untouched), run a real flagged request end-to-end through OLP scratch instance with CLAUDE_TUI_MODE=1: buffered stream:false returns correct JSON; stream:true returns valid SSE (one burst); flag-unset run is identical to today's stream-json.
  • Pass criteria: flagged path returns correct text via tmux/transcript; cc_entrypoint=cli; default path unchanged (diff a flag-unset response against current prod behavior); reaper runs clean at startup; real ~/.claude untouched.

6. Acceptance criteria (binding)

  • Flag unset → identical to current stream-json (C4). Flag set → TUI path, correct buffered + SSE-replay responses.
  • ADR 0016 merged as authority of record, names PR #101 + jaekwon-park.
  • README documents the env var, onboarding-hang, OAuth-login req, no-token-streaming, dropped params, grey-area framing.
  • CHANGELOG Unreleased entry; CONTRIBUTORS updated; no mid-Phase version bump.

7. Reviewer (Iron Rule 10)

Fresh-context reviewer opens spec §3.1, §4.5–§4.6, §10.2, §12 (PR-3), §13 + ADR 0016 and confirms: (a) the default branch is unchanged and the flag is the sole toggle (C4); (b) the 2-chunk adapter slots into both cache paths without server cache-layer edits; (c) dropped params documented, no silent failure; (d) ADR 0016 acknowledges PR #101/jaekwon-park and the co-author trailer is on the commits; (e) README quirks present. A review that does not open ADR 0016 + confirm the author-credit obligation is not valid.

8. Authority citation

OpenAI /v1/chat/completions spec (entry surface is unchanged; stream, max_tokens, temperature, top_p, stop fields — document non-honored set) — cite the OpenAI spec URL for the entry-surface PR portion; claude CLI v2.1.158 (provider branch); ADR 0016 (new authority of record) + ADR 0009 Amendment 1 (superseded billing lane) + ADR 0002 Amendment 9 (ISOLATION) + ADR 0014 (sandbox). spec §§3.1/4.5/4.6/10.2/12/13. Co-author trailer jaekwon-park on every commit (§13).

Risk / rollback

Independently revertable (revert removes the branch; provider returns to pure stream-json). Default-off is the kill switch: until an operator sets CLAUDE_TUI_MODE=1, nothing about TUI-mode executes. The OCP single-tenant canary (post-2026-06-15) is the first real enablement.


Parallel B-gate spike track (does NOT block A)

These run independently of PR-0..PR-3 and gate Deployment B only. One paragraph each.

  • T2 — body-capture tools:[] (security + credential-safety gate, §5.2(4)/§5.5). Stand up a body-logging channel for the outbound /v1/messages from an interactive claude spawn (a local MITM proxy with a trusted cert in the ephemeral home, or a body-capturing forward proxy via HTTPS_PROXY). --debug api is insufficient (metadata only). Method: run a TUI turn under the full §5.2 flag set (--strict-mcp-config + --disallowedTools "mcp__*" + --tools ""), capture the wire request body, assert it carries tools:[] or no tools array. PASS is the hard gate that lets B launch (serialized). Per §5.5 this is a credential-safety gate, not mere MCP hygiene.
  • T4 — concurrency (§7.3). Run K concurrent TUI turns sharing one owner OAuth, each with its own ephemeral $HOME + --session-id + cwd. Method: fire K parallel runTuiTurn calls; assert transcript isolation (no cross-session lines), billing entrypoint stays cli on all, no OAuth auth contention/refresh thrash, and that one credential tolerates K concurrent interactive sessions. Until PASS, B serializes (concurrency=1). This lifts B's concurrency limit only.
  • T5 — cold-start latency + inotify (§4.4 sizing / non-blocking). Method: measure submit→transcript-available cold-start end-to-end (currently unmeasured); compare inotifywait vs 0.5s poll under load; measure Opus-class long-stream turn_duration ordering to size the §4.4 wall-clock cap (recommend ≥120s, tune here). Non-blocking for A; informs the cap constant and a possible future quiescence window (which §4.4 forbids in v1).

Test strategy on PI231 without breaking prod

  • PI231 runs prod OLP on :4567. It must stay untouched throughout. All TUI testing is /tmp scratch + tmux: a scratch OLP instance on a different port (or direct node invocation of the new modules), ephemeral homes under /tmp/olp-spawn/*, scratch tmux sessions olp-tui-*.
  • Never point a TUI test at the prod ~/.claude — the ephemeral-home seed + symlink keep the real home read-only; every PI231 checkpoint asserts ~/.claude.json + ~/.claude/projects mtime unchanged.
  • The canary is OCP single-tenant post-6/15 (spec §12.6): OCP is one user, no cross-tenant boundary, and is where PR #101 originated. Enable CLAUDE_TUI_MODE=1 there first; watch billing entrypoint stays cli, cap behavior, completion reliability over real usage — before any OLP Deployment-B exposure.
  • Mac mini is NEVER a test target (cc-mem rule). MacBook/PI231-scratch only.

Author credit (binding, §13) — checklist applied to every PR

  • Co-author trailer Co-Authored-By: jaekwon-park <…> on every implementing commit (pull real email/handle from OCP PR #101 first — do not invent).
  • ADR 0016 names PR #101 + jaekwon-park (adopted idea vs redesigned implementation).
  • Add jaekwon-park to CONTRIBUTORS.
  • Notify on OCP PR #101 (comment linking the shipping PR) at ship time.

Open implementation questions (maintainer decides BEFORE code)

  1. keyId/reqId into the TUI driver. The provider spawn(irRequest, authContext, isolationCtx) does not receive keyId/reqId today. The driver needs them for the ephemeral root + tmux session name. Recommended: have the server pass tui:(CLAUDE_TUI_MODE==='1') to prepareIsolatedEnvironment at server.mjs:1347/:1564 (so the seed + chmod fire in the manager), and thread ephemeralRoot (+ a reqId field) to the provider via isolationCtx; the TUI branch then reuses isolationCtx.ephemeralRoot rather than re-preparing. Confirm this threading before PR-2 fixes runTuiTurn's signature. (Spec §3.2 implies the reuse but does not specify the parameter plumbing.)
  2. Warm pool (§7.2) is namespace-reserved, not built. Confirm A's canary runs ephemeral-per-request (no warm pool) for this plan — the spec allows warm pool for A but it adds cross-request-context-leak risk and is out of the PR-0..PR-3 scope.
  3. Wall-clock cap constant. Spec recommends ≥120s pending T5. Confirm the v1 value to bake into readTurnResult (PR-1) — or read it from config so T5 can tune it without a code change.
  4. Large-paste path (§6 caveat). T3 validated ≤50 lines / 1.2 KB. Coding-proxy traffic carries multi-KB pastes. Decide whether PR-2 ships send-keys only (with the documented ≤50-line validation) or also wires the paste-buffer/load-buffer fallback for large bodies now (recommended as a fast-follow, non-blocking for A).
  5. Preflight MCP-disable session for A. §5.2 makes the separate-preflight /mcp-empty assertion a hard gate for B. Confirm whether A's canary runs it as advisory-at-startup (recommended) or skips it (relying on the per-spawn cache-dir assertion alone).

Maintainer decisions — plan-review fixes + open questions RESOLVED (2026-05-30)

Plan-review verdict was ready-with-fixes. All anchors verified accurate. Decisions below resolve P1P5 + the open implementation questions; the plan is now ready to implement.

Ref Decision
P1 / OQ#1 — keyId/reqId plumbing Reuse isolationCtx.ephemeralRoot + reqId. The two existing spawn call sites (server.mjs:1347, :1564) already call prepareIsolatedEnvironment and pass isolationCtx into spawn(). PR-0 adds ephemeralRoot + reqId to the returned isolationCtx; the TUI branch reads them from there — no new edits to the default-path call sites, preserving the byte-for-byte-unchanged invariant. runTuiTurn(isolationCtx, irRequest, opts) takes isolationCtx, not raw keyId/reqId.
P2 — reaper boot anchor Wire reapOrphans() into the real boot region: the isMain block at server.mjs:2417 (NOT :2334, which is wrong; :82 is the import). Co-locate with the existing await bootstrapSandbox() call.
P3 / OQ#5 — A preflight /mcp A also spawns with --strict-mcp-config + --disallowedTools "mcp__*" (defense-in-depth — even the owner does not want a prompt-injected client reaching the owner's own Gmail/Drive). The separate preflight /mcp-empty session is advisory-at-startup for A (log a warning if managed MCP still attaches; do NOT block), and a hard gate for B. Decided line item for PR-2, no longer open.
P4 — tier citation Cite accurately: manifest owner_tier ∈ {'owner','guest'} (keys.mjs:143); 'anonymous' is a runtime fallback identity (:439), not a manifest tier. Cosmetic; correct the anchor table.
P5 / OQ#3 — wall-clock cap Config, not constant. Read from env CLAUDE_TUI_WALLCLOCK_MS (default 120000) so T5 can tune it without a code change. Baked into readTurnResult (PR-1).
OQ#2 / OQ#5 — warm pool Out of PR-0..PR-3 scope. Initial A = per-request ephemeral session (cleanest, matches B). Warm pool is a later opt-in optimization (CLAUDE_TUI_WARM_POOL), process-reuse-not-context per spec §7.2, tracked separately.
OQ#4 — large-paste (>50 lines) Defer to fast-follow. PR-2 ships the send-keys -- "$(cat file)" recipe with the documented ≤50-line / multi-KB validation from T3; the paste-buffer/load-buffer path for very large bodies is a non-blocking follow-up PR. Document the current bound in the README.

Net: P1 (the one true PR-2-interface blocker) is decided = reuse isolationCtx. P2/P4 are anchor corrections. P3/P5 are decided line items. Warm-pool + large-paste are explicitly scoped out of the initial A deliverable. Implementation may proceed PR-0 → PR-1 → PR-2 → PR-3.