From aa5bd958efa1e12d6367793bac0196681717bea2 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Wed, 25 Mar 2026 10:45:58 -0700 Subject: [PATCH 1/5] Fix Claude tmux bypass confirmation --- src/loop/tmux.ts | 24 +++-- tests/loop/tmux.test.ts | 195 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 0d81c7a..5d61179 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -36,6 +36,9 @@ 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_BYPASS_MODE = "Bypass Permissions mode"; +const CLAUDE_BYPASS_ACCEPT = "Yes, I accept"; +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"; @@ -700,16 +703,25 @@ const runTmuxCommand = ( throw new Error(`${message}${suffix}`); }; +const normalizePaneText = (text: string): string => + text.replace(/\s+/g, " ").trim(); + const detectClaudePrompt = (text: string): "bypass" | "confirm" | undefined => { - if (text.includes(CLAUDE_BYPASS_PROMPT)) { + const normalized = normalizePaneText(text); + if ( + normalized.includes(CLAUDE_BYPASS_PROMPT) || + (normalized.includes(CLAUDE_BYPASS_MODE) && + normalized.includes(CLAUDE_BYPASS_ACCEPT) && + normalized.includes(CLAUDE_EXIT_OPTION)) + ) { return "bypass"; } - if (text.includes(CLAUDE_TRUST_PROMPT)) { + if (normalized.includes(CLAUDE_TRUST_PROMPT)) { return "confirm"; } if ( - text.includes(CLAUDE_DEV_CHANNELS_PROMPT) && - text.includes(CLAUDE_DEV_CHANNELS_CONFIRM) + normalized.includes(CLAUDE_DEV_CHANNELS_PROMPT) && + normalized.includes(CLAUDE_DEV_CHANNELS_CONFIRM) ) { return "confirm"; } @@ -740,7 +752,9 @@ const unblockClaudePane = async ( continue; } if (prompt === "bypass") { - deps.sendKeys(pane, ["Down", "Enter"]); + deps.sendKeys(pane, ["Down"]); + await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); + deps.sendKeys(pane, ["Enter"]); handledPrompt = true; quietPolls = 0; await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 939df2c..cd20534 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -665,7 +665,11 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = expect(keyCalls[0]).toEqual({ keys: ["Enter"], pane: "repo-loop-1:0.0" }); expect(keyCalls[1]).toEqual({ - keys: ["Down", "Enter"], + keys: ["Down"], + pane: "repo-loop-1:0.0", + }); + expect(keyCalls[2]).toEqual({ + keys: ["Enter"], pane: "repo-loop-1:0.0", }); expect( @@ -696,6 +700,176 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () = ); }); +test("runInTmux confirms wrapped Claude dev-channel prompts", 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; + if (pollCount === 1) { + return devChannelsPrompt; + } + 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 confirms the current Claude bypass prompt wording", async () => { + const keyCalls: Array<{ keys: string[]; pane: string }> = []; + let sessionStarted = false; + let pollCount = 0; + const bypassPrompt = [ + "Bypass Permissions mode", + "", + "1. No, exit", + "2. Yes, I accept", + ].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; + if (pollCount === 1) { + return bypassPrompt; + } + 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: ["Down"], + pane: "repo-loop-1:0.0", + }); + expect(keyCalls).toContainEqual({ + keys: ["Enter"], + pane: "repo-loop-1:0.0", + }); +}); + test("runInTmux still confirms Claude trust prompts in paired mode", async () => { const keyCalls: Array<{ keys: string[]; pane: string }> = []; let sessionStarted = false; @@ -1576,6 +1750,25 @@ test("tmux internals build shell command with escaping", () => { ); }); +test("tmux internals launch Claude in bypass mode", () => { + expect( + tmuxInternals.buildClaudeCommand( + "claude-session-1", + "opus", + "loop-bridge-1", + false + ) + ).toContain("--dangerously-skip-permissions"); + expect( + tmuxInternals.buildClaudeCommand( + "claude-session-1", + "opus", + "loop-bridge-1", + false + ) + ).not.toContain("--permission-mode"); +}); + test("tmux internals build run names", () => { expect(tmuxInternals.buildRunName("repo", 3)).toBe("repo-loop-3"); }); From b3a1bc426407aca1fb306c8d4cb5dd0fef81ac3a Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Wed, 25 Mar 2026 10:57:05 -0700 Subject: [PATCH 2/5] Clean up stale tmux MCP bridge state --- src/loop/bridge.ts | 85 +++++++++++++++++++++++++++++++++--- src/loop/codex-tmux-proxy.ts | 18 ++++++-- tests/loop/bridge.test.ts | 63 ++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/src/loop/bridge.ts b/src/loop/bridge.ts index 79e98bd..16c873e 100644 --- a/src/loop/bridge.ts +++ b/src/loop/bridge.ts @@ -10,6 +10,7 @@ import { dirname, join } from "node:path"; import { spawnSync } from "bun"; import { injectCodexMessage } from "./codex-app-server"; import { LOOP_VERSION } from "./constants"; +import { sanitizeBase } from "./git"; import { buildLaunchArgv } from "./launch"; import { appendRunTranscriptEntry, @@ -17,6 +18,8 @@ import { isActiveRunState, parseRunLifecycleState, readRunManifest, + touchRunManifest, + updateRunManifest, } from "./run-state"; import type { Agent } from "./types"; @@ -126,6 +129,8 @@ const eventSignature = (event: BridgeMessage): string => bridgeSignature(event.source, event.target, event.message); const bridgePath = (runDir: string): string => join(runDir, BRIDGE_FILE); +const manifestPath = (runDir: string): string => join(runDir, "manifest.json"); +const bridgeCommandDeps = { spawnSync }; const ensureParentDir = (path: string): void => { mkdirSync(dirname(path), { recursive: true }); @@ -351,13 +356,65 @@ const injectCodexTmuxMessage = async ( }; const tmuxSessionExists = (session: string): boolean => { - const result = spawnSync(["tmux", "has-session", "-t", session], { - stderr: "ignore", - stdout: "ignore", - }); + const result = bridgeCommandDeps.spawnSync( + ["tmux", "has-session", "-t", session], + { + stderr: "ignore", + stdout: "ignore", + } + ); return result.exitCode === 0; }; +const claudeChannelServerName = (runId: string): string => + `${BRIDGE_SERVER}-${sanitizeBase(runId)}`; + +const removeClaudeChannelServer = (runId: string): void => { + if (!runId) { + return; + } + try { + bridgeCommandDeps.spawnSync( + [ + "claude", + "mcp", + "remove", + "--scope", + "local", + claudeChannelServerName(runId), + ], + { + stderr: "ignore", + stdout: "ignore", + } + ); + } catch { + // Cleanup should not fail the bridge flow. + } +}; + +export const clearStaleTmuxBridgeState = (runDir: string): boolean => { + let removedRunId = ""; + const next = updateRunManifest(manifestPath(runDir), (manifest) => { + if (!manifest?.tmuxSession) { + return manifest; + } + removedRunId = manifest.runId; + return touchRunManifest( + { + ...manifest, + tmuxSession: undefined, + }, + new Date().toISOString() + ); + }); + if (!(next && removedRunId)) { + return false; + } + removeClaudeChannelServer(removedRunId); + return true; +}; + const claudeChannelInstructions = (): string => [ `Messages from the Codex agent arrive as .`, @@ -486,8 +543,11 @@ const deliverCodexBridgeMessage = async ( const status = readBridgeStatus(runDir); // A stale tmux session entry should not block direct app-server delivery on a // later non-tmux resume. - if (status.tmuxSession && tmuxSessionExists(status.tmuxSession)) { - return false; + if (status.tmuxSession) { + if (tmuxSessionExists(status.tmuxSession)) { + return false; + } + clearStaleTmuxBridgeState(runDir); } if (!(status.codexRemoteUrl && status.codexThreadId)) { return false; @@ -517,6 +577,10 @@ const drainCodexTmuxMessages = async (runDir: string): Promise => { if (!tmuxSession) { return false; } + if (!tmuxSessionExists(tmuxSession)) { + clearStaleTmuxBridgeState(runDir); + return false; + } const message = readPendingBridgeMessages(runDir).find( (entry) => entry.target === "codex" ); @@ -1027,7 +1091,11 @@ export const runBridgeWorker = async (runDir: string): Promise => { if (!(state && isActiveRunState(state))) { return; } - if (!(status.tmuxSession && tmuxSessionExists(status.tmuxSession))) { + if (!status.tmuxSession) { + return; + } + if (!tmuxSessionExists(status.tmuxSession)) { + clearStaleTmuxBridgeState(runDir); return; } const delivered = await drainCodexTmuxMessages(runDir); @@ -1081,6 +1149,9 @@ export const ensureClaudeBridgeConfig = ( export const bridgeInternals = { appendBridgeEvent, bridgePath, + clearStaleTmuxBridgeState, + claudeChannelServerName, + commandDeps: bridgeCommandDeps, drainCodexTmuxMessages, deliverCodexBridgeMessage, readBridgeEvents, diff --git a/src/loop/codex-tmux-proxy.ts b/src/loop/codex-tmux-proxy.ts index 920ca68..35ef7c3 100644 --- a/src/loop/codex-tmux-proxy.ts +++ b/src/loop/codex-tmux-proxy.ts @@ -3,6 +3,7 @@ import type { ServerWebSocket } from "bun"; import { serve, spawnSync } from "bun"; import { type BridgeMessage, + clearStaleTmuxBridgeState, markBridgeMessage, readPendingBridgeMessages, } from "./bridge"; @@ -45,6 +46,8 @@ interface ProxyRoute { threadId?: string; } +type StopReason = "dead-tmux" | "inactive-run"; + const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; @@ -421,30 +424,37 @@ class CodexTmuxProxy { } } - private shouldStop(): boolean { + private stopReason(): StopReason | undefined { const manifest = readRunManifest(join(this.runDir, "manifest.json")); if (!(manifest && isActiveRunState(manifest.state))) { - return true; + return "inactive-run"; } const sessionAlive = manifest.tmuxSession ? isTmuxSessionAlive(manifest.tmuxSession) : false; if (sessionAlive) { this.sawTmuxSession = true; + return undefined; } return shouldStopForTmuxSession( sessionAlive, this.sawTmuxSession, this.startupDeadlineMs, Date.now() - ); + ) + ? "dead-tmux" + : undefined; } private drainBridgeMessages(): void { if (this.stopped) { return; } - if (this.shouldStop()) { + const stopReason = this.stopReason(); + if (stopReason) { + if (stopReason === "dead-tmux") { + clearStaleTmuxBridgeState(this.runDir); + } this.stop(); return; } diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index b8f0803..5d984cf 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { readRunManifest } from "../../src/loop/run-state"; const loadBridge = ( overrides: { @@ -461,7 +462,17 @@ test("bridge delivers Claude replies directly to Codex when app-server state is test("bridge falls back to direct Codex delivery when the stored tmux session is stale", async () => { const injectCodexMessage = mock(async () => true); + const spawnSync = mock((args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 1 }; + } + if (args[0] === "claude" && args[1] === "mcp" && args[2] === "remove") { + return { exitCode: 0 }; + } + return { exitCode: 0 }; + }); const bridge = await loadBridge({ injectCodexMessage }); + bridge.bridgeInternals.commandDeps.spawnSync = spawnSync; const root = makeTempDir(); const runDir = join(root, "run"); mkdirSync(runDir, { recursive: true }); @@ -503,11 +514,63 @@ test("bridge falls back to direct Codex delivery when the stored tmux session is "codex-thread-1", "Please review the final state." ); + expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( + undefined + ); + const removeCall = spawnSync.mock.calls.find( + (call) => call[0]?.[0] === "claude" && call[0]?.[2] === "remove" + ); + expect(removeCall).toBeDefined(); + expect(removeCall?.[0]).toEqual([ + "claude", + "mcp", + "remove", + "--scope", + "local", + bridge.bridgeInternals.claudeChannelServerName("8"), + ]); + expect(removeCall?.[1]).toMatchObject({ + stderr: "ignore", + stdout: "ignore", + }); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); rmSync(root, { recursive: true, force: true }); }); +test("bridge stale tmux cleanup is a no-op when the manifest has no tmux session", async () => { + const spawnSync = mock(() => ({ exitCode: 0 })); + const bridge = await loadBridge(); + bridge.bridgeInternals.commandDeps.spawnSync = spawnSync; + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + codexRemoteUrl: "ws://127.0.0.1:4500", + codexThreadId: "codex-thread-1", + createdAt: "2026-03-23T10:00:00.000Z", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "8", + status: "running", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + expect(bridge.bridgeInternals.clearStaleTmuxBridgeState(runDir)).toBe(false); + expect(spawnSync).not.toHaveBeenCalled(); + expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( + undefined + ); + + rmSync(root, { recursive: true, force: true }); +}); + test("bridge MCP delivers pending codex messages to Claude as channel notifications", async () => { const bridge = await loadBridge(); const root = makeTempDir(); From 7805c97455fa0ac2d29a190bc47a9e932b2aff13 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Wed, 25 Mar 2026 11:12:42 -0700 Subject: [PATCH 3/5] Exit cleanly after tmux handoff --- src/loop/codex-app-server.ts | 62 ++++++++-- src/loop/runner.ts | 10 ++ src/loop/tmux.ts | 22 +++- tests/loop/codex-app-server.test.ts | 39 ++++++- tests/loop/runner.test.ts | 4 + tests/loop/tmux.test.ts | 173 ++++++++++++++++++++++++++++ 6 files changed, 295 insertions(+), 15 deletions(-) diff --git a/src/loop/codex-app-server.ts b/src/loop/codex-app-server.ts index 0a8ef5d..1422dd4 100644 --- a/src/loop/codex-app-server.ts +++ b/src/loop/codex-app-server.ts @@ -9,6 +9,7 @@ type TransportMode = "app-server" | "exec"; type Callback = (text: string) => void; export interface AppServerLaunchOptions { configValues?: string[]; + orphanOnExit?: boolean; persistentThread?: boolean; resumeThreadId?: string; threadModel?: string; @@ -422,6 +423,7 @@ class AppServerClient { private ws: import("./ws-client").WsClient | undefined; private closed = false; private lastThreadId = ""; + private orphanOnExit = false; private persistentThread = false; private threadModel = ""; private started = false; @@ -451,14 +453,17 @@ class AppServerClient { if (!this.started) { return false; } - return !sameConfigValues( - this.configValues, - normalizeConfigValues(options.configValues) + return ( + !sameConfigValues( + this.configValues, + normalizeConfigValues(options.configValues) + ) || this.orphanOnExit !== (options.orphanOnExit ?? false) ); } configureLaunch(options: AppServerLaunchOptions = {}): void { this.configValues = normalizeConfigValues(options.configValues); + this.orphanOnExit = options.orphanOnExit ?? false; this.persistentThread = options.persistentThread ?? false; if (options.resumeThreadId !== undefined) { this.lastThreadId = options.resumeThreadId; @@ -497,17 +502,26 @@ class AppServerClient { { detached: DETACH_CHILD_PROCESS, env: process.env, - stderr: "pipe", - stdin: "pipe", - stdout: "pipe", + stderr: this.orphanOnExit ? "ignore" : "pipe", + stdin: this.orphanOnExit ? "ignore" : "pipe", + stdout: this.orphanOnExit ? "ignore" : "pipe", } ); this.child = child; - this.consumeFrames(child).finally(() => { - if (!this.closed) { - this.handleUnexpectedExit(); - } - }); + if (this.orphanOnExit) { + child.unref?.(); + child.exited.then(() => { + if (!this.closed) { + this.handleUnexpectedExit(); + } + }); + } else { + this.consumeFrames(child).finally(() => { + if (!this.closed) { + this.handleUnexpectedExit(); + } + }); + } const ws = await this.connectWebSocket(connectUrl); this.ws = ws; ws.onmessage = (data) => { @@ -625,6 +639,24 @@ class AppServerClient { this.started = false; } + release(): void { + this.closed = true; + this.failAll(new Error("codex app-server released")); + const ws = this.ws; + this.ws = undefined; + if (ws) { + try { + ws.close(); + } catch { + // ignore close errors + } + } + this.child = undefined; + this.connectUrl = ""; + this.ready = false; + this.started = false; + } + private async ensureThread( model: string, resumeThreadId?: string @@ -1221,3 +1253,11 @@ export const closeAppServer = async (): Promise => { await singleton.close(); singleton = undefined; }; + +export const releaseAppServer = (): void => { + if (!singleton) { + return; + } + singleton.release(); + singleton = undefined; +}; diff --git a/src/loop/runner.ts b/src/loop/runner.ts index 27d06ff..41e71c8 100644 --- a/src/loop/runner.ts +++ b/src/loop/runner.ts @@ -12,8 +12,10 @@ import { CODEX_TRANSPORT_EXEC, CodexAppServerFallbackError, CodexAppServerUnexpectedExitError, + closeAppServer, hasAppServerProcess, interruptAppServer, + releaseAppServer, runCodexTurn, startAppServer, useAppServer, @@ -604,3 +606,11 @@ export const startPersistentAgentSession = async ( persistent: true, }); }; + +export const releasePersistentCodexSession = (): void => { + releaseAppServer(); +}; + +export const closePersistentCodexSession = async (): Promise => { + await closeAppServer(); +}; diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 5d61179..e4a9ea6 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -22,7 +22,11 @@ import { touchRunManifest, updateRunManifest, } from "./run-state"; -import { startPersistentAgentSession } from "./runner"; +import { + closePersistentCodexSession, + releasePersistentCodexSession, + startPersistentAgentSession, +} from "./runner"; import type { Agent, Options } from "./types"; export const TMUX_FLAG = "--tmux"; @@ -71,6 +75,7 @@ interface GitResult { interface TmuxDeps { attach: (session: string) => void; capturePane: (pane: string) => string; + closePersistentCodexSession: typeof closePersistentCodexSession; cwd: string; env: NodeJS.ProcessEnv; findBinary: (cmd: string) => boolean; @@ -82,6 +87,7 @@ interface TmuxDeps { log: (line: string) => void; makeClaudeSessionId: () => string; preparePairedRun: typeof preparePairedRun; + releasePersistentCodexSession: typeof releasePersistentCodexSession; runGit: (cwd: string, args: string[]) => GitResult; sendKeys: (pane: string, keys: string[]) => void; sendText: (pane: string, text: string) => void; @@ -660,7 +666,7 @@ const ensurePairedSessionIds = async ( "codex", opts, manifest.codexThreadId || undefined, - undefined, + { codexLaunch: { orphanOnExit: true } }, codexKind ); @@ -1081,6 +1087,8 @@ const defaultDeps = (): TmuxDeps => ({ ); return waitForCodexTmuxProxy(port); }, + closePersistentCodexSession, + releasePersistentCodexSession, startPersistentAgentSession, spawn: (args: string[]) => { const result = spawnSync(args, { stderr: "pipe" }); @@ -1169,7 +1177,15 @@ export const runInTmux = async ( deps.log(`[loop] started tmux session "${session}"`); deps.log(`[loop] attach with: tmux attach -t ${session}`); - return attachSessionIfInteractive(session, deps); + const handedOff = attachSessionIfInteractive(session, deps); + if (pairedLaunch && handedOff) { + if (sessionExists(session, deps.spawn)) { + deps.releasePersistentCodexSession(); + } else { + await deps.closePersistentCodexSession(); + } + } + return handedOff; }; export const tmuxInternals = { diff --git a/tests/loop/codex-app-server.test.ts b/tests/loop/codex-app-server.test.ts index df0d175..3788d6c 100644 --- a/tests/loop/codex-app-server.test.ts +++ b/tests/loop/codex-app-server.test.ts @@ -22,6 +22,7 @@ interface TestProcess { close: () => void; killSignals: string[]; pid: number; + unrefCount: number; writes: string[]; } @@ -96,6 +97,14 @@ const installSpawn = (appServerModule: AppServerModule): void => { stdout.enqueue(`${JSON.stringify(frame)}\n`); }; + const processState: TestProcess = { + close, + killSignals, + pid, + unrefCount: 0, + writes, + }; + const child = { exited, kill: (signal?: string) => { @@ -103,6 +112,9 @@ const installSpawn = (appServerModule: AppServerModule): void => { close(); }, pid, + unref: () => { + processState.unrefCount += 1; + }, stdin: { write: (chunk: string): void => { const lines = chunk.split("\n"); @@ -119,7 +131,7 @@ const installSpawn = (appServerModule: AppServerModule): void => { stderr: stderr.stream, stdout: stdout.stream, }; - processes.push({ close, killSignals, pid, writes }); + processes.push(processState); return child; } ); @@ -273,6 +285,31 @@ test("startAppServer exposes the app-server websocket URL", async () => { expect(appServer.getCodexAppServerUrl()).toBe(""); }); +test("releaseAppServer drops local handles without killing the detached child", async () => { + const appServer = await getModule(); + currentHandler = (request, write) => { + if (request.method === "initialize") { + write({ id: request.id, result: {} }); + return; + } + if (request.method === "thread/start") { + write({ id: request.id, result: { thread: { id: "thread-1" } } }); + } + }; + + await appServer.startAppServer({ + orphanOnExit: true, + persistentThread: true, + threadModel: "test-model", + }); + + expect(appServer.getCodexAppServerUrl()).toMatch(LOCAL_WS_URL_RE); + expect(latestProcess()?.unrefCount).toBe(1); + appServer.releaseAppServer(); + expect(latestProcess()?.killSignals).toEqual([]); + expect(appServer.getCodexAppServerUrl()).toBe(""); +}); + test("startAppServer normalizes codex bridge config args before spawning", async () => { const appServer = await getModule(); currentHandler = (request, write) => { diff --git a/tests/loop/runner.test.ts b/tests/loop/runner.test.ts index d34a22e..4ca2f26 100644 --- a/tests/loop/runner.test.ts +++ b/tests/loop/runner.test.ts @@ -44,9 +44,11 @@ const appServerUnexpectedExit: AppServerModule["CodexAppServerUnexpectedExitErro RunnerCodexUnexpectedExitError; const hasAppServerProcess: MockFn<() => boolean> = mock(() => false); +const closeAppServer: MockFn<() => Promise> = mock(async () => undefined); const interruptAppServer: MockFn<(signal: "SIGINT" | "SIGTERM") => void> = mock( () => undefined ); +const releaseAppServer: MockFn<() => void> = mock(() => undefined); const runCodexTurn: MockFn< ( _prompt: string, @@ -141,8 +143,10 @@ const installCodexServerMock = (): void => { CODEX_TRANSPORT_EXEC, CodexAppServerFallbackError: appServerFallback, CodexAppServerUnexpectedExitError: appServerUnexpectedExit, + closeAppServer, hasAppServerProcess, interruptAppServer, + releaseAppServer, runCodexTurn, runLegacyAgent, startAppServer, diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index cd20534..37db8f7 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -373,6 +373,7 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { "remain-on-exit", "on", ], + ["tmux", "has-session", "-t", "repo-loop-1"], ]); const typedByPane = new Map(); for (const entry of typed) { @@ -395,6 +396,178 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { expect(manifest.tmuxSession).toBe("repo-loop-1"); }); +test("runInTmux releases local codex app-server handles after paired handoff", async () => { + const attaches: string[] = []; + let released = 0; + let sessionStarted = false; + let manifest = createRunManifest({ + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "1", + status: "running", + }); + const opts = makePairedOptions(); + 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", + }; + + const delegated = await runInTmux( + ["--tmux", "--proof", "verify with tests"], + { + attach: (session: string) => { + attaches.push(session); + }, + capturePane: (pane: string) => + pane.endsWith(":0.1") ? "Ctrl+J newline" : "", + cwd: "/repo", + env: {}, + findBinary: () => true, + getCodexAppServerUrl: () => "ws://127.0.0.1:4500", + getLastCodexThreadId: () => "codex-thread-1", + isInteractive: () => true, + 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 }; + }, + releasePersistentCodexSession: () => { + released += 1; + }, + sendKeys: (): void => undefined, + 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) => { + manifest = update(manifest) ?? manifest; + return manifest; + }, + }, + { opts, task: "Ship feature" } + ); + + expect(delegated).toBe(true); + expect(attaches).toEqual(["repo-loop-1"]); + expect(released).toBe(1); +}); + +test("runInTmux closes the local codex app-server when the paired session is gone after attach", async () => { + const attaches: string[] = []; + let closed = 0; + let released = 0; + let sessionStarted = false; + let sessionAlive = false; + let manifest = createRunManifest({ + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "1", + status: "running", + }); + const opts = makePairedOptions(); + 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", + }; + + const delegated = await runInTmux( + ["--tmux", "--proof", "verify with tests"], + { + attach: (session: string) => { + attaches.push(session); + sessionAlive = false; + }, + capturePane: (pane: string) => + pane.endsWith(":0.1") ? "Ctrl+J newline" : "", + closePersistentCodexSession: () => { + closed += 1; + return Promise.resolve(); + }, + cwd: "/repo", + env: {}, + findBinary: () => true, + getCodexAppServerUrl: () => "ws://127.0.0.1:4500", + getLastCodexThreadId: () => "codex-thread-1", + isInteractive: () => true, + 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 }; + }, + releasePersistentCodexSession: () => { + released += 1; + }, + sendKeys: (): void => undefined, + 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 sessionAlive + ? { exitCode: 0, stderr: "" } + : { exitCode: 1, stderr: "" }; + } + if (args[0] === "tmux" && args[1] === "new-session") { + sessionStarted = true; + sessionAlive = true; + } + if ( + !sessionStarted && + args[0] === "tmux" && + args[1] === "split-window" + ) { + throw new Error("split before new-session"); + } + return { exitCode: 0, stderr: "" }; + }, + updateRunManifest: (_path, update) => { + manifest = update(manifest) ?? manifest; + return manifest; + }, + }, + { opts, task: "Ship feature" } + ); + + expect(delegated).toBe(true); + expect(attaches).toEqual(["repo-loop-1"]); + expect(closed).toBe(1); + expect(released).toBe(0); +}); + test("runInTmux starts paired interactive tmux panes without a task", async () => { const calls: string[][] = []; const typed: Array<{ pane: string; text: string }> = []; From 2768511f7480c52b68aec702fa8e879f09456d9a Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Wed, 25 Mar 2026 11:20:51 -0700 Subject: [PATCH 4/5] Unref detached tmux proxy launcher --- src/loop/tmux.ts | 26 ++++++++++++++++++-------- tests/loop/tmux.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index e4a9ea6..a0741d7 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -113,6 +113,21 @@ const quoteShellArg = (value: string): string => const buildShellCommand = (argv: string[]): string => argv.map(quoteShellArg).join(" "); +const spawnDetachedProcess = ( + argv: string[], + env: NodeJS.ProcessEnv, + spawnFn: typeof spawn = spawn +): void => { + const child = spawnFn(argv, { + detached: DETACH_CHILD_PROCESS, + env, + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + }); + child.unref?.(); +}; + const stripTmuxFlag = (argv: string[]): string[] => argv.filter((arg) => arg !== TMUX_FLAG); @@ -1068,7 +1083,7 @@ const defaultDeps = (): TmuxDeps => ({ threadId: string ) => { const port = await findCodexTmuxProxyPort(); - spawn( + spawnDetachedProcess( [ ...buildLaunchArgv(), CODEX_TMUX_PROXY_SUBCOMMAND, @@ -1077,13 +1092,7 @@ const defaultDeps = (): TmuxDeps => ({ threadId, String(port), ], - { - detached: DETACH_CHILD_PROCESS, - env: process.env, - stderr: "ignore", - stdin: "ignore", - stdout: "ignore", - } + process.env ); return waitForCodexTmuxProxy(port); }, @@ -1201,6 +1210,7 @@ export const tmuxInternals = { buildPrimaryPrompt, buildRunName, buildShellCommand, + spawnDetachedProcess, isSessionConflict, quoteShellArg, sanitizeBase, diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 37db8f7..9637430 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -1923,6 +1923,41 @@ test("tmux internals build shell command with escaping", () => { ); }); +test("tmux internals unref detached helper processes", () => { + const calls: Array<{ argv: string[]; options: Record }> = []; + let unrefCount = 0; + + tmuxInternals.spawnDetachedProcess( + ["loop", "__codex-tmux-proxy"], + { HOME: "/tmp/home" }, + (argv, options) => { + calls.push({ + argv: argv.map((value) => String(value)), + options: options as Record, + }); + return { + unref: () => { + unrefCount += 1; + }, + } as ReturnType; + } + ); + + expect(calls).toEqual([ + { + argv: ["loop", "__codex-tmux-proxy"], + options: { + detached: process.platform !== "win32", + env: { HOME: "/tmp/home" }, + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + }, + }, + ]); + expect(unrefCount).toBe(1); +}); + test("tmux internals launch Claude in bypass mode", () => { expect( tmuxInternals.buildClaudeCommand( From 846cb61c9962a896515bdc9678b2d2980d48e24b Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Wed, 25 Mar 2026 11:51:11 -0700 Subject: [PATCH 5/5] Log Claude MCP cleanup failures --- src/loop/bridge.ts | 37 ++++++++++----- tests/loop/bridge.test.ts | 95 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/loop/bridge.ts b/src/loop/bridge.ts index 16c873e..b0bc3d5 100644 --- a/src/loop/bridge.ts +++ b/src/loop/bridge.ts @@ -369,27 +369,42 @@ const tmuxSessionExists = (session: string): boolean => { const claudeChannelServerName = (runId: string): string => `${BRIDGE_SERVER}-${sanitizeBase(runId)}`; +const logClaudeChannelServerRemovalFailure = ( + serverName: string, + detail: string +): void => { + console.error( + `[loop] failed to remove Claude channel server "${serverName}": ${detail}` + ); +}; + const removeClaudeChannelServer = (runId: string): void => { if (!runId) { return; } + const serverName = claudeChannelServerName(runId); try { - bridgeCommandDeps.spawnSync( - [ - "claude", - "mcp", - "remove", - "--scope", - "local", - claudeChannelServerName(runId), - ], + const result = bridgeCommandDeps.spawnSync( + ["claude", "mcp", "remove", "--scope", "local", serverName], { - stderr: "ignore", + stderr: "pipe", stdout: "ignore", } ); - } catch { + if (result.exitCode === 0) { + return; + } + const stderr = result.stderr ? decodeOutput(result.stderr).trim() : ""; + logClaudeChannelServerRemovalFailure( + serverName, + stderr || `exit code ${result.exitCode ?? "unknown"}` + ); + } catch (error: unknown) { // Cleanup should not fail the bridge flow. + logClaudeChannelServerRemovalFailure( + serverName, + error instanceof Error ? error.message : String(error) + ); } }; diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 5d984cf..42f7d8c 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -530,7 +530,7 @@ test("bridge falls back to direct Codex delivery when the stored tmux session is bridge.bridgeInternals.claudeChannelServerName("8"), ]); expect(removeCall?.[1]).toMatchObject({ - stderr: "ignore", + stderr: "pipe", stdout: "ignore", }); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); @@ -571,6 +571,99 @@ test("bridge stale tmux cleanup is a no-op when the manifest has no tmux session rmSync(root, { recursive: true, force: true }); }); +test("bridge stale tmux cleanup logs non-zero Claude MCP remove exits", async () => { + const spawnSync = mock((args: string[]) => { + if (args[0] === "claude" && args[1] === "mcp" && args[2] === "remove") { + return { + exitCode: 1, + stderr: Buffer.from("command failed", "utf8"), + }; + } + return { exitCode: 0, stderr: Buffer.alloc(0) }; + }); + const bridge = await loadBridge(); + bridge.bridgeInternals.commandDeps.spawnSync = spawnSync; + const errorSpy = mock(() => undefined); + const originalError = console.error; + console.error = errorSpy; + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + createdAt: "2026-03-23T10:00:00.000Z", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "8", + status: "running", + tmuxSession: "repo-loop-8", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + try { + expect(bridge.bridgeInternals.clearStaleTmuxBridgeState(runDir)).toBe(true); + expect(errorSpy).toHaveBeenCalledWith( + '[loop] failed to remove Claude channel server "loop-bridge-8": command failed' + ); + expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( + undefined + ); + } finally { + console.error = originalError; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("bridge stale tmux cleanup logs thrown Claude MCP remove errors", async () => { + const spawnSync = mock((args: string[]) => { + if (args[0] === "claude" && args[1] === "mcp" && args[2] === "remove") { + throw new Error("spawn failed"); + } + return { exitCode: 0, stderr: Buffer.alloc(0) }; + }); + const bridge = await loadBridge(); + bridge.bridgeInternals.commandDeps.spawnSync = spawnSync; + const errorSpy = mock(() => undefined); + const originalError = console.error; + console.error = errorSpy; + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + createdAt: "2026-03-23T10:00:00.000Z", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "8", + status: "running", + tmuxSession: "repo-loop-8", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + try { + expect(bridge.bridgeInternals.clearStaleTmuxBridgeState(runDir)).toBe(true); + expect(errorSpy).toHaveBeenCalledWith( + '[loop] failed to remove Claude channel server "loop-bridge-8": spawn failed' + ); + expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( + undefined + ); + } finally { + console.error = originalError; + rmSync(root, { recursive: true, force: true }); + } +}); + test("bridge MCP delivers pending codex messages to Claude as channel notifications", async () => { const bridge = await loadBridge(); const root = makeTempDir();