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>
145 lines
7.0 KiB
JavaScript
145 lines
7.0 KiB
JavaScript
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
|
// and returns the latest assistant turn's text once the turn is terminal.
|
|
//
|
|
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
|
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
|
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
|
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
|
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
|
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
|
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
|
export function encodeCwd(cwd) {
|
|
return cwd.replace(/[/.]/g, "-");
|
|
}
|
|
|
|
export function transcriptPath(home, cwd, sessionId) {
|
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
|
}
|
|
|
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
|
export function findTranscriptPath(home, sessionId) {
|
|
if (!home || !sessionId) return null;
|
|
const root = `${home}/.claude/projects`;
|
|
let dirs;
|
|
try { dirs = readdirSync(root); } catch { return null; }
|
|
for (const d of dirs) {
|
|
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
|
export function parseTranscriptLines(text) {
|
|
const out = [];
|
|
for (const line of text.split("\n")) {
|
|
const t = line.trim();
|
|
if (!t) continue;
|
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// 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).
|
|
//
|
|
// 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;
|
|
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
|
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
|
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
|
//
|
|
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
|
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
|
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
|
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
|
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
|
// author must add user-line scoping here. See spec §7.2.
|
|
export function extractLatestAssistantText(events) {
|
|
let text = "";
|
|
for (const ev of events) {
|
|
if (!ev || ev.type !== "assistant") continue;
|
|
const content = ev.message && ev.message.content;
|
|
if (!Array.isArray(content)) continue;
|
|
const parts = content
|
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
.map((b) => b.text);
|
|
if (parts.length) text = parts.join("");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
|
// or null if absent. Lets callers assert the subscription-classified path.
|
|
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
|
export function verifyEntrypoint(events) {
|
|
for (const ev of events) {
|
|
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
|
return ev.entrypoint != null ? ev.entrypoint : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Block until the session transcript is terminal (turn_duration) or
|
|
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
|
// editors). Returns { text, entrypoint } where text is the latest assistant text
|
|
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g.
|
|
// "cli"), or null if not yet present. On cap with text, returns the partial result;
|
|
// on cap with no text at all, throws.
|
|
//
|
|
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
|
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
|
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
|
// file does not exist until the turn starts, so resolution happens inside the loop.
|
|
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
|
const deadline = Date.now() + wallclockMs;
|
|
let lastText = "";
|
|
let lastEntrypoint = null;
|
|
while (Date.now() < deadline) {
|
|
const resolved = p || findTranscriptPath(home, sessionId);
|
|
if (resolved && existsSync(resolved)) {
|
|
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
|
lastText = extractLatestAssistantText(events) || lastText;
|
|
const ep = verifyEntrypoint(events);
|
|
if (ep != null) lastEntrypoint = ep;
|
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
|
}
|
|
await sleep(pollMs);
|
|
}
|
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
|
}
|