diff --git a/lib/tui/session.mjs b/lib/tui/session.mjs index 8142402..3514cff 100644 --- a/lib/tui/session.mjs +++ b/lib/tui/session.mjs @@ -39,11 +39,47 @@ export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) { // ── Task 5: runTuiTurn ─────────────────────────────────────────────────── // Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable. -const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); -const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10); +const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready +const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval +const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// Capture the visible tmux pane as plain text (for readiness / paste verification). +function tuiCapturePane(tmux, tmuxName) { + const r = tmux(["capture-pane", "-p", "-t", tmuxName]); + return (r && typeof r.stdout === "string") ? r.stdout : ""; +} + +// True once claude's input bar is rendered and ready for keystrokes. +function tuiInputReady(pane) { + return /\? for shortcuts/.test(pane); +} + +// True once the pasted prompt has POSITIVELY landed in the input box. We only trust +// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's +// placeholder uses a curly quote `"`, randomized example text, and renders the big paste +// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the +// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff: +// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR +// (b) the prompt's own leading text appears in the pane (short/literal paste). +function tuiPromptLanded(pane, prompt) { + const flatPane = pane.replace(/\s+/g, " "); + if (flatPane.includes("[Pasted text")) return true; + const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || ""; + const needle = firstLine.replace(/\s+/g, " ").slice(0, 24); + return needle.length >= 3 && flatPane.includes(needle); +} + +async function pollUntil(fn, { timeoutMs, intervalMs }) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { if (fn()) return true; } catch { /* ignore, keep polling */ } + await sleep(intervalMs); + } + return false; +} + // Single-quote escaper for sh -c arguments. function shq(s) { return `'${String(s).replace(/'/g, "'\\''")}'`; @@ -149,8 +185,24 @@ export function resolveTuiEntrypointEnv(env, mode = "cli") { // A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B // (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential // wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring. -function buildTuiCmd(claudeBin, model, sessionId) { +function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) { + // Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the + // spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud + // host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01: + // passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version + // (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the + // billing pool (set below per entrypointMode). + const sets = [ + `HOME=${shq(ehome)}`, + "DISABLE_AUTOUPDATER=1", + "CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1", + ]; + const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"]; + if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli"); + else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY + const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" "); return [ + envPrefix, shq(claudeBin), "--model", shq(model), "--session-id", sessionId, @@ -162,9 +214,14 @@ function buildTuiCmd(claudeBin, model, sessionId) { // 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. -// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key -// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does). +// 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). @@ -215,35 +272,52 @@ export async function runTuiTurn({ // 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)], + buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)], { env }, ); if (!spawnResult || spawnResult.status !== 0) { throw new Error("tui_spawn_failed: tmux session not created"); } - await sleep(BOOT_MS); - // 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content — - // then settle, then send a SEPARATE Enter key event to submit the line. - // - // The `-l` (literal) flag is required on the paste send-keys call so that - // a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape") - // is typed literally as text rather than being interpreted as a key binding. - // The SEPARATE Enter event below deliberately omits -l so that tmux sends a - // real keypress (carriage return) to submit the prompt line. - spawnSync( - "sh", - ["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`], - { env, encoding: "utf8" }, - ); - await sleep(PASTE_SETTLE_MS); + // 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)). + // BOOT_MS is now the MAX readiness wait, not a fixed delay. + const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)), + { timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS }); + if (!ready) { + // (readiness timed out; relying on paste-verify) + console.error("[tui] input_not_ready", tmuxName); + } + + // 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT + // `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the + // embedded newlines arrive as separate key events (effectively repeated Enter), + // so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock + // (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer + // reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p + // wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste + // ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the + // per-session tmuxName, so concurrent turns never collide. + tmux(["load-buffer", "-b", tmuxName, promptFile]); + tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]); + + // Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste + // takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing + // Enter before the paste renders submits an empty box → the turn hangs to the wallclock + // (issue #130). Fast-fail if it never lands → deterministic error in seconds. + const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt), + { timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS }); + if (!landed) { + throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms"); + } + + // Submit (separate Enter key event). tmux(["send-keys", "-t", tmuxName, "Enter"]); - // 3. Block on the native transcript (resolved by session-id) until terminal. + // 4. Block on the native transcript (resolved by session-id) until terminal. // Returns { text, entrypoint } from readTuiTranscript. return await readTuiTranscript({ home: ehome, sessionId, wallclockMs }); } finally { - // 4. Teardown — always, even on throw. + // 5. Teardown — always, even on throw. try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ } try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ } } diff --git a/lib/tui/transcript.mjs b/lib/tui/transcript.mjs index 4df0cdf..90d2e0b 100644 --- a/lib/tui/transcript.mjs +++ b/lib/tui/transcript.mjs @@ -51,19 +51,29 @@ export function parseTranscriptLines(text) { return out; } -// A line marks the assistant turn complete when it is the turn_duration system -// event. That is the ONLY reliable terminal marker in interactive TUI mode. +// A line marks the assistant turn complete when EITHER: +// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds +// (e.g. 2.1.159), OR +// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason +// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level +// end-of-turn signal, present across claude builds whose transcripts do NOT +// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without +// it OCP can't detect completion on those builds and hangs to the wallclock, +// then returns only partial text (issue #130, cloud/server-side symptom). // -// Why tool_use is NOT a terminal marker: -// In interactive claude, when the model decides to call a tool (stop_reason= -// "tool_use"), claude handles the tool call internally and then continues -// generating — the turn is NOT complete. The transcript advances to another -// assistant entry after the tool result. Only {type:"system", -// subtype:"turn_duration"} signals that claude has fully finished the turn. -// Treating tool_use as terminal would truncate tool-using turns mid-flight. +// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will +// run a tool and continue with a later assistant entry). Matching on a FINAL +// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact. +// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores +// cross-version completion detection without bringing that bug back.) +const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]); export function isTerminalLine(obj) { if (!obj || typeof obj !== "object") return false; - return obj.type === "system" && obj.subtype === "turn_duration"; + if (obj.type === "system" && obj.subtype === "turn_duration") return true; + if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { + return TERMINAL_STOP_REASONS.has(obj.message.stop_reason); + } + return false; } // Text of the LAST assistant turn: concatenate its text content blocks diff --git a/test-features.mjs b/test-features.mjs index 7e31500..398f29b 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1387,6 +1387,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a test("isTerminalLine false on ordinary assistant text line", () => { assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false); }); +// issue #130 cloud/server-side: claude builds (e.g. 2.1.114) that DON'T emit +// turn_duration mark turn-end via assistant message.stop_reason — must be terminal. +test("isTerminalLine true on assistant stop_reason end_turn (version-robust, e.g. 2.1.114)", () => { + assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } }), true); +}); +test("isTerminalLine true on assistant stop_reason stop_sequence / max_tokens", () => { + assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "stop_sequence" } }), true); + assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "max_tokens" } }), true); +}); test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => { const evs = [ { type: "assistant", message: { content: [{ type: "text", text: "first" }] } }, @@ -1615,6 +1624,66 @@ if (process.env.OCP_TUI_LIVE === "1") { }); } +// ── TUI readiness / paste-verify predicates (issue #130) ──────────────────── +// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs. +// Keep in sync with the definitions there. +function _tuiInputReady(pane) { + return /\? for shortcuts/.test(pane); +} +function _tuiPromptLanded(pane, prompt) { + const flatPane = pane.replace(/\s+/g, " "); + if (flatPane.includes("[Pasted text")) return true; + const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || ""; + const needle = firstLine.replace(/\s+/g, " ").slice(0, 24); + return needle.length >= 3 && flatPane.includes(needle); +} + +// Real captured pane samples (empirically confirmed via live capture-pane on PI231, +// claude v2.1.114 and v2.1.159). Source: issue #130 spec. +const TUI_READY_PANE = `❯ Try "how does work?" + ? for shortcuts · ← for agents`; + +const TUI_LANDED_PANE = `❯ Reply with exactly: PONG_TEST + ? for shortcuts · ← for agents`; + +// Welcome splash shown before input bar is rendered — no `? for shortcuts`. +const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`; + +console.log("\nTUI readiness + paste-verify predicates (issue #130):"); + +test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => { + assert.equal(_tuiInputReady(TUI_READY_PANE), true); +}); +test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => { + assert.equal(_tuiInputReady(TUI_LANDED_PANE), true); +}); +test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => { + assert.equal(_tuiInputReady(TUI_BOOT_PANE), false); +}); + +test("tuiPromptLanded(READY_PANE, 'Reply with exactly: PONG_TEST') === false (still placeholder)", () => { + assert.equal(_tuiPromptLanded(TUI_READY_PANE, "Reply with exactly: PONG_TEST"), false); +}); +test("tuiPromptLanded(LANDED_PANE, 'Reply with exactly: PONG_TEST') === true (prompt prefix visible)", () => { + assert.equal(_tuiPromptLanded(TUI_LANDED_PANE, "Reply with exactly: PONG_TEST"), true); +}); +test("tuiPromptLanded(READY_PANE, 'ping') === false (needle <3 chars, placeholder present)", () => { + assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false); +}); +test("tuiPromptLanded('❯ ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => { + assert.equal(_tuiPromptLanded("❯ ping\n ? for shortcuts", "ping"), true); +}); +// issue #130 root cause: a big bracketed paste shows "[Pasted text #N +M lines]" — must be landed. +test("tuiPromptLanded(bracketed-paste pane, big prompt) === true", () => { + assert.equal(_tuiPromptLanded("❯ [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true); +}); +// issue #130 false-positive guard: the EMPTY placeholder uses a CURLY quote (“) and randomized +// example text — the old placeholder-gone heuristic wrongly reported landed=true here, so Enter +// fired into an empty box. Must be FALSE (no positive signal: not [Pasted text], prompt not shown). +test("tuiPromptLanded(curly-quote placeholder, big prompt) === false (no false-positive)", () => { + assert.equal(_tuiPromptLanded("❯ Try “how do I log an error?”\n ? for shortcuts", "[System] Context 0."), false); +}); + // ── /health anonymousKey gate (issue #109) ────────────────────────────────── // MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied // verbatim to avoid importing server.mjs (top-level server.listen() would