From 14c10e9b4298d57dc48a7bea2994bae462c760a1 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Fri, 27 Mar 2026 00:05:03 -0700 Subject: [PATCH] Handle delayed Claude tmux prompts --- src/loop/tmux.ts | 26 ++++++++----- tests/loop/tmux.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 074096e..8ac0722 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -46,7 +46,7 @@ const CLAUDE_EXIT_OPTION = "No, exit"; const CLAUDE_DEV_CHANNELS_PROMPT = "WARNING: Loading development channels"; const CLAUDE_DEV_CHANNELS_CONFIRM = "I am using this for local development"; const CLAUDE_CHANNEL_SCOPE = "local"; -const CLAUDE_PROMPT_INITIAL_POLLS = 4; +const CLAUDE_PROMPT_MAX_POLLS = 8; const CLAUDE_PROMPT_POLL_DELAY_MS = 250; const CLAUDE_PROMPT_SETTLE_POLLS = 2; const MCP_ALREADY_EXISTS_RE = /already exists/i; @@ -758,17 +758,17 @@ const unblockClaudePane = async ( ): Promise => { const pane = `${session}:0.0`; let handledPrompt = false; + let lastSnapshot = ""; let quietPolls = 0; + let sawOutput = false; - for ( - let attempt = 0; - attempt < CLAUDE_PROMPT_INITIAL_POLLS * 2; - attempt += 1 - ) { - const prompt = detectClaudePrompt(deps.capturePane(pane)); + for (let attempt = 0; attempt < CLAUDE_PROMPT_MAX_POLLS; attempt += 1) { + const snapshot = normalizePaneText(deps.capturePane(pane)); + const prompt = detectClaudePrompt(snapshot); if (prompt === "confirm") { deps.sendKeys(pane, ["Enter"]); handledPrompt = true; + lastSnapshot = ""; quietPolls = 0; await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); continue; @@ -778,16 +778,24 @@ const unblockClaudePane = async ( await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); deps.sendKeys(pane, ["Enter"]); handledPrompt = true; + lastSnapshot = ""; quietPolls = 0; await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); continue; } - quietPolls += 1; + if (snapshot) { + sawOutput = true; + } + quietPolls = snapshot === lastSnapshot ? quietPolls + 1 : 0; + lastSnapshot = snapshot; if (handledPrompt && quietPolls >= CLAUDE_PROMPT_SETTLE_POLLS) { return; } - if (!handledPrompt && quietPolls >= CLAUDE_PROMPT_INITIAL_POLLS) { + if (sawOutput && quietPolls >= CLAUDE_PROMPT_SETTLE_POLLS) { + return; + } + if (attempt + 1 >= CLAUDE_PROMPT_MAX_POLLS) { return; } await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 5f3307d..505b524 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -679,7 +679,7 @@ test("runInTmux starts paired interactive tmux panes without a task", async () = expect(manifest.tmuxSession).toBe("repo-loop-1"); }); -test("runInTmux keeps the no-prompt Claude startup wait short", async () => { +test("runInTmux keeps the no-prompt Claude startup wait bounded", async () => { const sleeps: number[] = []; let sessionStarted = false; const manifest = createRunManifest({ @@ -743,7 +743,7 @@ test("runInTmux keeps the no-prompt Claude startup wait short", async () => { { opts: makePairedOptions({ proof: "" }) } ); - expect(sleeps).toEqual([250, 250, 250]); + expect(sleeps).toEqual([250, 250, 250, 250, 250, 250, 250]); }); test("tmux prompts keep the paired review workflow explicit", () => { @@ -1020,6 +1020,86 @@ test("runInTmux confirms wrapped Claude dev-channel prompts", async () => { }); }); +test("runInTmux catches a delayed Claude dev-channel prompt", async () => { + const keyCalls: Array<{ keys: string[]; pane: string }> = []; + let sessionStarted = false; + let pollCount = 0; + const devChannelsPrompt = [ + "WARNING: Loading development channels", + "", + "--dangerously-load-development-channels is for local channel development only.", + "", + "1. I am using this for local development", + ].join("\n"); + const manifest = createRunManifest({ + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "1", + status: "running", + }); + const storage = { + manifestPath: "/repo/.loop/runs/1/manifest.json", + repoId: "repo-123", + runDir: "/repo/.loop/runs/1", + runId: "1", + storageRoot: "/repo/.loop/runs", + transcriptPath: "/repo/.loop/runs/1/transcript.jsonl", + }; + + await runInTmux( + ["--tmux", "--proof", "verify with tests"], + { + capturePane: () => { + pollCount += 1; + return pollCount === 5 ? devChannelsPrompt : ""; + }, + cwd: "/repo", + env: {}, + findBinary: () => true, + getCodexAppServerUrl: () => "ws://127.0.0.1:4500", + getLastCodexThreadId: () => "codex-thread-1", + isInteractive: () => false, + launchArgv: ["bun", "/repo/src/cli.ts"], + log: (): void => undefined, + makeClaudeSessionId: () => "claude-session-1", + preparePairedRun: (nextOpts) => { + nextOpts.codexMcpConfigArgs = [ + "-c", + 'mcp_servers.loop-bridge.command="loop"', + ]; + return { manifest, storage }; + }, + sendKeys: (pane: string, keys: string[]) => { + keyCalls.push({ keys, pane }); + }, + sendText: (): void => undefined, + sleep: () => Promise.resolve(), + startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"), + startPersistentAgentSession: () => Promise.resolve(undefined), + spawn: (args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return sessionStarted + ? { exitCode: 0, stderr: "" } + : { exitCode: 1, stderr: "" }; + } + if (args[0] === "tmux" && args[1] === "new-session") { + sessionStarted = true; + } + return { exitCode: 0, stderr: "" }; + }, + updateRunManifest: (_path, update) => update(manifest), + }, + { opts: makePairedOptions(), task: "Ship feature" } + ); + + expect(keyCalls).toContainEqual({ + keys: ["Enter"], + pane: "repo-loop-1:0.0", + }); +}); + test("runInTmux confirms the current Claude bypass prompt wording", async () => { const keyCalls: Array<{ keys: string[]; pane: string }> = []; let sessionStarted = false;