mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
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:
+98
-24
@@ -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 */ }
|
||||
}
|
||||
|
||||
+20
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user