mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +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>
325 lines
17 KiB
JavaScript
325 lines
17 KiB
JavaScript
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
|
//
|
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
|
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
|
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
|
//
|
|
// Trust handling: rather than answer the trust-folder dialog interactively (which
|
|
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
|
|
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
|
|
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readTuiTranscript } from "./transcript.mjs";
|
|
|
|
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
|
|
|
const defaultTmux = (args, opts = {}) =>
|
|
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
|
|
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
|
let killed = 0;
|
|
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
if (name.startsWith(SESSION_PREFIX)) {
|
|
tmux(["kill-session", "-t", name]);
|
|
killed++;
|
|
}
|
|
}
|
|
return killed;
|
|
}
|
|
|
|
// ── 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); // 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, "'\\''")}'`;
|
|
}
|
|
|
|
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
|
|
// the trust-folder dialog never appears. Verified-live trust shape:
|
|
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
|
|
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
|
|
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
|
|
// Must run BEFORE the session boots so claude reads the trusted record at startup.
|
|
export function ensureTuiCwdTrusted(home, cwd) {
|
|
if (!home || !cwd) return;
|
|
const path = `${home}/.claude.json`;
|
|
let j, mode;
|
|
try {
|
|
j = JSON.parse(readFileSync(path, "utf8"));
|
|
mode = statSync(path).mode & 0o777;
|
|
} catch { return; }
|
|
j.projects = j.projects || {};
|
|
const entry = j.projects[cwd] || {};
|
|
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
|
|
entry.hasTrustDialogAccepted = true;
|
|
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
|
|
j.projects[cwd] = entry;
|
|
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
|
|
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
|
|
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
|
|
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
|
|
// onboarding completion is an A-path precondition (the host already runs claude).
|
|
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
|
|
// a file we fully own, removing the real-config-mutation concern entirely.
|
|
try {
|
|
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
|
|
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
|
|
renameSync(tmp, path);
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
// Prepare the HOME claude runs under. Two modes:
|
|
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
|
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
|
|
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
|
|
// .credentials.json, with a seeded .claude.json (onboarded real config minus
|
|
// the user's project history; trusts only the scratch cwd) and its own
|
|
// projects/ dir — so the real ~/.claude is never mutated or polluted.
|
|
//
|
|
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
|
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
|
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
|
// refresh in the scratch home can invalidate the token the user's real-home claude
|
|
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
|
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
|
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
|
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
|
// corrupts. Run BEFORE the session boots.
|
|
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
|
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
|
try {
|
|
const claudeDir = `${tuiHome}/.claude`;
|
|
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
|
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
|
const link = `${claudeDir}/.credentials.json`;
|
|
if (!existsSync(link)) {
|
|
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
|
}
|
|
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
|
|
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
|
const seedPath = `${tuiHome}/.claude.json`;
|
|
if (!existsSync(seedPath)) {
|
|
let base = {};
|
|
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
|
base.hasCompletedOnboarding = true;
|
|
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
|
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
|
}
|
|
} catch { /* best effort */ }
|
|
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
|
|
ensureTuiCwdTrusted(tuiHome, cwd);
|
|
}
|
|
|
|
// ── Billing-classifier labeling ─────────────────────────────────────────
|
|
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
|
|
// inherited value first (so a stray entrypoint from OCP's own parent env can never
|
|
// leak into / mislabel the billing header). Then:
|
|
// "cli" (default) → set "cli": deterministic subscription-pool classification.
|
|
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
|
|
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
|
|
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
|
|
// observe/diagnose the real TTY-derived value.
|
|
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
|
|
export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
|
if (mode === "off") return env;
|
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
return env;
|
|
}
|
|
|
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
|
// belt-and-braces with --disallowedTools "mcp__*".
|
|
// 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, 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,
|
|
"--strict-mcp-config",
|
|
"--disallowedTools", shq("mcp__*"),
|
|
].join(" ");
|
|
}
|
|
|
|
// Full per-request TUI lifecycle:
|
|
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
|
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
|
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
|
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
|
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
|
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
|
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
|
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
|
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
|
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
|
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
|
// marker or wall-clock cap.
|
|
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
|
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
|
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
|
export async function runTuiTurn({
|
|
prompt,
|
|
model,
|
|
claudeBin,
|
|
home,
|
|
realHome,
|
|
cwd,
|
|
wallclockMs = 120000,
|
|
entrypointMode = "cli",
|
|
tmux = defaultTmux,
|
|
}) {
|
|
const sessionId = randomUUID();
|
|
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
|
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
|
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
|
|
|
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
|
// cwd — before claude boots.
|
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
|
prepareTuiHome(rhome, ehome, cwd);
|
|
|
|
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
|
const promptFile = `${tmpDir}/prompt.txt`;
|
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
|
|
|
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
|
|
// env vars that might interfere with interactive-mode classification.
|
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
|
delete env.CLAUDECODE;
|
|
delete env.ANTHROPIC_API_KEY;
|
|
delete env.ANTHROPIC_BASE_URL;
|
|
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
|
|
resolveTuiEntrypointEnv(env, entrypointMode);
|
|
|
|
try {
|
|
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
|
// Capture the result: if tmux new-session fails (status !== 0) there is no
|
|
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
|
|
// into a non-existent session or issue a billing request without a verified
|
|
// interactive context. The finally teardown is still harmless (kill-session
|
|
// is a no-op when the session never existed).
|
|
const spawnResult = tmux(
|
|
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
|
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
|
{ env },
|
|
);
|
|
if (!spawnResult || spawnResult.status !== 0) {
|
|
throw new Error("tui_spawn_failed: tmux session not created");
|
|
}
|
|
|
|
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
|
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
|
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
|
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
|
if (!ready) {
|
|
// (readiness timed out; relying on paste-verify)
|
|
console.error("[tui] input_not_ready", tmuxName);
|
|
}
|
|
|
|
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
|
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
|
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
|
// 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"]);
|
|
|
|
// 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 {
|
|
// 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 */ }
|
|
}
|
|
}
|