diff --git a/src/loop/bridge.ts b/src/loop/bridge.ts index 2be31dc..79e98bd 100644 --- a/src/loop/bridge.ts +++ b/src/loop/bridge.ts @@ -362,6 +362,7 @@ const claudeChannelInstructions = (): string => [ `Messages from the Codex agent arrive as .`, 'When you are replying to an inbound channel message, use the "reply" tool and pass back the same chat_id.', + "Never answer the human when the inbound message came from Codex. Send the response back through the bridge tools instead.", 'Use the "send_to_agent" tool for proactive messages to Codex that are not direct replies to a channel message.', 'Use "bridge_status" only when direct delivery appears stuck.', ].join("\n"); diff --git a/src/loop/paired-loop.ts b/src/loop/paired-loop.ts index 3fc2323..54405e0 100644 --- a/src/loop/paired-loop.ts +++ b/src/loop/paired-loop.ts @@ -94,7 +94,7 @@ const bridgeGuidance = (agent: Agent): string => { return [ "Paired mode:", `You are in a persistent Claude/Codex pair. Use the MCP tool "send_to_agent" when you want ${peer} to act, review, or answer.`, - 'Do not ask the human to relay messages between agents. Use "bridge_status" if you need the current bridge state.', + 'Do not ask the human to relay messages between agents or answer the human on the other agent\'s behalf. Use "bridge_status" if you need the current bridge state.', 'If "bridge_status" shows pending messages addressed to you, call "receive_messages" to read them.', ].join("\n"); }; @@ -148,7 +148,9 @@ const forwardBridgePrompt = (source: Agent, message: string): string => [ `Message from ${capitalize(source)} via the loop bridge:`, message.trim(), - "Treat this as direct agent-to-agent coordination. Reply with send_to_agent only when you have something useful for the other agent to act on. Do not acknowledge receipt without new information.", + "Treat this as direct agent-to-agent coordination. Do not reply to the human.", + 'Reply to the other agent with "send_to_agent" only when you have something useful for them to act on.', + "Do not acknowledge receipt without new information.", ].join("\n\n"); const updateIds = (state: PairedState): void => { diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 64e959e..0d81c7a 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -36,6 +36,8 @@ const RUN_BASE_ENV = "LOOP_RUN_BASE"; const RUN_ID_ENV = "LOOP_RUN_ID"; const CLAUDE_TRUST_PROMPT = "Is this a project you created or one you trust?"; const CLAUDE_BYPASS_PROMPT = "running in Bypass Permissions mode"; +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 = 8; const CLAUDE_PROMPT_POLL_DELAY_MS = 250; @@ -122,13 +124,21 @@ const appendProofPrompt = (parts: string[], proof: string): void => { parts.push(`Proof requirements:\n${trimmed}`); }; -const pairedBridgeGuidance = (agent: Agent): string => { - const peer = agent === "claude" ? "Codex" : "Claude"; +const pairedBridgeGuidance = (agent: Agent, runId: string): string => { + const serverName = buildClaudeChannelServerName(runId); + + if (agent === "claude") { + return [ + `Your bridge MCP server is "${serverName}". All bridge tool calls must use the mcp__${serverName}__ prefix.`, + 'Reply to inbound Codex channel messages with the MCP tool "reply" and the same chat_id.', + 'Use "send_to_agent" only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.', + 'Use "bridge_status" or "receive_messages" only if delivery looks stuck.', + ].join("\n"); + } + return [ - "Paired mode:", - `You are in a persistent Claude/Codex pair. Use the MCP tool "send_to_agent" when you want ${peer} to act, review, or answer.`, - "Do not ask the human to relay messages between agents. Normal paired messages should arrive directly.", - 'Use "bridge_status" only for diagnostics if direct delivery seems stuck. Use "receive_messages" only as a manual fallback.', + 'Message Claude with "send_to_agent", not a human-facing message.', + 'Use "bridge_status" or "receive_messages" only if delivery looks stuck.', ].join("\n"); }; @@ -138,104 +148,119 @@ const pairedWorkflowGuidance = (opts: Options, agent: Agent): string => { if (agent === opts.agent) { return [ - "Workflow:", - `You are the main worker. ${peer} is the peer reviewer/support agent.`, - "Do the implementation and verification work yourself first.", - `After your initial pass, ask ${peer} for review with "send_to_agent". Also do your own final review before closing out.`, - "If either your own review or the peer review finds an issue, keep working and repeat the review cycle until both reviews pass.", - "Do not stop after a single passing review.", - "Once both reviews pass, do the PR step yourself: create a draft PR for the current branch, or if a PR already exists, send a follow-up commit to it.", + `You are the main worker. ${peer} reviews and helps on request.`, + "Implement and verify first, then ask for review.", + "Keep iterating until your own review and the peer review both pass.", + "After both pass, handle the PR yourself: create a draft PR or send a follow-up commit to the existing PR.", ].join("\n"); } return [ - "Workflow:", `${primary} is the main worker. You are the reviewer/support agent.`, "Do not take over the task or create the PR yourself.", - `When ${primary} asks for review, do a real review against the task, proof requirements, and current repo state.`, - "If you find an issue, send clear actionable feedback back to the main worker.", - "If the work looks good, send an explicit approval so the main worker can count your review as passed.", + `When ${primary} asks, do a real review against the task, proof requirements, and repo state.`, + "Send either clear actionable feedback or an explicit approval.", ].join("\n"); }; -const buildPrimaryPrompt = (task: string, opts: Options): string => { +const buildPrimaryPrompt = ( + task: string, + opts: Options, + runId: string +): string => { const peer = capitalize(peerAgent(opts.agent)); const parts = [ - `Paired tmux mode. You are the primary ${capitalize(opts.agent)} agent for this run.`, + `Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`, `Task:\n${task.trim()}`, `Your peer is ${peer}. Do the initial pass yourself, then use "send_to_agent" when you want review or targeted help from ${peer}.`, ]; appendProofPrompt(parts, opts.proof); parts.push(SPAWN_TEAM_WITH_WORKTREE_ISOLATION); - parts.push(pairedBridgeGuidance(opts.agent)); + parts.push(pairedBridgeGuidance(opts.agent, runId)); parts.push(pairedWorkflowGuidance(opts, opts.agent)); parts.push( - `${peer} has already been prompted as the reviewer/support agent and should send you a short ready message. Wait briefly for that ready signal if it arrives quickly, then review the repo and begin the task. Ask ${peer} for review once you have concrete work or a specific question.` + `${peer} should send a short ready message. Wait briefly if it arrives, then inspect the repo and start. Ask ${peer} for review once you have concrete work or a specific question.` ); return parts.join("\n\n"); }; -const buildPeerPrompt = (task: string, opts: Options, agent: Agent): string => { +const buildPeerPrompt = ( + task: string, + opts: Options, + agent: Agent, + runId: string +): string => { const primary = capitalize(opts.agent); const parts = [ - `Paired tmux mode. ${primary} is the primary agent for this run.`, + `Agent-to-agent pair programming: ${primary} is the primary agent for this run.`, `Task:\n${task.trim()}`, `You are ${capitalize(agent)}. Do not start implementing or verifying this task on your own.`, ]; appendProofPrompt(parts, opts.proof); - parts.push(pairedBridgeGuidance(agent)); + parts.push(pairedBridgeGuidance(agent, runId)); parts.push(pairedWorkflowGuidance(opts, agent)); parts.push( - `Your first action is to use "send_to_agent" to tell ${primary}: "Reviewer ready. I have the task context and I am waiting for your request." After that, wait for ${primary} to send you a targeted request or review ask.` + `Wait for ${primary} to send you a targeted request or review ask.` ); return parts.join("\n\n"); }; -const buildInteractivePrimaryPrompt = (opts: Options): string => { +const buildInteractivePrimaryPrompt = ( + opts: Options, + runId: string +): string => { const peer = capitalize(peerAgent(opts.agent)); const parts = [ - `Paired tmux mode. You are the primary ${capitalize(opts.agent)} agent for this run.`, + `Agent-to-agent pair programming: you are the primary ${capitalize(opts.agent)} agent for this run.`, "No task has been assigned yet.", - `Your peer is ${peer}. Stay in paired mode and use "send_to_agent" when you want ${peer} to review work, answer questions, or help once the human gives you a task.`, + `Your peer is ${peer}. Use "send_to_agent" for review or help once the human gives you a task.`, ]; appendProofPrompt(parts, opts.proof); parts.push( `${SPAWN_TEAM_WITH_WORKTREE_ISOLATION} Apply that once the human gives you a concrete task.` ); - parts.push(pairedBridgeGuidance(opts.agent)); + parts.push(pairedBridgeGuidance(opts.agent, runId)); parts.push(pairedWorkflowGuidance(opts, opts.agent)); parts.push( - `Wait for the human to provide the first task. Do not start implementing anything until a task arrives. Once you have a concrete task, coordinate directly with ${peer} and keep the paired review workflow intact.` + `Wait for the first human task. Do not implement until one arrives. Once it does, coordinate directly with ${peer} and keep the paired review workflow intact. Do not send a message to ${peer} until then.` ); return parts.join("\n\n"); }; -const buildInteractivePeerPrompt = (opts: Options, agent: Agent): string => { +const buildInteractivePeerPrompt = ( + opts: Options, + agent: Agent, + runId: string +): string => { const primary = capitalize(opts.agent); const parts = [ - `Paired tmux mode. ${primary} is the primary agent for this run.`, + `Agent-to-agent pair programming: ${primary} is the primary agent for this run.`, "No task has been assigned yet.", - `You are ${capitalize(agent)}. Your reviewer/support role is active, but do not start implementing or verifying anything until ${primary} or the human gives you a specific request.`, + `You are ${capitalize(agent)}. Stay idle until ${primary} sends a specific request or the human clearly assigns you separate work.`, ]; appendProofPrompt(parts, opts.proof); - parts.push(pairedBridgeGuidance(agent)); + parts.push(pairedBridgeGuidance(agent, runId)); parts.push(pairedWorkflowGuidance(opts, agent)); parts.push( - `Your first action is to use "send_to_agent" to tell ${primary}: "Reviewer ready. No task yet. I am waiting for your request." After that, wait for the human or ${primary} to provide a concrete task or review request.` + `Wait for ${primary} to provide a concrete task or review request. Do not send a message to ${primary} yet. If the human clearly assigns you separate work in this pane, treat that as a new task. If you are answering ${primary}, use the bridge tools instead of a human-facing reply.` ); return parts.join("\n\n"); }; -const buildLaunchPrompt = (launch: PairedTmuxLaunch, agent: Agent): string => { +const buildLaunchPrompt = ( + launch: PairedTmuxLaunch, + agent: Agent, + runId: string +): string => { const task = launch.task?.trim(); if (!task) { return launch.opts.agent === agent - ? buildInteractivePrimaryPrompt(launch.opts) - : buildInteractivePeerPrompt(launch.opts, agent); + ? buildInteractivePrimaryPrompt(launch.opts, runId) + : buildInteractivePeerPrompt(launch.opts, agent, runId); } return launch.opts.agent === agent - ? buildPrimaryPrompt(task, launch.opts) - : buildPeerPrompt(task, launch.opts, agent); + ? buildPrimaryPrompt(task, launch.opts, runId) + : buildPeerPrompt(task, launch.opts, agent, runId); }; const resolveTmuxModel = (agent: Agent, opts: Options): string => { @@ -675,13 +700,19 @@ const runTmuxCommand = ( throw new Error(`${message}${suffix}`); }; -const detectClaudePrompt = (text: string): "bypass" | "trust" | undefined => { - if (text.includes(CLAUDE_TRUST_PROMPT)) { - return "trust"; - } +const detectClaudePrompt = (text: string): "bypass" | "confirm" | undefined => { if (text.includes(CLAUDE_BYPASS_PROMPT)) { return "bypass"; } + if (text.includes(CLAUDE_TRUST_PROMPT)) { + return "confirm"; + } + if ( + text.includes(CLAUDE_DEV_CHANNELS_PROMPT) && + text.includes(CLAUDE_DEV_CHANNELS_CONFIRM) + ) { + return "confirm"; + } return undefined; }; @@ -701,7 +732,7 @@ const unblockClaudePane = async ( attempt += 1 ) { const prompt = detectClaudePrompt(deps.capturePane(pane)); - if (prompt === "trust") { + if (prompt === "confirm") { deps.sendKeys(pane, ["Enter"]); handledPrompt = true; quietPolls = 0; @@ -805,8 +836,8 @@ const startPairedSession = async ( const claudeChannelServer = buildClaudeChannelServerName(storage.runId); registerClaudeChannelServer(deps, claudeChannelServer, storage.runDir); const env = [`${RUN_BASE_ENV}=${runBase}`, `${RUN_ID_ENV}=${storage.runId}`]; - const claudePrompt = buildLaunchPrompt(launch, "claude"); - const codexPrompt = buildLaunchPrompt(launch, "codex"); + const claudePrompt = buildLaunchPrompt(launch, "claude", storage.runId); + const codexPrompt = buildLaunchPrompt(launch, "codex", storage.runId); const claudeCommand = buildShellCommand([ "env", ...env, @@ -814,7 +845,8 @@ const startPairedSession = async ( claudeSessionId, resolveTmuxModel("claude", launch.opts), claudeChannelServer, - hadClaudeSession + hadClaudeSession, + hadClaudeSession ? undefined : claudePrompt ), ]); const codexCommand = buildShellCommand([ @@ -870,18 +902,12 @@ const startPairedSession = async ( const primaryPrompt = launch.opts.agent === "claude" ? claudePrompt : codexPrompt; - if (!hadClaudeSession && peerPane.endsWith(":0.0")) { - await seedPanePrompt(peerPane, peerPrompt, deps); - } if (!hadCodexThread && peerPane.endsWith(":0.1")) { await submitCodexPrompt(session, peerPrompt, deps); } if (!(hadClaudeSession && hadCodexThread)) { await deps.sleep(REVIEWER_BOOT_DELAY_MS); } - if (!hadClaudeSession && primaryPane.endsWith(":0.0")) { - await seedPanePrompt(primaryPane, primaryPrompt, deps); - } if (!hadCodexThread && primaryPane.endsWith(":0.1")) { await submitCodexPrompt(session, primaryPrompt, deps); } diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 22475a8..b8f0803 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -308,6 +308,9 @@ test("bridge MCP handles standard empty-list and ping requests through the CLI p expect(result.stdout).toContain( '\\"reply\\" tool and pass back the same chat_id' ); + expect(result.stdout).toContain( + "Never answer the human when the inbound message came from Codex" + ); expect(result.stdout).toContain('"id":2'); expect(result.stdout).toContain('"result":{}'); expect(result.stdout).toContain('"id":3'); diff --git a/tests/loop/paired-loop.test.ts b/tests/loop/paired-loop.test.ts index a5a0627..8e0a7c4 100644 --- a/tests/loop/paired-loop.test.ts +++ b/tests/loop/paired-loop.test.ts @@ -607,6 +607,10 @@ test("runPairedLoop delivers forwarded bridge messages to the target agent", asy "Message from Codex via the loop bridge:" ); expect(calls[0]?.prompt).toContain("Please review the Codex output."); + expect(calls[0]?.prompt).toContain("Do not reply to the human."); + expect(calls[0]?.prompt).toContain( + 'Reply to the other agent with "send_to_agent"' + ); const events = bridgeInternals.readBridgeEvents(runDir); expect(events.some((event) => event.kind === "delivered")).toBe(true); @@ -773,6 +777,7 @@ test("runPairedLoop delivers peer messages back to the primary agent", async () expect(calls[1]?.prompt).toContain( "Please verify the implementation details." ); + expect(calls[1]?.prompt).toContain("Do not reply to the human."); expect(calls[2]?.agent).toBe("claude"); expect(calls[2]?.prompt).toContain( "Message from Codex via the loop bridge:" @@ -780,6 +785,9 @@ test("runPairedLoop delivers peer messages back to the primary agent", async () expect(calls[2]?.prompt).toContain( "Found one change to make before landing this." ); + expect(calls[2]?.prompt).toContain( + 'Reply to the other agent with "send_to_agent"' + ); }); }); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 90b3850..939df2c 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -289,6 +289,12 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { ["bun", "/repo/src/cli.ts"], storage.runDir ); + const claudePrompt = tmuxInternals.buildPeerPrompt( + "Ship feature", + opts, + "claude", + "1" + ); const claudeCommand = tmuxInternals.buildShellCommand([ "env", ...env, @@ -296,7 +302,8 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { "claude-session-1", "opus", claudeChannelServer, - false + false, + claudePrompt ), ]); const codexCommand = tmuxInternals.buildShellCommand([ @@ -373,11 +380,9 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { values.push(entry.text); typedByPane.set(entry.pane, values); } - expect(typedByPane.get("repo-loop-1:0.0")?.join("\n")).toBe( - tmuxInternals.buildPeerPrompt("Ship feature", opts, "claude") - ); + expect(typedByPane.get("repo-loop-1:0.0")).toBeUndefined(); expect(typedByPane.get("repo-loop-1:0.1")?.join("\n")).toBe( - tmuxInternals.buildPrimaryPrompt("Ship feature", opts) + tmuxInternals.buildPrimaryPrompt("Ship feature", opts, "1") ); expect(logs[0]).toBe( "[loop] starting paired tmux workspace. This can take a few seconds..." @@ -461,55 +466,113 @@ test("runInTmux starts paired interactive tmux panes without a task", async () = expect(delegated).toBe(true); expect(calls[0]).toEqual(["tmux", "has-session", "-t", "repo-loop-1"]); + const env = ["LOOP_RUN_BASE=repo", "LOOP_RUN_ID=1"]; + const claudeChannelServer = tmuxInternals.buildClaudeChannelServerName("1"); + const claudePrompt = tmuxInternals.buildInteractivePeerPrompt( + opts, + "claude", + "1" + ); + const claudeCommand = tmuxInternals.buildShellCommand([ + "env", + ...env, + ...tmuxInternals.buildClaudeCommand( + "claude-session-1", + "opus", + claudeChannelServer, + false, + claudePrompt + ), + ]); + expect(calls[2]).toEqual([ + "tmux", + "new-session", + "-d", + "-s", + "repo-loop-1", + "-c", + "/repo", + claudeCommand, + ]); const typedByPane = new Map(); for (const entry of typed) { const values = typedByPane.get(entry.pane) ?? []; values.push(entry.text); typedByPane.set(entry.pane, values); } - expect(typedByPane.get("repo-loop-1:0.0")?.join("\n")).toBe( - tmuxInternals.buildInteractivePeerPrompt(opts, "claude") - ); + expect(typedByPane.get("repo-loop-1:0.0")).toBeUndefined(); expect(typedByPane.get("repo-loop-1:0.1")?.join("\n")).toBe( - tmuxInternals.buildInteractivePrimaryPrompt(opts) + tmuxInternals.buildInteractivePrimaryPrompt(opts, "1") ); expect(manifest.tmuxSession).toBe("repo-loop-1"); }); test("tmux prompts keep the paired review workflow explicit", () => { const opts = makePairedOptions(); - const primaryPrompt = tmuxInternals.buildPrimaryPrompt("Ship feature", opts); + const primaryPrompt = tmuxInternals.buildPrimaryPrompt( + "Ship feature", + opts, + "1" + ); const peerPrompt = tmuxInternals.buildPeerPrompt( "Ship feature", opts, - "claude" + "claude", + "1" ); + expect(primaryPrompt).toContain("Agent-to-agent pair programming"); expect(primaryPrompt).toContain("You are the main worker."); expect(primaryPrompt).toContain( - "If either your own review or the peer review finds an issue" + "your own review and the peer review both pass" + ); + expect(primaryPrompt).toContain( + "create a draft PR or send a follow-up commit to the existing PR" + ); + expect(primaryPrompt).toContain("Wait briefly if it arrives"); + expect(primaryPrompt).toContain( + 'Message Claude with "send_to_agent", not a human-facing message' ); - expect(primaryPrompt).toContain("create a draft PR"); - expect(primaryPrompt).toContain("ready signal"); expect(primaryPrompt).toContain("worktree isolation"); expect(peerPrompt).toContain("You are the reviewer/support agent."); expect(peerPrompt).toContain("Do not take over the task or create the PR"); - expect(peerPrompt).toContain("Reviewer ready."); + expect(peerPrompt).toContain("Wait for Codex to send you a targeted request"); + expect(peerPrompt).toContain('"reply"'); + expect(peerPrompt).toContain( + 'Use "send_to_agent" only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.' + ); + expect(primaryPrompt).not.toContain("mcp__loop-bridge-1__ prefix"); + expect(peerPrompt).toContain("mcp__loop-bridge-1__ prefix"); }); test("interactive tmux prompts tell both agents to wait for the human", () => { const opts = makePairedOptions({ proof: "" }); - const primaryPrompt = tmuxInternals.buildInteractivePrimaryPrompt(opts); - const peerPrompt = tmuxInternals.buildInteractivePeerPrompt(opts, "claude"); + const primaryPrompt = tmuxInternals.buildInteractivePrimaryPrompt(opts, "1"); + const peerPrompt = tmuxInternals.buildInteractivePeerPrompt( + opts, + "claude", + "1" + ); + expect(primaryPrompt).toContain("Agent-to-agent pair programming"); expect(primaryPrompt).toContain("No task has been assigned yet."); + expect(primaryPrompt).toContain("Wait for the first human task"); expect(primaryPrompt).toContain( - "Wait for the human to provide the first task" + 'Message Claude with "send_to_agent", not a human-facing message' ); expect(primaryPrompt).toContain("worktree isolation"); expect(peerPrompt).toContain("No task has been assigned yet."); - expect(peerPrompt).toContain("Reviewer ready. No task yet."); - expect(peerPrompt).toContain("wait for the human"); + expect(peerPrompt).toContain("Wait for Codex to provide a concrete task"); + expect(peerPrompt).toContain("human clearly assigns you separate work"); + expect(peerPrompt).toContain('"reply"'); + expect(peerPrompt).toContain( + 'Use "send_to_agent" only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.' + ); + expect(peerPrompt).toContain( + "If you are answering Codex, use the bridge tools instead of a human-facing reply." + ); + expect(primaryPrompt).not.toContain("mcp__loop-bridge-1__ prefix"); + expect(peerPrompt).toContain("mcp__loop-bridge-1__ prefix"); }); test("runInTmux auto-confirms Claude startup prompts in paired mode", async () => { @@ -518,6 +581,15 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = const typed: Array<{ pane: string; text: 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 bypassPrompt = + "WARNING: Claude Code running in Bypass Permissions mode"; const opts = makePairedOptions(); const manifest = createRunManifest({ cwd: "/repo", @@ -542,10 +614,10 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = capturePane: () => { pollCount += 1; if (pollCount === 1) { - return "Is this a project you created or one you trust?"; + return devChannelsPrompt; } if (pollCount === 2) { - return "WARNING: Claude Code running in Bypass Permissions mode"; + return `${devChannelsPrompt}\n\n${bypassPrompt}`; } return ""; }, @@ -618,14 +690,88 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = values.push(entry.text); typedByPane.set(entry.pane, values); } - expect(typedByPane.get("repo-loop-1:0.0")?.join("\n")).toBe( - tmuxInternals.buildPeerPrompt("Ship feature", opts, "claude") - ); + expect(typedByPane.get("repo-loop-1:0.0")).toBeUndefined(); expect(typedByPane.get("repo-loop-1:0.1")?.join("\n")).toBe( - tmuxInternals.buildPrimaryPrompt("Ship feature", opts) + tmuxInternals.buildPrimaryPrompt("Ship feature", opts, "1") ); }); +test("runInTmux still confirms Claude trust prompts in paired mode", async () => { + const keyCalls: Array<{ keys: string[]; pane: string }> = []; + let sessionStarted = false; + let pollCount = 0; + 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; + if (pollCount === 1) { + return "Is this a project you created or one you trust?"; + } + return ""; + }, + 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 reopens paired tmux panes without replaying the task", async () => { const calls: string[][] = []; const typed: Array<{ pane: string; text: string }> = [];