fix(tui): reliable large-prompt paste + version-robust turn detection (#130) (#131)

TUI mode hung ("stuck typing") on the OpenClaw agent's real (large, multi-line) prompts.
Three root causes, all fixed:

1. transcript.mjs — terminal detection only recognized {system, turn_duration}, which
   claude 2.1.114 (the cloud host) does not emit → readTuiTranscript never saw completion
   and ran to the 120s wallclock even though claude had answered. Now also treats an
   {assistant} line with message.stop_reason ∈ {end_turn, stop_sequence, max_tokens} as
   terminal (version-robust; stop_reason "tool_use" stays non-terminal, so tool turns are
   not truncated — preserves the v3.17.1 narrowing).

2. session.mjs paste — send-keys -l "$(cat file)" delivers a large multi-line prompt's
   newlines as separate key events (≈repeated Enter), so the prompt never lands. Replaced
   with tmux load-buffer + paste-buffer -p (bracketed paste): atomic, no shell arg limit,
   claude ingests it as one "[Pasted text]".

3. session.mjs verify — the old "placeholder-gone" heuristic false-positived on claude's
   empty curly-quote placeholder, so Enter fired into an empty box (THE root cause of the
   hang). tuiPromptLanded now trusts only positive signals ([Pasted text] indicator or the
   prompt's own text); readiness-poll + paste-verify-poll + fast-fail (tui_paste_not_landed)
   turns a 120s wallclock hang into a deterministic ~5s error.

Also: env delivered to the pane via an `env`-prefix on the pane command (tmux does NOT
forward spawnSync's env, and new-session -e needs tmux ≥3.2 while the cloud host runs 2.7),
carrying DISABLE_AUTOUPDATER (version pin) + CLAUDE_CODE_ENTRYPOINT labeling.

Validated live on both hosts: a 300-line / ~30KB prompt that previously hung now returns
in ~5-8s; small prompts ~4-5s. 192 tests pass (new: terminal-detection across schemas,
positive-signal verify incl. the curly-quote false-positive guard, paste predicates).

Scope discipline: an attempt to also strip claude's CLAUDE.md/auto-memory injection (context
reduction) was REVERTED — it didn't measurably reduce context through OCP, caused an
off-response, and is a separate concern. It will be its own measured branch.

ALIGNMENT.md: TUI is the ADR-0007 proxy-side execution bridge, not a forwarded Anthropic
operation, so a cli.js citation is N/A under Rule 2. No blacklisted tokens / port literals.

Independent fresh-context reviewer (opus): APPROVE WITH CHANGES (Iron Rule 10) — validated
the three fixes, caught that a per-session tmux socket would break the startup stale-session
reaper, and flagged the context-suppression scope-creep; both were reverted per the review.

Closes #130.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dtzp555-max
2026-06-01 21:26:25 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.8
parent 1f577c075f
commit 9568411bcb
3 changed files with 187 additions and 34 deletions
+98 -24
View File
@@ -39,11 +39,47 @@ export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
// ── Task 5: runTuiTurn ─────────────────────────────────────────────────── // ── Task 5: runTuiTurn ───────────────────────────────────────────────────
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable. // Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10); 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)); 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. // Single-quote escaper for sh -c arguments.
function shq(s) { function shq(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`; 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 // 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 // (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. // 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 [ return [
envPrefix,
shq(claudeBin), shq(claudeBin),
"--model", shq(model), "--model", shq(model),
"--session-id", sessionId, "--session-id", sessionId,
@@ -162,9 +214,14 @@ function buildTuiCmd(claudeBin, model, sessionId) {
// Full per-request TUI lifecycle: // Full per-request TUI lifecycle:
// 1. Pre-trust the scratch cwd (no trust dialog will appear). // 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). // 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. // 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key // capture-pane until the `? for shortcuts` input bar appears (readiness-poll
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does). // 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 // 5. Block on the native JSONL transcript (located by session-id) until terminal
// marker or wall-clock cap. // marker or wall-clock cap.
// 6. Always teardown: kill session + rm temp dir (even on throw). // 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). // is a no-op when the session never existed).
const spawnResult = tmux( const spawnResult = tmux(
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd, ["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
buildTuiCmd(claudeBin, model, sessionId)], buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
{ env }, { env },
); );
if (!spawnResult || spawnResult.status !== 0) { if (!spawnResult || spawnResult.status !== 0) {
throw new Error("tui_spawn_failed: tmux session not created"); 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 — // 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
// then settle, then send a SEPARATE Enter key event to submit the line. // BOOT_MS is now the MAX readiness wait, not a fixed delay.
// const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
// The `-l` (literal) flag is required on the paste send-keys call so that { timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape") if (!ready) {
// is typed literally as text rather than being interpreted as a key binding. // (readiness timed out; relying on paste-verify)
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a console.error("[tui] input_not_ready", tmuxName);
// real keypress (carriage return) to submit the prompt line. }
spawnSync(
"sh", // 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`], // `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
{ env, encoding: "utf8" }, // 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
await sleep(PASTE_SETTLE_MS); // (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"]); 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. // Returns { text, entrypoint } from readTuiTranscript.
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs }); return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
} finally { } finally {
// 4. Teardown — always, even on throw. // 5. Teardown — always, even on throw.
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ } try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ } try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
} }
+20 -10
View File
@@ -51,19 +51,29 @@ export function parseTranscriptLines(text) {
return out; return out;
} }
// A line marks the assistant turn complete when it is the turn_duration system // A line marks the assistant turn complete when EITHER:
// event. That is the ONLY reliable terminal marker in interactive TUI mode. // (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: // stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
// In interactive claude, when the model decides to call a tool (stop_reason= // run a tool and continue with a later assistant entry). Matching on a FINAL
// "tool_use"), claude handles the tool call internally and then continues // stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
// generating — the turn is NOT complete. The transcript advances to another // (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
// assistant entry after the tool result. Only {type:"system", // cross-version completion detection without bringing that bug back.)
// subtype:"turn_duration"} signals that claude has fully finished the turn. const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
export function isTerminalLine(obj) { export function isTerminalLine(obj) {
if (!obj || typeof obj !== "object") return false; 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 // Text of the LAST assistant turn: concatenate its text content blocks
+69
View File
@@ -1387,6 +1387,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a
test("isTerminalLine false on ordinary assistant text line", () => { test("isTerminalLine false on ordinary assistant text line", () => {
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false); 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", () => { test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
const evs = [ const evs = [
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } }, { 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 <filepath> 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) ────────────────────────────────── // ── /health anonymousKey gate (issue #109) ──────────────────────────────────
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied // MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
// verbatim to avoid importing server.mjs (top-level server.listen() would // verbatim to avoid importing server.mjs (top-level server.listen() would