From 04bb6e8b24982b9d31a6b20b890b977838df2caf Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Fri, 27 Mar 2026 22:50:06 -0700 Subject: [PATCH 01/12] Refactor bridge config and runtime status --- src/loop/bridge-claude-registration.ts | 92 +++++++++ src/loop/bridge-config.ts | 62 ++++-- src/loop/bridge-runtime.ts | 104 +++++----- src/loop/bridge-store.ts | 25 ++- src/loop/bridge.ts | 8 +- src/loop/paired-options.ts | 7 +- src/loop/tmux.ts | 52 ++--- tests/loop/bridge.test.ts | 251 +++++++++++++++++++++++++ 8 files changed, 485 insertions(+), 116 deletions(-) create mode 100644 src/loop/bridge-claude-registration.ts diff --git a/src/loop/bridge-claude-registration.ts b/src/loop/bridge-claude-registration.ts new file mode 100644 index 0000000..73a22fe --- /dev/null +++ b/src/loop/bridge-claude-registration.ts @@ -0,0 +1,92 @@ +import { + buildClaudeChannelServerConfig, + claudeChannelServerName, +} from "./bridge-config"; + +const CLAUDE_CHANNEL_SCOPE = "local"; +const MCP_ALREADY_EXISTS_RE = /already exists/i; + +interface BridgeCommandResult { + exitCode?: number | null; + stderr?: string | Uint8Array; +} + +type BridgeCommand = (args: string[]) => BridgeCommandResult; + +const stderrText = (value: BridgeCommandResult["stderr"]): string => { + if (!value) { + return ""; + } + if (typeof value === "string") { + return value.trim(); + } + return new TextDecoder().decode(value).trim(); +}; + +const logClaudeChannelServerRemovalFailure = ( + serverName: string, + detail: string, + log: (line: string) => void +): void => { + log( + `[loop] failed to remove Claude channel server "${serverName}": ${detail}` + ); +}; + +export const registerClaudeChannelServer = ( + launchArgv: string[], + runId: string, + runDir: string, + runCommand: BridgeCommand +): void => { + const result = runCommand([ + "claude", + "mcp", + "add-json", + "--scope", + CLAUDE_CHANNEL_SCOPE, + claudeChannelServerName(runId), + buildClaudeChannelServerConfig(launchArgv, runDir), + ]); + const stderr = stderrText(result.stderr); + if (result.exitCode === 0 || MCP_ALREADY_EXISTS_RE.test(stderr)) { + return; + } + const suffix = stderr ? `: ${stderr}` : "."; + throw new Error(`[loop] failed to register Claude channel server${suffix}`); +}; + +export const removeClaudeChannelServer = ( + runId: string, + runCommand: BridgeCommand, + log: (line: string) => void = console.error +): void => { + if (!runId) { + return; + } + const serverName = claudeChannelServerName(runId); + try { + const result = runCommand([ + "claude", + "mcp", + "remove", + "--scope", + CLAUDE_CHANNEL_SCOPE, + serverName, + ]); + if (result.exitCode === 0) { + return; + } + logClaudeChannelServerRemovalFailure( + serverName, + stderrText(result.stderr) || `exit code ${result.exitCode ?? "unknown"}`, + log + ); + } catch (error: unknown) { + logClaudeChannelServerRemovalFailure( + serverName, + error instanceof Error ? error.message : String(error), + log + ); + } +}; diff --git a/src/loop/bridge-config.ts b/src/loop/bridge-config.ts index 7b46a39..c5c02a9 100644 --- a/src/loop/bridge-config.ts +++ b/src/loop/bridge-config.ts @@ -1,6 +1,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { BRIDGE_SERVER, BRIDGE_SUBCOMMAND } from "./bridge-constants"; +import { sanitizeBase } from "./git"; import { buildLaunchArgv } from "./launch"; import type { Agent } from "./types"; @@ -17,12 +18,48 @@ const ensureParentDir = (path: string): void => { const stringifyToml = (value: string): string => JSON.stringify(value); +interface BridgeServerConfig { + args: string[]; + command: string; + type: "stdio"; +} + +const buildBridgeServerConfig = ( + runDir: string, + source: Agent, + launchArgv: string[] +): BridgeServerConfig => { + const [command, ...baseArgs] = launchArgv; + return { + args: [...baseArgs, BRIDGE_SUBCOMMAND, runDir, source], + command, + type: "stdio", + }; +}; + +const buildBridgeFileConfig = ( + serverName: string, + config: BridgeServerConfig +): { mcpServers: Record } => ({ + mcpServers: { + [serverName]: config, + }, +}); + +export const claudeChannelServerName = (runId: string): string => + `${BRIDGE_SERVER}-${sanitizeBase(runId)}`; + +export const buildClaudeChannelServerConfig = ( + launchArgv: string[], + runDir: string +): string => + JSON.stringify(buildBridgeServerConfig(runDir, "claude", launchArgv)); + export const buildCodexBridgeConfigArgs = ( runDir: string, source: Agent ): string[] => { - const [command, ...baseArgs] = buildLaunchArgv(); - const args = [...baseArgs, BRIDGE_SUBCOMMAND, runDir, source]; + const config = buildBridgeServerConfig(runDir, source, buildLaunchArgv()); const approvalArgs = CODEX_AUTO_APPROVED_BRIDGE_TOOLS.flatMap((tool) => [ "-c", `mcp_servers.${BRIDGE_SERVER}.tools.${tool}.approval_mode=${stringifyToml( @@ -31,32 +68,27 @@ export const buildCodexBridgeConfigArgs = ( ]); return [ "-c", - `mcp_servers.${BRIDGE_SERVER}.command=${stringifyToml(command)}`, + `mcp_servers.${BRIDGE_SERVER}.command=${stringifyToml(config.command)}`, "-c", - `mcp_servers.${BRIDGE_SERVER}.args=${JSON.stringify(args)}`, + `mcp_servers.${BRIDGE_SERVER}.args=${JSON.stringify(config.args)}`, ...approvalArgs, ]; }; export const ensureClaudeBridgeConfig = ( runDir: string, - source: Agent + source: Agent, + serverName = BRIDGE_SERVER ): string => { - const [command, ...baseArgs] = buildLaunchArgv(); const path = join(runDir, `${source}-mcp.json`); ensureParentDir(path); writeFileSync( path, `${JSON.stringify( - { - mcpServers: { - [BRIDGE_SERVER]: { - args: [...baseArgs, BRIDGE_SUBCOMMAND, runDir, source], - command, - type: "stdio", - }, - }, - }, + buildBridgeFileConfig( + serverName, + buildBridgeServerConfig(runDir, source, buildLaunchArgv()) + ), null, 2 )}\n`, diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index 0cfb99a..07d93ad 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { spawnSync } from "bun"; -import { BRIDGE_SERVER, CLAUDE_CHANNEL_USER } from "./bridge-constants"; +import { removeClaudeChannelServer } from "./bridge-claude-registration"; +import { CLAUDE_CHANNEL_USER } from "./bridge-constants"; import { acknowledgeBridgeDelivery, bridgeChatId, @@ -8,11 +9,11 @@ import { } from "./bridge-dispatch"; import { type BridgeMessage, + type BridgeStatus, readBridgeInbox, readBridgeStatus, } from "./bridge-store"; import { injectCodexMessage } from "./codex-app-server"; -import { sanitizeBase } from "./git"; import { isActiveRunState, parseRunLifecycleState, @@ -115,51 +116,33 @@ const tmuxSessionExists = (session: string): boolean => { return result.exitCode === 0; }; -export const hasLiveCodexTmuxSession = (runDir: string): boolean => { - const { tmuxSession } = readBridgeStatus(runDir); - return Boolean(tmuxSession && tmuxSessionExists(tmuxSession)); -}; +export interface BridgeRuntimeStatus extends BridgeStatus { + codexDeliveryMode: "app-server" | "none" | "tmux"; + hasLiveTmuxSession: boolean; +} -export 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}` +export const readBridgeRuntimeStatus = ( + runDir: string +): BridgeRuntimeStatus => { + const status = readBridgeStatus(runDir); + const hasLiveTmuxSession = Boolean( + status.tmuxSession && tmuxSessionExists(status.tmuxSession) ); + let codexDeliveryMode: BridgeRuntimeStatus["codexDeliveryMode"] = "none"; + if (hasLiveTmuxSession) { + codexDeliveryMode = "tmux"; + } else if (status.hasCodexRemote) { + codexDeliveryMode = "app-server"; + } + return { + ...status, + codexDeliveryMode, + hasLiveTmuxSession, + }; }; -const removeClaudeChannelServer = (runId: string): void => { - if (!runId) { - return; - } - const serverName = claudeChannelServerName(runId); - try { - const result = bridgeRuntimeCommandDeps.spawnSync( - ["claude", "mcp", "remove", "--scope", "local", serverName], - { - stderr: "pipe", - stdout: "ignore", - } - ); - 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) - ); - } +export const hasLiveCodexTmuxSession = (runDir: string): boolean => { + return readBridgeRuntimeStatus(runDir).hasLiveTmuxSession; }; export const clearStaleTmuxBridgeState = (runDir: string): boolean => { @@ -180,7 +163,15 @@ export const clearStaleTmuxBridgeState = (runDir: string): boolean => { if (!(next && removedRunId)) { return false; } - removeClaudeChannelServer(removedRunId); + removeClaudeChannelServer( + removedRunId, + (args) => + bridgeRuntimeCommandDeps.spawnSync(args, { + stderr: "pipe", + stdout: "ignore", + }), + console.error + ); return true; }; @@ -220,14 +211,14 @@ export const deliverCodexBridgeMessage = async ( runDir: string, message: BridgeMessage ): Promise => { - const status = readBridgeStatus(runDir); + const status = readBridgeRuntimeStatus(runDir); + if (status.hasLiveTmuxSession) { + return false; + } if (status.tmuxSession) { - if (tmuxSessionExists(status.tmuxSession)) { - return false; - } clearStaleTmuxBridgeState(runDir); } - if (!(status.codexRemoteUrl && status.codexThreadId)) { + if (!status.hasCodexRemote) { return false; } try { @@ -248,11 +239,11 @@ export const deliverCodexBridgeMessage = async ( export const drainCodexTmuxMessages = async ( runDir: string ): Promise => { - const { tmuxSession } = readBridgeStatus(runDir); - if (!tmuxSession) { + const status = readBridgeRuntimeStatus(runDir); + if (!status.tmuxSession) { return false; } - if (!tmuxSessionExists(tmuxSession)) { + if (!status.hasLiveTmuxSession) { clearStaleTmuxBridgeState(runDir); return false; } @@ -260,7 +251,10 @@ export const drainCodexTmuxMessages = async ( if (!message) { return false; } - const delivered = await injectCodexTmuxMessage(tmuxSession, message.message); + const delivered = await injectCodexTmuxMessage( + status.tmuxSession, + message.message + ); if (!delivered) { return false; } @@ -270,7 +264,7 @@ export const drainCodexTmuxMessages = async ( export const runBridgeWorker = async (runDir: string): Promise => { while (true) { - const status = readBridgeStatus(runDir); + const status = readBridgeRuntimeStatus(runDir); const state = parseRunLifecycleState(status.state); if (!(state && isActiveRunState(state))) { return; @@ -278,7 +272,7 @@ export const runBridgeWorker = async (runDir: string): Promise => { if (!status.tmuxSession) { return; } - if (!tmuxSessionExists(status.tmuxSession)) { + if (!status.hasLiveTmuxSession) { clearStaleTmuxBridgeState(runDir); return; } diff --git a/src/loop/bridge-store.ts b/src/loop/bridge-store.ts index 2dd44be..b911cd5 100644 --- a/src/loop/bridge-store.ts +++ b/src/loop/bridge-store.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { claudeChannelServerName } from "./bridge-config"; +import { BRIDGE_SERVER } from "./bridge-constants"; import { appendRunTranscriptEntry, buildTranscriptPath, @@ -36,9 +38,14 @@ interface BridgeAck extends BridgeBaseEvent { export type BridgeEvent = BridgeAck | BridgeMessage; export interface BridgeStatus { + bridgeServer: string; + claudeBridgeMode: "local-registration" | "mcp-config"; + claudeChannelServer: string; claudeSessionId: string; codexRemoteUrl: string; codexThreadId: string; + hasCodexRemote: boolean; + hasTmuxSession: boolean; pending: { claude: number; codex: number }; runId: string; state: string; @@ -231,15 +238,25 @@ const countPendingMessages = (runDir: string): BridgeStatus["pending"] => { export const readBridgeStatus = (runDir: string): BridgeStatus => { const manifest = readRunManifest(join(runDir, "manifest.json")); + const runId = manifest?.runId ?? ""; + const codexRemoteUrl = manifest?.codexRemoteUrl ?? ""; + const codexThreadId = manifest?.codexThreadId ?? ""; + const tmuxSession = manifest?.tmuxSession ?? ""; + const hasTmuxSession = Boolean(tmuxSession); return { + bridgeServer: BRIDGE_SERVER, + claudeBridgeMode: hasTmuxSession ? "local-registration" : "mcp-config", + claudeChannelServer: runId ? claudeChannelServerName(runId) : BRIDGE_SERVER, claudeSessionId: manifest?.claudeSessionId ?? "", - codexRemoteUrl: manifest?.codexRemoteUrl ?? "", - codexThreadId: manifest?.codexThreadId ?? "", + codexRemoteUrl, + codexThreadId, + hasCodexRemote: Boolean(codexRemoteUrl && codexThreadId), + hasTmuxSession, pending: countPendingMessages(runDir), - runId: manifest?.runId ?? "", + runId, state: manifest?.state ?? "unknown", status: manifest?.status ?? "unknown", - tmuxSession: manifest?.tmuxSession ?? "", + tmuxSession, }; }; diff --git a/src/loop/bridge.ts b/src/loop/bridge.ts index e202f8e..a0b2f63 100644 --- a/src/loop/bridge.ts +++ b/src/loop/bridge.ts @@ -1,3 +1,4 @@ +import { claudeChannelServerName } from "./bridge-config"; import { BRIDGE_SERVER as BRIDGE_SERVER_VALUE } from "./bridge-constants"; import { consumeBridgeInbox, @@ -8,12 +9,12 @@ import { import { claudeChannelInstructions } from "./bridge-guidance"; import { bridgeRuntimeCommandDeps, - claudeChannelServerName, clearStaleTmuxBridgeState, deliverCodexBridgeMessage, drainCodexTmuxMessages, flushClaudeChannelMessages, hasLiveCodexTmuxSession, + readBridgeRuntimeStatus, } from "./bridge-runtime"; import { appendBlockedBridgeMessage, @@ -23,7 +24,6 @@ import { formatBridgeInbox, normalizeAgent, readBridgeEvents, - readBridgeStatus, } from "./bridge-store"; import { LOOP_VERSION } from "./constants"; import type { Agent } from "./types"; @@ -117,7 +117,9 @@ const handleBridgeStatusTool = ( writeJsonRpc({ id, jsonrpc: "2.0", - result: toolContent(JSON.stringify(readBridgeStatus(runDir), null, 2)), + result: toolContent( + JSON.stringify(readBridgeRuntimeStatus(runDir), null, 2) + ), }); }; diff --git a/src/loop/paired-options.ts b/src/loop/paired-options.ts index 15d5579..5810602 100644 --- a/src/loop/paired-options.ts +++ b/src/loop/paired-options.ts @@ -1,5 +1,6 @@ import { buildCodexBridgeConfigArgs, + claudeChannelServerName, ensureClaudeBridgeConfig, } from "./bridge-config"; import { @@ -139,7 +140,11 @@ export const applyPairedOptions = ( manifest: RunManifest | undefined, allowRawSessionFallback = false ): void => { - opts.claudeMcpConfigPath = ensureClaudeBridgeConfig(storage.runDir, "claude"); + opts.claudeMcpConfigPath = ensureClaudeBridgeConfig( + storage.runDir, + "claude", + claudeChannelServerName(storage.runId) + ); opts.claudePersistentSession = true; opts.codexMcpConfigArgs = buildCodexBridgeConfigArgs(storage.runDir, "codex"); opts.pairedMode = true; diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index fd81332..9e3115b 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { spawn, spawnSync } from "bun"; -import { BRIDGE_SERVER, BRIDGE_SUBCOMMAND } from "./bridge-constants"; +import { registerClaudeChannelServer as registerClaudeBridgeServer } from "./bridge-claude-registration"; +import { + buildClaudeChannelServerConfig, + claudeChannelServerName, +} from "./bridge-config"; import { claudeTmuxReplyGuidance, receiveMessagesStuckGuidance, @@ -51,11 +55,9 @@ 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"; 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; interface SpawnResult { exitCode: number; @@ -150,7 +152,7 @@ const appendProofPrompt = (parts: string[], proof: string): void => { }; const pairedBridgeGuidance = (agent: Agent, runId: string): string => { - const serverName = buildClaudeChannelServerName(runId); + const serverName = claudeChannelServerName(runId); if (agent === "claude") { return [ @@ -302,21 +304,6 @@ const resolveTmuxModel = (agent: Agent, opts: Options): string => { : (opts.claudeReviewerModel ?? DEFAULT_CLAUDE_MODEL); }; -const buildClaudeChannelServerName = (runId: string): string => - `${BRIDGE_SERVER}-${sanitizeBase(runId)}`; - -const buildClaudeChannelServerConfig = ( - launchArgv: string[], - runDir: string -): string => { - const [command, ...baseArgs] = launchArgv; - return JSON.stringify({ - args: [...baseArgs, BRIDGE_SUBCOMMAND, runDir, "claude"], - command, - type: "stdio", - }); -}; - const buildClaudeCommand = ( sessionId: string, model: string, @@ -647,25 +634,14 @@ const updatePairedManifest = ( ); }; -const registerClaudeChannelServer = ( +const registerClaudeChannelServerForRun = ( deps: TmuxDeps, - serverName: string, + runId: string, runDir: string ): void => { - const result = deps.spawn([ - "claude", - "mcp", - "add-json", - "--scope", - CLAUDE_CHANNEL_SCOPE, - serverName, - buildClaudeChannelServerConfig(deps.launchArgv, runDir), - ]); - if (result.exitCode === 0 || MCP_ALREADY_EXISTS_RE.test(result.stderr)) { - return; - } - const suffix = result.stderr ? `: ${result.stderr}` : "."; - throw new Error(`[loop] failed to register Claude channel server${suffix}`); + registerClaudeBridgeServer(deps.launchArgv, runId, runDir, (args) => + deps.spawn(args) + ); }; const ensurePairedSessionIds = async ( @@ -835,8 +811,8 @@ const startPairedSession = async ( codexRemoteUrl, codexThreadId ); - const claudeChannelServer = buildClaudeChannelServerName(storage.runId); - registerClaudeChannelServer(deps, claudeChannelServer, storage.runDir); + const claudeChannelServer = claudeChannelServerName(storage.runId); + registerClaudeChannelServerForRun(deps, storage.runId, storage.runDir); const env = [`${RUN_BASE_ENV}=${runBase}`, `${RUN_ID_ENV}=${storage.runId}`]; const claudePrompt = buildLaunchPrompt(launch, "claude", storage.runId); const codexPrompt = buildLaunchPrompt(launch, "codex", storage.runId); @@ -1151,7 +1127,7 @@ export const runInTmux = async ( export const tmuxInternals = { buildClaudeCommand, buildClaudeChannelServerConfig, - buildClaudeChannelServerName, + buildClaudeChannelServerName: claudeChannelServerName, buildCodexCommand, buildInteractivePeerPrompt, buildInteractivePrimaryPrompt, diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 21c7a6f..dbec844 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -64,6 +64,13 @@ const listedTools = (stdout: string): Record[] => { ?.tools ?? [] ); }; +const toolText = (stdout: string, id: number): string => { + const response = parseJsonLines(stdout).find((entry) => entry.id === id); + const content = ( + response?.result as { content?: Array<{ text?: string }> } | undefined + )?.content; + return content?.[0]?.text ?? ""; +}; const runBridgeProcess = async ( runDir: string, @@ -186,6 +193,126 @@ test("markBridgeMessage records acknowledgements and clears pending entries", as rmSync(root, { recursive: true, force: true }); }); +test("readBridgeStatus derives bridge naming and transport fields", async () => { + const bridge = await loadBridge(); + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + claudeSessionId: "claude-session-1", + codexRemoteUrl: "ws://127.0.0.1:4500", + codexThreadId: "codex-thread-1", + createdAt: "2026-03-27T10:00:00.000Z", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "7", + state: "submitted", + status: "running", + updatedAt: "2026-03-27T10:00:00.000Z", + })}\n`, + "utf8" + ); + + expect(bridge.readBridgeStatus(runDir)).toMatchObject({ + bridgeServer: bridge.BRIDGE_SERVER, + claudeBridgeMode: "mcp-config", + claudeChannelServer: bridge.claudeChannelServerName("7"), + claudeSessionId: "claude-session-1", + codexRemoteUrl: "ws://127.0.0.1:4500", + codexThreadId: "codex-thread-1", + hasCodexRemote: true, + hasTmuxSession: false, + pending: { claude: 0, codex: 0 }, + runId: "7", + state: "submitted", + status: "running", + tmuxSession: "", + }); + + rmSync(root, { recursive: true, force: true }); +}); + +test("readBridgeRuntimeStatus distinguishes live and stale tmux delivery", async () => { + const spawnSync = mock((args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + const session = args[3]; + return { + exitCode: session === "repo-loop-live" ? 0 : 1, + stderr: Buffer.alloc(0), + stdout: Buffer.alloc(0), + }; + } + return { exitCode: 0, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + }); + const bridge = await loadBridge(); + bridge.bridgeRuntimeCommandDeps.spawnSync = spawnSync; + const root = makeTempDir(); + const liveRunDir = join(root, "live"); + const staleRunDir = join(root, "stale"); + mkdirSync(liveRunDir, { recursive: true }); + mkdirSync(staleRunDir, { recursive: true }); + + writeFileSync( + join(liveRunDir, "manifest.json"), + `${JSON.stringify({ + codexRemoteUrl: "ws://127.0.0.1:4500", + codexThreadId: "codex-thread-live", + createdAt: "2026-03-27T10:00:00.000Z", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "8", + state: "running", + status: "running", + tmuxSession: "repo-loop-live", + updatedAt: "2026-03-27T10:00:00.000Z", + })}\n`, + "utf8" + ); + writeFileSync( + join(staleRunDir, "manifest.json"), + `${JSON.stringify({ + codexRemoteUrl: "ws://127.0.0.1:4500", + codexThreadId: "codex-thread-stale", + createdAt: "2026-03-27T10:00:00.000Z", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "9", + state: "running", + status: "running", + tmuxSession: "repo-loop-stale", + updatedAt: "2026-03-27T10:00:00.000Z", + })}\n`, + "utf8" + ); + + expect(bridge.readBridgeRuntimeStatus(liveRunDir)).toMatchObject({ + claudeBridgeMode: "local-registration", + claudeChannelServer: bridge.claudeChannelServerName("8"), + codexDeliveryMode: "tmux", + hasCodexRemote: true, + hasLiveTmuxSession: true, + hasTmuxSession: true, + }); + expect(bridge.readBridgeRuntimeStatus(staleRunDir)).toMatchObject({ + claudeBridgeMode: "local-registration", + claudeChannelServer: bridge.claudeChannelServerName("9"), + codexDeliveryMode: "app-server", + hasCodexRemote: true, + hasLiveTmuxSession: false, + hasTmuxSession: true, + }); + + rmSync(root, { recursive: true, force: true }); +}); + test("readPendingBridgeMessages keeps repeated messages until each is acknowledged", async () => { const bridge = await loadBridge(); const root = makeTempDir(); @@ -713,6 +840,130 @@ test("bridge MCP writes line-delimited JSON responses", async () => { rmSync(root, { recursive: true, force: true }); }); +test("bridge runtime status reports app-server-backed config-file delivery", async () => { + const bridge = await loadBridge(); + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + claudeSessionId: "claude-session-1", + 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: "7", + state: "submitted", + status: "running", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + expect(bridge.readBridgeRuntimeStatus(runDir)).toMatchObject({ + claudeBridgeMode: "mcp-config", + claudeChannelServer: bridge.claudeChannelServerName("7"), + codexDeliveryMode: "app-server", + hasCodexRemote: true, + hasLiveTmuxSession: false, + }); + + rmSync(root, { recursive: true, force: true }); +}); + +test("bridge runtime status reports live tmux delivery with a run-scoped Claude server", async () => { + const spawnSync = mock((args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 0, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + } + return { exitCode: 1, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + }); + const bridge = await loadBridge(); + bridge.bridgeRuntimeCommandDeps.spawnSync = spawnSync; + 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", + state: "submitted", + status: "running", + tmuxSession: "repo-loop-8", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + expect(bridge.readBridgeRuntimeStatus(runDir)).toMatchObject({ + claudeBridgeMode: "local-registration", + claudeChannelServer: "loop-bridge-8", + codexDeliveryMode: "tmux", + hasCodexRemote: false, + hasLiveTmuxSession: true, + }); + + rmSync(root, { recursive: true, force: true }); +}); + +test("bridge MCP bridge_status includes runtime delivery fields", async () => { + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + claudeSessionId: "claude-session-1", + 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: "7", + state: "submitted", + status: "running", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + const result = await runBridgeProcess( + runDir, + "codex", + encodeLine({ + id: 1, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: {}, + name: "bridge_status", + }, + }) + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + const status = toolText(result.stdout, 1); + expect(status).toContain('"claudeBridgeMode": "mcp-config"'); + expect(status).toContain('"claudeChannelServer": "loop-bridge-7"'); + expect(status).toContain('"codexDeliveryMode": "app-server"'); + expect(status).toContain('"hasCodexRemote": true'); + expect(status).toContain('"hasLiveTmuxSession": false'); + + rmSync(root, { recursive: true, force: true }); +}); + test("bridge MCP receive_messages returns and clears queued inbox items", async () => { const bridge = await loadBridge(); const root = makeTempDir(); From abc9f16ea30b9b202af469e117ac94f46cd8e592 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Fri, 27 Mar 2026 23:02:58 -0700 Subject: [PATCH 02/12] Standardize bridge messaging on send_to_agent --- src/loop/bridge-dispatch.ts | 3 -- src/loop/bridge-guidance.ts | 13 ++--- src/loop/bridge.ts | 64 ------------------------ src/loop/paired-loop.ts | 2 +- src/loop/tmux.ts | 2 - tests/loop/bridge.test.ts | 91 +++------------------------------- tests/loop/paired-loop.test.ts | 4 +- tests/loop/tmux.test.ts | 8 +-- 8 files changed, 18 insertions(+), 169 deletions(-) diff --git a/src/loop/bridge-dispatch.ts b/src/loop/bridge-dispatch.ts index c6eead5..5df4c6b 100644 --- a/src/loop/bridge-dispatch.ts +++ b/src/loop/bridge-dispatch.ts @@ -24,9 +24,6 @@ export const bridgeChatId = (runDir: string): string => { return `codex_${runId}`; }; -export const isActiveBridgeChatId = (runDir: string, chatId: string): boolean => - chatId === bridgeChatId(runDir); - export const acknowledgeBridgeDelivery = ( runDir: string, message: BridgeMessage, diff --git a/src/loop/bridge-guidance.ts b/src/loop/bridge-guidance.ts index 37d90a7..23b2cf6 100644 --- a/src/loop/bridge-guidance.ts +++ b/src/loop/bridge-guidance.ts @@ -3,12 +3,6 @@ import type { Agent } from "./types"; const bridgeTargetLiteral = (agent: Agent): string => `target: "${agent}"`; -export const claudeReplyGuidance = - 'When you are replying to an inbound channel message, use the "reply" tool and pass back the same chat_id.'; - -export const claudeTmuxReplyGuidance = - 'Reply to inbound Codex channel messages with the "reply" tool and the same chat_id.'; - export const bridgeStatusStuckGuidance = 'Use "bridge_status" only when direct delivery appears stuck.'; @@ -19,13 +13,12 @@ export const sendToClaudeGuidance = (): string => `Use "send_to_agent" with ${bridgeTargetLiteral("claude")} for Claude-facing messages, not a human-facing message.`; export const sendProactiveCodexGuidance = (): string => - `Use "send_to_agent" with ${bridgeTargetLiteral("codex")} only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.`; + `Use "send_to_agent" with ${bridgeTargetLiteral("codex")} for Codex-facing messages, including replies to inbound Codex channel messages; do not send Codex-facing responses as a human-facing message.`; export const claudeChannelInstructions = (): string => [ - `Messages from the Codex agent arrive as .`, - claudeReplyGuidance, - "Never answer the human when the inbound message came from Codex. Send the response back through the bridge tools instead.", + `Messages from the Codex agent arrive as . The chat_id is informational only.`, sendProactiveCodexGuidance(), + "Never answer the human when the inbound message came from Codex. Send the response back through the bridge tools instead.", bridgeStatusStuckGuidance, ].join("\n"); diff --git a/src/loop/bridge.ts b/src/loop/bridge.ts index a0b2f63..064382f 100644 --- a/src/loop/bridge.ts +++ b/src/loop/bridge.ts @@ -4,7 +4,6 @@ import { consumeBridgeInbox, dispatchBridgeMessage, formatDispatchResult, - isActiveBridgeChatId, } from "./bridge-dispatch"; import { claudeChannelInstructions } from "./bridge-guidance"; import { @@ -142,45 +141,6 @@ const handleReceiveMessagesTool = ( }); }; -const handleReplyTool = async ( - id: JsonRpcRequest["id"], - runDir: string, - source: Agent, - args: Record -): Promise => { - const chatId = asString(args.chat_id); - const text = asString(args.text); - if (!chatId) { - writeError(id, MCP_INVALID_PARAMS, "reply requires a chat_id"); - return; - } - if (!isActiveBridgeChatId(runDir, chatId)) { - writeError( - id, - MCP_INVALID_PARAMS, - "reply chat_id does not match the active bridge conversation" - ); - return; - } - if (!text) { - writeError(id, MCP_INVALID_PARAMS, "reply requires a non-empty text"); - return; - } - const result = await dispatchBridgeMessage( - runDir, - source, - "codex", - text, - (entry) => deliverCodexBridgeMessage(runDir, entry), - () => hasLiveCodexTmuxSession(runDir) - ); - writeJsonRpc({ - id, - jsonrpc: "2.0", - result: toolContent(formatDispatchResult(result)), - }); -}; - const handleSendToAgentTool = async ( id: JsonRpcRequest["id"], runDir: string, @@ -276,11 +236,6 @@ const handleToolCall = async ( return; } - if (source === "claude" && name === "reply") { - await handleReplyTool(id, runDir, source, args); - return; - } - if (name !== "send_to_agent") { writeError(id, MCP_INVALID_PARAMS, `Unknown tool: ${name}`); return; @@ -347,25 +302,6 @@ const handleBridgeRequest = async ( jsonrpc: "2.0", result: { tools: [ - ...(source === "claude" - ? [ - { - annotations: MUTATING_TOOL_ANNOTATIONS, - description: - "Reply to the active Codex channel conversation and deliver the response back to Codex.", - inputSchema: { - additionalProperties: false, - properties: { - chat_id: { type: "string" }, - text: { type: "string" }, - }, - required: ["chat_id", "text"], - type: "object", - }, - name: "reply", - }, - ] - : []), { annotations: MUTATING_TOOL_ANNOTATIONS, description: "Send an explicit message to the paired agent.", diff --git a/src/loop/paired-loop.ts b/src/loop/paired-loop.ts index 6c10751..0455c20 100644 --- a/src/loop/paired-loop.ts +++ b/src/loop/paired-loop.ts @@ -155,7 +155,7 @@ 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. 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.', + 'Send a message 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"); diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 9e3115b..7060e55 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -8,7 +8,6 @@ import { claudeChannelServerName, } from "./bridge-config"; import { - claudeTmuxReplyGuidance, receiveMessagesStuckGuidance, sendProactiveCodexGuidance, sendToClaudeGuidance, @@ -157,7 +156,6 @@ const pairedBridgeGuidance = (agent: Agent, runId: string): string => { if (agent === "claude") { return [ `Your bridge MCP server is "${serverName}". All bridge tool calls must use the mcp__${serverName}__ prefix.`, - claudeTmuxReplyGuidance, sendProactiveCodexGuidance(), receiveMessagesStuckGuidance, ].join("\n"); diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index dbec844..7e66576 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -529,25 +529,10 @@ test("bridge MCP send_to_agent rejects an unknown normalized target", async () = rmSync(root, { recursive: true, force: true }); }); -test("bridge MCP reply accepts the manifest bridge chat_id", async () => { - const bridge = await loadBridge(); +test("bridge MCP send_to_agent rejects targeting the current agent", async () => { const root = makeTempDir(); const runDir = join(root, "run"); mkdirSync(runDir, { recursive: true }); - writeFileSync( - join(runDir, "manifest.json"), - `${JSON.stringify({ - createdAt: "2026-03-27T10:00:00.000Z", - cwd: "/repo", - mode: "paired", - pid: 1234, - repoId: "repo-123", - runId: "7", - status: "running", - updatedAt: "2026-03-27T10:00:00.000Z", - })}\n`, - "utf8" - ); const result = await runBridgeProcess( runDir, @@ -559,63 +544,10 @@ test("bridge MCP reply accepts the manifest bridge chat_id", async () => { method: "tools/call", params: { arguments: { - chat_id: "codex_7", - text: "ship it", - }, - name: "reply", - }, - }), - "\n", - ].join("") - ); - - expect(result.code).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("queued"); - expect(bridge.readPendingBridgeMessages(runDir)).toEqual([ - expect.objectContaining({ - message: "ship it", - source: "claude", - target: "codex", - }), - ]); - rmSync(root, { recursive: true, force: true }); -}); - -test("bridge MCP reply rejects a mismatched bridge chat_id", async () => { - const bridge = await loadBridge(); - const root = makeTempDir(); - const runDir = join(root, "run"); - mkdirSync(runDir, { recursive: true }); - writeFileSync( - join(runDir, "manifest.json"), - `${JSON.stringify({ - createdAt: "2026-03-27T10:00:00.000Z", - cwd: "/repo", - mode: "paired", - pid: 1234, - repoId: "repo-123", - runId: "7", - status: "running", - updatedAt: "2026-03-27T10:00:00.000Z", - })}\n`, - "utf8" - ); - - const result = await runBridgeProcess( - runDir, - "claude", - [ - encodeFrame({ - id: 1, - jsonrpc: "2.0", - method: "tools/call", - params: { - arguments: { - chat_id: "codex_999", - text: "ship it", + message: "ship it", + target: "claude", }, - name: "reply", + name: "send_to_agent", }, }), "\n", @@ -626,12 +558,11 @@ test("bridge MCP reply rejects a mismatched bridge chat_id", async () => { expect(JSON.parse(result.stdout)).toMatchObject({ error: { code: -32_602, - message: "reply chat_id does not match the active bridge conversation", + message: "send_to_agent cannot target the current agent", }, id: 1, jsonrpc: "2.0", }); - expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); rmSync(root, { recursive: true, force: true }); }); @@ -689,7 +620,7 @@ test("bridge MCP handles standard empty-list and ping requests through the Claud expect(result.stderr).toBe(""); expect(result.stdout).toContain('"claude/channel":{}'); expect(result.stdout).toContain( - '\\"reply\\" tool and pass back the same chat_id' + '\\"send_to_agent\\" with target: \\"codex\\" for Codex-facing messages' ); expect(result.stdout).toContain( "Never answer the human when the inbound message came from Codex" @@ -706,14 +637,6 @@ test("bridge MCP handles standard empty-list and ping requests through the Claud const tools = listedTools(result.stdout); expect(tools).toEqual( expect.arrayContaining([ - expect.objectContaining({ - annotations: { - destructiveHint: false, - openWorldHint: false, - readOnlyHint: false, - }, - name: "reply", - }), expect.objectContaining({ annotations: { destructiveHint: false, @@ -740,6 +663,8 @@ test("bridge MCP handles standard empty-list and ping requests through the Claud }), ]) ); + expect(tools).toHaveLength(3); + expect(tools.some((tool) => tool.name === "reply")).toBe(false); rmSync(root, { recursive: true, force: true }); }); diff --git a/tests/loop/paired-loop.test.ts b/tests/loop/paired-loop.test.ts index 557eeeb..333c5e9 100644 --- a/tests/loop/paired-loop.test.ts +++ b/tests/loop/paired-loop.test.ts @@ -610,7 +610,7 @@ test("runPairedLoop delivers forwarded bridge messages to the target agent", asy 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"' + 'Send a message to the other agent with "send_to_agent"' ); const events = bridgeInternals.readBridgeEvents(runDir); @@ -787,7 +787,7 @@ test("runPairedLoop delivers peer messages back to the primary agent", async () "Found one change to make before landing this." ); expect(calls[2]?.prompt).toContain( - 'Reply to the other agent with "send_to_agent"' + 'Send a message to the other agent with "send_to_agent"' ); }); }); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index 505b524..a025cf2 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -776,9 +776,9 @@ test("tmux prompts keep the paired review workflow explicit", () => { 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("Wait for Codex to send you a targeted request"); - expect(peerPrompt).toContain('"reply"'); + expect(peerPrompt).not.toContain('"reply"'); expect(peerPrompt).toContain( - 'Use "send_to_agent" with target: "codex" only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.' + 'Use "send_to_agent" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; 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"); @@ -809,9 +809,9 @@ test("interactive tmux prompts tell both agents to 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).not.toContain('"reply"'); expect(peerPrompt).toContain( - 'Use "send_to_agent" with target: "codex" only for new proactive messages to Codex; do not send Codex-facing responses as a human-facing message.' + 'Use "send_to_agent" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; 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." From af6c99aabb4c463c551e54116462ae9d7ff39233 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 13:59:03 -0700 Subject: [PATCH 03/12] Harden bridge runtime and Claude server cleanup --- src/loop/bridge-claude-registration.ts | 14 +- src/loop/bridge-config.ts | 10 +- src/loop/bridge-runtime.ts | 59 ++++--- src/loop/bridge-store.ts | 6 +- src/loop/paired-options.ts | 18 ++- src/loop/run-state.ts | 13 ++ src/loop/tmux.ts | 215 ++++++++++++++++--------- tests/loop/bridge.test.ts | 130 ++++++++++++--- tests/loop/paired-options.test.ts | 40 ++++- tests/loop/tmux.test.ts | 156 ++++++++++++++++-- 10 files changed, 512 insertions(+), 149 deletions(-) diff --git a/src/loop/bridge-claude-registration.ts b/src/loop/bridge-claude-registration.ts index 73a22fe..fdac7c5 100644 --- a/src/loop/bridge-claude-registration.ts +++ b/src/loop/bridge-claude-registration.ts @@ -1,7 +1,4 @@ -import { - buildClaudeChannelServerConfig, - claudeChannelServerName, -} from "./bridge-config"; +import { buildClaudeChannelServerConfig } from "./bridge-config"; const CLAUDE_CHANNEL_SCOPE = "local"; const MCP_ALREADY_EXISTS_RE = /already exists/i; @@ -35,7 +32,7 @@ const logClaudeChannelServerRemovalFailure = ( export const registerClaudeChannelServer = ( launchArgv: string[], - runId: string, + serverName: string, runDir: string, runCommand: BridgeCommand ): void => { @@ -45,7 +42,7 @@ export const registerClaudeChannelServer = ( "add-json", "--scope", CLAUDE_CHANNEL_SCOPE, - claudeChannelServerName(runId), + serverName, buildClaudeChannelServerConfig(launchArgv, runDir), ]); const stderr = stderrText(result.stderr); @@ -57,14 +54,13 @@ export const registerClaudeChannelServer = ( }; export const removeClaudeChannelServer = ( - runId: string, + serverName: string, runCommand: BridgeCommand, log: (line: string) => void = console.error ): void => { - if (!runId) { + if (!serverName) { return; } - const serverName = claudeChannelServerName(runId); try { const result = runCommand([ "claude", diff --git a/src/loop/bridge-config.ts b/src/loop/bridge-config.ts index c5c02a9..c04b8bd 100644 --- a/src/loop/bridge-config.ts +++ b/src/loop/bridge-config.ts @@ -46,9 +46,17 @@ const buildBridgeFileConfig = ( }, }); -export const claudeChannelServerName = (runId: string): string => +export const legacyClaudeChannelServerName = (runId: string): string => `${BRIDGE_SERVER}-${sanitizeBase(runId)}`; +export const claudeChannelServerName = ( + runId: string, + repoId?: string +): string => + repoId?.trim() + ? `${BRIDGE_SERVER}-${sanitizeBase(repoId)}-${sanitizeBase(runId)}` + : legacyClaudeChannelServerName(runId); + export const buildClaudeChannelServerConfig = ( launchArgv: string[], runDir: string diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index 07d93ad..126390f 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -1,6 +1,10 @@ import { join } from "node:path"; import { spawnSync } from "bun"; import { removeClaudeChannelServer } from "./bridge-claude-registration"; +import { + claudeChannelServerName, + legacyClaudeChannelServerName, +} from "./bridge-config"; import { CLAUDE_CHANNEL_USER } from "./bridge-constants"; import { acknowledgeBridgeDelivery, @@ -17,6 +21,7 @@ import { injectCodexMessage } from "./codex-app-server"; import { isActiveRunState, parseRunLifecycleState, + readRunManifest, touchRunManifest, updateRunManifest, } from "./run-state"; @@ -106,14 +111,18 @@ const injectCodexTmuxMessage = async ( }; const tmuxSessionExists = (session: string): boolean => { - const result = bridgeRuntimeCommandDeps.spawnSync( - ["tmux", "has-session", "-t", session], - { - stderr: "ignore", - stdout: "ignore", - } - ); - return result.exitCode === 0; + try { + const result = bridgeRuntimeCommandDeps.spawnSync( + ["tmux", "has-session", "-t", session], + { + stderr: "ignore", + stdout: "ignore", + } + ); + return result.exitCode === 0; + } catch { + return false; + } }; export interface BridgeRuntimeStatus extends BridgeStatus { @@ -142,16 +151,22 @@ export const readBridgeRuntimeStatus = ( }; export const hasLiveCodexTmuxSession = (runDir: string): boolean => { - return readBridgeRuntimeStatus(runDir).hasLiveTmuxSession; + const manifest = readRunManifest(join(runDir, "manifest.json")); + return Boolean( + manifest?.tmuxSession && tmuxSessionExists(manifest.tmuxSession) + ); }; export const clearStaleTmuxBridgeState = (runDir: string): boolean => { - let removedRunId = ""; + let removedServerNames: string[] = []; const next = updateRunManifest(join(runDir, "manifest.json"), (manifest) => { if (!manifest?.tmuxSession) { return manifest; } - removedRunId = manifest.runId; + removedServerNames = [ + claudeChannelServerName(manifest.runId, manifest.repoId), + legacyClaudeChannelServerName(manifest.runId), + ]; return touchRunManifest( { ...manifest, @@ -160,18 +175,20 @@ export const clearStaleTmuxBridgeState = (runDir: string): boolean => { new Date().toISOString() ); }); - if (!(next && removedRunId)) { + if (!(next && removedServerNames.length > 0)) { return false; } - removeClaudeChannelServer( - removedRunId, - (args) => - bridgeRuntimeCommandDeps.spawnSync(args, { - stderr: "pipe", - stdout: "ignore", - }), - console.error - ); + for (const serverName of new Set(removedServerNames)) { + removeClaudeChannelServer( + serverName, + (args) => + bridgeRuntimeCommandDeps.spawnSync(args, { + stderr: "pipe", + stdout: "ignore", + }), + console.error + ); + } return true; }; diff --git a/src/loop/bridge-store.ts b/src/loop/bridge-store.ts index b911cd5..a38d0a2 100644 --- a/src/loop/bridge-store.ts +++ b/src/loop/bridge-store.ts @@ -246,7 +246,11 @@ export const readBridgeStatus = (runDir: string): BridgeStatus => { return { bridgeServer: BRIDGE_SERVER, claudeBridgeMode: hasTmuxSession ? "local-registration" : "mcp-config", - claudeChannelServer: runId ? claudeChannelServerName(runId) : BRIDGE_SERVER, + claudeChannelServer: + manifest?.claudeChannelServer ?? + (runId + ? claudeChannelServerName(runId, manifest?.repoId) + : BRIDGE_SERVER), claudeSessionId: manifest?.claudeSessionId ?? "", codexRemoteUrl, codexThreadId, diff --git a/src/loop/paired-options.ts b/src/loop/paired-options.ts index 5810602..1af1355 100644 --- a/src/loop/paired-options.ts +++ b/src/loop/paired-options.ts @@ -40,6 +40,16 @@ export const canResumePairedManifest = (manifest?: RunManifest): boolean => { return manifest ? isActiveRunState(manifest.state) : false; }; +const resolveClaudeBridgeServer = ( + storage: RunStorage, + manifest?: RunManifest +): string => { + return ( + manifest?.claudeChannelServer ?? + claudeChannelServerName(storage.runId, storage.repoId) + ); +}; + const resolveRequestedRunState = ( opts: Options, cwd: string @@ -117,6 +127,7 @@ export const resolvePreparedRunState = ( } const manifest = createRunManifest({ + claudeChannelServer: claudeChannelServerName(storage.runId, storage.repoId), claudeSessionId: "", codexThreadId: "", cwd, @@ -143,7 +154,7 @@ export const applyPairedOptions = ( opts.claudeMcpConfigPath = ensureClaudeBridgeConfig( storage.runDir, "claude", - claudeChannelServerName(storage.runId) + resolveClaudeBridgeServer(storage, manifest) ); opts.claudePersistentSession = true; opts.codexMcpConfigArgs = buildCodexBridgeConfigArgs(storage.runDir, "codex"); @@ -181,6 +192,7 @@ export const preparePairedRun = ( ? touchRunManifest( { ...existing, + claudeChannelServer: resolveClaudeBridgeServer(storage, existing), claudeSessionId: resumable?.claudeSessionId || opts.pairedSessionIds?.claude || "", codexThreadId: @@ -195,6 +207,10 @@ export const preparePairedRun = ( new Date().toISOString() ) : createRunManifest({ + claudeChannelServer: claudeChannelServerName( + storage.runId, + storage.repoId + ), claudeSessionId: opts.pairedSessionIds?.claude ?? "", codexThreadId: opts.pairedSessionIds?.codex ?? "", cwd, diff --git a/src/loop/run-state.ts b/src/loop/run-state.ts index 008e7d2..0fbcf01 100644 --- a/src/loop/run-state.ts +++ b/src/loop/run-state.ts @@ -54,6 +54,7 @@ export interface RunStorage { } export interface RunManifest { + claudeChannelServer?: string; claudeSessionId: string; codexRemoteUrl?: string; codexThreadId: string; @@ -122,6 +123,7 @@ interface RepoIdDeps { } interface RunManifestInput { + claudeChannelServer?: string; claudeSessionId?: string; codexRemoteUrl?: string; codexThreadId?: string; @@ -444,6 +446,9 @@ export const createRunManifest = ( parseRunLifecycleState(undefined, input.status) ?? "submitted"; return { + ...(input.claudeChannelServer + ? { claudeChannelServer: input.claudeChannelServer } + : {}), claudeSessionId: input.claudeSessionId ?? "", ...(input.codexRemoteUrl ? { codexRemoteUrl: input.codexRemoteUrl } : {}), codexThreadId: input.codexThreadId ?? "", @@ -524,6 +529,14 @@ export const readRunManifest = ( } return { + ...(firstString(parsed, ["claudeChannelServer", "claude_channel_server"]) + ? { + claudeChannelServer: firstString(parsed, [ + "claudeChannelServer", + "claude_channel_server", + ]), + } + : {}), claudeSessionId: firstString(parsed, ["claudeSessionId", "claude_session_id"]) ?? "", ...(firstString(parsed, ["codexRemoteUrl", "codex_remote_url"]) diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index 7060e55..db9c533 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -2,10 +2,14 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { spawn, spawnSync } from "bun"; -import { registerClaudeChannelServer as registerClaudeBridgeServer } from "./bridge-claude-registration"; +import { + registerClaudeChannelServer, + removeClaudeChannelServer, +} from "./bridge-claude-registration"; import { buildClaudeChannelServerConfig, claudeChannelServerName, + legacyClaudeChannelServerName, } from "./bridge-config"; import { receiveMessagesStuckGuidance, @@ -150,9 +154,11 @@ const appendProofPrompt = (parts: string[], proof: string): void => { parts.push(`Proof requirements:\n${trimmed}`); }; -const pairedBridgeGuidance = (agent: Agent, runId: string): string => { - const serverName = claudeChannelServerName(runId); - +const pairedBridgeGuidance = ( + agent: Agent, + _runId: string, + serverName: string +): string => { if (agent === "claude") { return [ `Your bridge MCP server is "${serverName}". All bridge tool calls must use the mcp__${serverName}__ prefix.`, @@ -188,7 +194,8 @@ const pairedWorkflowGuidance = (opts: Options, agent: Agent): string => { const buildPrimaryPrompt = ( task: string, opts: Options, - runId: string + runId: string, + serverName: string ): string => { const peer = capitalize(peerAgent(opts.agent)); const parts = [ @@ -198,7 +205,7 @@ const buildPrimaryPrompt = ( ]; appendProofPrompt(parts, opts.proof); parts.push(SPAWN_TEAM_WITH_WORKTREE_ISOLATION); - parts.push(pairedBridgeGuidance(opts.agent, runId)); + parts.push(pairedBridgeGuidance(opts.agent, runId, serverName)); parts.push(pairedWorkflowGuidance(opts, opts.agent)); parts.push( `${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.` @@ -210,7 +217,8 @@ const buildPeerPrompt = ( task: string, opts: Options, agent: Agent, - runId: string + runId: string, + serverName: string ): string => { const primary = capitalize(opts.agent); const parts = [ @@ -219,7 +227,7 @@ const buildPeerPrompt = ( `You are ${capitalize(agent)}. Do not start implementing or verifying this task on your own.`, ]; appendProofPrompt(parts, opts.proof); - parts.push(pairedBridgeGuidance(agent, runId)); + parts.push(pairedBridgeGuidance(agent, runId, serverName)); parts.push(pairedWorkflowGuidance(opts, agent)); parts.push( `Wait for ${primary} to send you a targeted request or review ask.` @@ -229,7 +237,8 @@ const buildPeerPrompt = ( const buildInteractivePrimaryPrompt = ( opts: Options, - runId: string + runId: string, + serverName: string ): string => { const peer = capitalize(peerAgent(opts.agent)); const parts = [ @@ -241,7 +250,7 @@ const buildInteractivePrimaryPrompt = ( parts.push( `${SPAWN_TEAM_WITH_WORKTREE_ISOLATION} Apply that once the human gives you a concrete task.` ); - parts.push(pairedBridgeGuidance(opts.agent, runId)); + parts.push(pairedBridgeGuidance(opts.agent, runId, serverName)); parts.push(pairedWorkflowGuidance(opts, opts.agent)); parts.push( `If the human asks for plan mode, write PLAN.md first, ask ${peer} for a plan review, iterate on PLAN.md, then ask the human to review the plan before implementing.` @@ -255,7 +264,8 @@ const buildInteractivePrimaryPrompt = ( const buildInteractivePeerPrompt = ( opts: Options, agent: Agent, - runId: string + runId: string, + serverName: string ): string => { const primary = capitalize(opts.agent); const parts = [ @@ -264,7 +274,7 @@ const buildInteractivePeerPrompt = ( `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, runId)); + parts.push(pairedBridgeGuidance(agent, runId, serverName)); parts.push(pairedWorkflowGuidance(opts, agent)); parts.push( `If ${primary} asks for a plan review, review PLAN.md only, suggest concrete fixes, and wait for the next request.` @@ -278,17 +288,18 @@ const buildInteractivePeerPrompt = ( const buildLaunchPrompt = ( launch: PairedTmuxLaunch, agent: Agent, - runId: string + runId: string, + serverName: string ): string => { const task = launch.task?.trim(); if (!task) { return launch.opts.agent === agent - ? buildInteractivePrimaryPrompt(launch.opts, runId) - : buildInteractivePeerPrompt(launch.opts, agent, runId); + ? buildInteractivePrimaryPrompt(launch.opts, runId, serverName) + : buildInteractivePeerPrompt(launch.opts, agent, runId, serverName); } return launch.opts.agent === agent - ? buildPrimaryPrompt(task, launch.opts, runId) - : buildPeerPrompt(task, launch.opts, agent, runId); + ? buildPrimaryPrompt(task, launch.opts, runId, serverName) + : buildPeerPrompt(task, launch.opts, agent, runId, serverName); }; const resolveTmuxModel = (agent: Agent, opts: Options): string => { @@ -634,14 +645,35 @@ const updatePairedManifest = ( const registerClaudeChannelServerForRun = ( deps: TmuxDeps, - runId: string, + serverName: string, runDir: string ): void => { - registerClaudeBridgeServer(deps.launchArgv, runId, runDir, (args) => + registerClaudeChannelServer(deps.launchArgv, serverName, runDir, (args) => deps.spawn(args) ); }; +const cleanupFailedPairedSessionStart = ( + deps: TmuxDeps, + session: string, + serverName: string, + runId: string +): void => { + try { + if (sessionExists(session, deps.spawn)) { + deps.spawn(["tmux", "kill-session", "-t", session]); + } + } catch { + // Best-effort cleanup after a failed paired startup. + } + for (const name of new Set([ + serverName, + legacyClaudeChannelServerName(runId), + ])) { + removeClaudeChannelServer(name, (args) => deps.spawn(args), deps.log); + } +}; + const ensurePairedSessionIds = async ( deps: TmuxDeps, opts: Options, @@ -809,70 +841,95 @@ const startPairedSession = async ( codexRemoteUrl, codexThreadId ); - const claudeChannelServer = claudeChannelServerName(storage.runId); - registerClaudeChannelServerForRun(deps, storage.runId, storage.runDir); - const env = [`${RUN_BASE_ENV}=${runBase}`, `${RUN_ID_ENV}=${storage.runId}`]; - const claudePrompt = buildLaunchPrompt(launch, "claude", storage.runId); - const codexPrompt = buildLaunchPrompt(launch, "codex", storage.runId); - const claudeCommand = buildShellCommand([ - "env", - ...env, - ...buildClaudeCommand( - claudeSessionId, - resolveTmuxModel("claude", launch.opts), - claudeChannelServer, - hadClaudeSession, - hadClaudeSession ? undefined : claudePrompt - ), - ]); - const codexCommand = buildShellCommand([ - "env", - ...env, - ...buildCodexCommand( - codexProxyUrl, - resolveTmuxModel("codex", launch.opts), - launch.opts.codexMcpConfigArgs ?? [], - hadCodexThread ? undefined : codexPrompt - ), - ]); + const claudeChannelServer = + manifest.claudeChannelServer || + claudeChannelServerName(storage.runId, storage.repoId); + registerClaudeChannelServerForRun(deps, claudeChannelServer, storage.runDir); + try { + const env = [ + `${RUN_BASE_ENV}=${runBase}`, + `${RUN_ID_ENV}=${storage.runId}`, + ]; + const claudePrompt = buildLaunchPrompt( + launch, + "claude", + storage.runId, + claudeChannelServer + ); + const codexPrompt = buildLaunchPrompt( + launch, + "codex", + storage.runId, + claudeChannelServer + ); + const claudeCommand = buildShellCommand([ + "env", + ...env, + ...buildClaudeCommand( + claudeSessionId, + resolveTmuxModel("claude", launch.opts), + claudeChannelServer, + hadClaudeSession, + hadClaudeSession ? undefined : claudePrompt + ), + ]); + const codexCommand = buildShellCommand([ + "env", + ...env, + ...buildCodexCommand( + codexProxyUrl, + resolveTmuxModel("codex", launch.opts), + launch.opts.codexMcpConfigArgs ?? [], + hadCodexThread ? undefined : codexPrompt + ), + ]); - runTmuxCommand(deps, [ - "tmux", - "new-session", - "-d", - ...buildSessionSizeArgs(deps), - "-s", - session, - "-c", - deps.cwd, - claudeCommand, - ]); - runTmuxCommand( - deps, - [ + runTmuxCommand(deps, [ "tmux", - "split-window", - "-h", - "-t", - `${session}:0`, + "new-session", + "-d", + ...buildSessionSizeArgs(deps), + "-s", + session, "-c", deps.cwd, - codexCommand, - ], - "Failed to split tmux window" - ); - deps.spawn([ - "tmux", - "select-layout", - "-t", - `${session}:0`, - "even-horizontal", - ]); - await unblockClaudePane(session, deps); - const primaryPane = - launch.opts.agent === "claude" ? `${session}:0.0` : `${session}:0.1`; - deps.spawn(["tmux", "select-pane", "-t", primaryPane]); - return session; + claudeCommand, + ]); + runTmuxCommand( + deps, + [ + "tmux", + "split-window", + "-h", + "-t", + `${session}:0`, + "-c", + deps.cwd, + codexCommand, + ], + "Failed to split tmux window" + ); + deps.spawn([ + "tmux", + "select-layout", + "-t", + `${session}:0`, + "even-horizontal", + ]); + await unblockClaudePane(session, deps); + const primaryPane = + launch.opts.agent === "claude" ? `${session}:0.0` : `${session}:0.1`; + deps.spawn(["tmux", "select-pane", "-t", primaryPane]); + return session; + } catch (error: unknown) { + cleanupFailedPairedSessionStart( + deps, + session, + claudeChannelServer, + storage.runId + ); + throw error; + } }; const startRequestedSession = ( diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 7e66576..4af9c52 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -28,19 +28,23 @@ const loadBridge = ( const nonce = Date.now(); return Promise.all([ import(`../../src/loop/bridge?test=${nonce}`), + import(`../../src/loop/bridge-claude-registration?test=${nonce}`), import(`../../src/loop/bridge-dispatch?test=${nonce}`), import(`../../src/loop/bridge-config?test=${nonce}`), import(`../../src/loop/bridge-constants?test=${nonce}`), import(`../../src/loop/bridge-runtime?test=${nonce}`), import(`../../src/loop/bridge-store?test=${nonce}`), - ]).then(([bridge, dispatch, config, constants, runtime, store]) => ({ - ...bridge, - ...dispatch, - ...config, - ...constants, - ...runtime, - ...store, - })); + ]).then( + ([bridge, registration, dispatch, config, constants, runtime, store]) => ({ + ...bridge, + ...registration, + ...dispatch, + ...config, + ...constants, + ...runtime, + ...store, + }) + ); }; const makeTempDir = (): string => mkdtempSync(join(tmpdir(), "loop-bridge-")); @@ -75,11 +79,13 @@ const toolText = (stdout: string, id: number): string => { const runBridgeProcess = async ( runDir: string, source: "claude" | "codex", - frames: string + frames: string, + env?: NodeJS.ProcessEnv ): Promise<{ code: number | null; stderr: string; stdout: string }> => { const cli = join(process.cwd(), "src", "cli.ts"); - const child = spawn("bun", [cli, "__bridge-mcp", runDir, source], { + const child = spawn(process.execPath, [cli, "__bridge-mcp", runDir, source], { cwd: process.cwd(), + env, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; @@ -220,7 +226,7 @@ test("readBridgeStatus derives bridge naming and transport fields", async () => expect(bridge.readBridgeStatus(runDir)).toMatchObject({ bridgeServer: bridge.BRIDGE_SERVER, claudeBridgeMode: "mcp-config", - claudeChannelServer: bridge.claudeChannelServerName("7"), + claudeChannelServer: bridge.claudeChannelServerName("7", "repo-123"), claudeSessionId: "claude-session-1", codexRemoteUrl: "ws://127.0.0.1:4500", codexThreadId: "codex-thread-1", @@ -295,7 +301,7 @@ test("readBridgeRuntimeStatus distinguishes live and stale tmux delivery", async expect(bridge.readBridgeRuntimeStatus(liveRunDir)).toMatchObject({ claudeBridgeMode: "local-registration", - claudeChannelServer: bridge.claudeChannelServerName("8"), + claudeChannelServer: bridge.claudeChannelServerName("8", "repo-123"), codexDeliveryMode: "tmux", hasCodexRemote: true, hasLiveTmuxSession: true, @@ -303,7 +309,7 @@ test("readBridgeRuntimeStatus distinguishes live and stale tmux delivery", async }); expect(bridge.readBridgeRuntimeStatus(staleRunDir)).toMatchObject({ claudeBridgeMode: "local-registration", - claudeChannelServer: bridge.claudeChannelServerName("9"), + claudeChannelServer: bridge.claudeChannelServerName("9", "repo-123"), codexDeliveryMode: "app-server", hasCodexRemote: true, hasLiveTmuxSession: false, @@ -791,7 +797,7 @@ test("bridge runtime status reports app-server-backed config-file delivery", asy expect(bridge.readBridgeRuntimeStatus(runDir)).toMatchObject({ claudeBridgeMode: "mcp-config", - claudeChannelServer: bridge.claudeChannelServerName("7"), + claudeChannelServer: bridge.claudeChannelServerName("7", "repo-123"), codexDeliveryMode: "app-server", hasCodexRemote: true, hasLiveTmuxSession: false, @@ -831,7 +837,7 @@ test("bridge runtime status reports live tmux delivery with a run-scoped Claude expect(bridge.readBridgeRuntimeStatus(runDir)).toMatchObject({ claudeBridgeMode: "local-registration", - claudeChannelServer: "loop-bridge-8", + claudeChannelServer: "loop-bridge-repo-123-8", codexDeliveryMode: "tmux", hasCodexRemote: false, hasLiveTmuxSession: true, @@ -881,7 +887,7 @@ test("bridge MCP bridge_status includes runtime delivery fields", async () => { expect(result.stderr).toBe(""); const status = toolText(result.stdout, 1); expect(status).toContain('"claudeBridgeMode": "mcp-config"'); - expect(status).toContain('"claudeChannelServer": "loop-bridge-7"'); + expect(status).toContain('"claudeChannelServer": "loop-bridge-repo-123-7"'); expect(status).toContain('"codexDeliveryMode": "app-server"'); expect(status).toContain('"hasCodexRemote": true'); expect(status).toContain('"hasLiveTmuxSession": false'); @@ -889,6 +895,56 @@ test("bridge MCP bridge_status includes runtime delivery fields", async () => { rmSync(root, { recursive: true, force: true }); }); +test("bridge MCP bridge_status tolerates a missing tmux binary", async () => { + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + claudeSessionId: "claude-session-1", + 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: "7", + state: "submitted", + status: "running", + tmuxSession: "repo-loop-7", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + const result = await runBridgeProcess( + runDir, + "codex", + encodeLine({ + id: 1, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: {}, + name: "bridge_status", + }, + }), + { ...process.env, PATH: "/definitely-missing" } + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + const status = toolText(result.stdout, 1); + expect(status).toContain('"claudeChannelServer": "loop-bridge-repo-123-7"'); + expect(status).toContain('"codexDeliveryMode": "app-server"'); + expect(status).toContain('"hasLiveTmuxSession": false'); + expect(status).toContain('"hasTmuxSession": true'); + + rmSync(root, { recursive: true, force: true }); +}); + test("bridge MCP receive_messages returns and clears queued inbox items", async () => { const bridge = await loadBridge(); const root = makeTempDir(); @@ -1049,7 +1105,7 @@ test("bridge falls back to direct Codex delivery when the stored tmux session is "remove", "--scope", "local", - bridge.claudeChannelServerName("8"), + bridge.claudeChannelServerName("8", "repo-123"), ]); expect(removeCall?.[1]).toMatchObject({ stderr: "pipe", @@ -1212,7 +1268,7 @@ test("bridge stale tmux cleanup logs non-zero Claude MCP remove exits", async () try { expect(bridge.clearStaleTmuxBridgeState(runDir)).toBe(true); expect(errorSpy).toHaveBeenCalledWith( - '[loop] failed to remove Claude channel server "loop-bridge-8": command failed' + '[loop] failed to remove Claude channel server "loop-bridge-repo-123-8": command failed' ); expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( undefined @@ -1257,7 +1313,7 @@ test("bridge stale tmux cleanup logs thrown Claude MCP remove errors", async () try { expect(bridge.clearStaleTmuxBridgeState(runDir)).toBe(true); expect(errorSpy).toHaveBeenCalledWith( - '[loop] failed to remove Claude channel server "loop-bridge-8": spawn failed' + '[loop] failed to remove Claude channel server "loop-bridge-repo-123-8": spawn failed' ); expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( undefined @@ -1318,7 +1374,7 @@ test("runBridgeWorker clears stale tmux routing and exits", async () => { "remove", "--scope", "local", - bridge.claudeChannelServerName("8"), + bridge.claudeChannelServerName("8", "repo-123"), ], expect.objectContaining({ stderr: "pipe", stdout: "ignore" }), ], @@ -1653,6 +1709,40 @@ test("bridge config helper writes the Claude MCP config file", async () => { rmSync(root, { recursive: true, force: true }); }); +test("bridge config helper writes the Claude MCP config file for a custom server", async () => { + const bridge = await loadBridge(); + const root = makeTempDir(); + const runDir = join(root, "run"); + const serverName = bridge.claudeChannelServerName("1", "repo-123"); + + const path = bridge.ensureClaudeBridgeConfig(runDir, "claude", serverName); + expect(path).toBe(join(runDir, "claude-mcp.json")); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + mcpServers: { + [serverName]: { + args: ["src/loop/main.ts", bridge.BRIDGE_SUBCOMMAND, runDir, "claude"], + command: "/opt/bun", + type: "stdio", + }, + }, + }); + + rmSync(root, { recursive: true, force: true }); +}); + +test("bridge registration helper throws on unexpected Claude MCP add-json failures", async () => { + const bridge = await loadBridge(); + + expect(() => + bridge.registerClaudeChannelServer( + ["/opt/bun", "src/loop/main.ts"], + bridge.claudeChannelServerName("7", "repo-123"), + "/tmp/run", + () => ({ exitCode: 1, stderr: "command failed" }) + ) + ).toThrow("[loop] failed to register Claude channel server: command failed"); +}); + test("dispatchBridgeMessage reports delivered when direct codex delivery succeeds", async () => { const injectCodexMessage = mock(async () => true); const bridge = await loadBridge({ injectCodexMessage }); diff --git a/tests/loop/paired-options.test.ts b/tests/loop/paired-options.test.ts index 4914f2b..7cbf349 100644 --- a/tests/loop/paired-options.test.ts +++ b/tests/loop/paired-options.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -66,6 +66,44 @@ test("preparePairedOptions accepts a raw session id without creating a paired ma } }); +test("preparePairedOptions writes a repo-scoped Claude bridge server for fresh runs", () => { + const home = makeTempHome(); + const originalHome = process.env.HOME; + const originalRunId = process.env.LOOP_RUN_ID; + process.env.HOME = home; + Reflect.deleteProperty(process.env, "LOOP_RUN_ID"); + + try { + const opts = makeOptions({ agent: "claude", pairedMode: true }); + + preparePairedOptions(opts, process.cwd(), true); + + const storage = resolveRunStorage("1", process.cwd(), home); + const manifest = readRunManifest(storage.manifestPath); + expect(manifest?.claudeChannelServer).toBe( + `loop-bridge-${storage.repoId}-1` + ); + const configPath = opts.claudeMcpConfigPath; + expect(configPath).toBeDefined(); + const config = JSON.parse(readFileSync(configPath ?? "", "utf8")); + expect(Object.keys(config.mcpServers)).toEqual([ + `loop-bridge-${storage.repoId}-1`, + ]); + } finally { + if (originalHome === undefined) { + Reflect.deleteProperty(process.env, "HOME"); + } else { + process.env.HOME = originalHome; + } + if (originalRunId === undefined) { + Reflect.deleteProperty(process.env, "LOOP_RUN_ID"); + } else { + process.env.LOOP_RUN_ID = originalRunId; + } + rmSync(home, { recursive: true, force: true }); + } +}); + test("preparePairedOptions ignores stored session ids from a completed paired run", () => { const home = makeTempHome(); const originalHome = process.env.HOME; diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index a025cf2..6987ada 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -282,7 +282,10 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { ); const env = ["LOOP_RUN_BASE=repo", "LOOP_RUN_ID=1"]; - const claudeChannelServer = tmuxInternals.buildClaudeChannelServerName("1"); + const claudeChannelServer = tmuxInternals.buildClaudeChannelServerName( + "1", + storage.repoId + ); const claudeChannelConfig = tmuxInternals.buildClaudeChannelServerConfig( ["bun", "/repo/src/cli.ts"], storage.runDir @@ -291,7 +294,8 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { "Ship feature", opts, "claude", - "1" + "1", + claudeChannelServer ); const claudeCommand = tmuxInternals.buildShellCommand([ "env", @@ -311,7 +315,12 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => { codexProxyUrl, "test-model", codexMcpConfigArgs, - tmuxInternals.buildPrimaryPrompt("Ship feature", opts, "1") + tmuxInternals.buildPrimaryPrompt( + "Ship feature", + opts, + "1", + claudeChannelServer + ) ), ]); @@ -628,11 +637,15 @@ 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 claudeChannelServer = tmuxInternals.buildClaudeChannelServerName( + "1", + storage.repoId + ); const claudePrompt = tmuxInternals.buildInteractivePeerPrompt( opts, "claude", - "1" + "1", + claudeChannelServer ); const claudeCommand = tmuxInternals.buildShellCommand([ "env", @@ -662,7 +675,11 @@ test("runInTmux starts paired interactive tmux panes without a task", async () = "ws://127.0.0.1:4600/", "test-model", ["-c", 'mcp_servers.loop-bridge.command="loop"'], - tmuxInternals.buildInteractivePrimaryPrompt(opts, "1") + tmuxInternals.buildInteractivePrimaryPrompt( + opts, + "1", + claudeChannelServer + ) ), ]); expect(calls[3]).toEqual([ @@ -748,16 +765,22 @@ test("runInTmux keeps the no-prompt Claude startup wait bounded", async () => { test("tmux prompts keep the paired review workflow explicit", () => { const opts = makePairedOptions(); + const claudeChannelServer = tmuxInternals.buildClaudeChannelServerName( + "1", + "repo-123" + ); const primaryPrompt = tmuxInternals.buildPrimaryPrompt( "Ship feature", opts, - "1" + "1", + claudeChannelServer ); const peerPrompt = tmuxInternals.buildPeerPrompt( "Ship feature", opts, "claude", - "1" + "1", + claudeChannelServer ); expect(primaryPrompt).toContain("Agent-to-agent pair programming"); @@ -780,17 +803,26 @@ test("tmux prompts keep the paired review workflow explicit", () => { expect(peerPrompt).toContain( 'Use "send_to_agent" with target: "codex" for Codex-facing messages, including replies to inbound Codex channel messages; 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"); + expect(primaryPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); + expect(peerPrompt).toContain("mcp__loop-bridge-repo-123-1__ prefix"); }); test("interactive tmux prompts tell both agents to wait for the human", () => { const opts = makePairedOptions({ proof: "" }); - const primaryPrompt = tmuxInternals.buildInteractivePrimaryPrompt(opts, "1"); + const claudeChannelServer = tmuxInternals.buildClaudeChannelServerName( + "1", + "repo-123" + ); + const primaryPrompt = tmuxInternals.buildInteractivePrimaryPrompt( + opts, + "1", + claudeChannelServer + ); const peerPrompt = tmuxInternals.buildInteractivePeerPrompt( opts, "claude", - "1" + "1", + claudeChannelServer ); expect(primaryPrompt).toContain("Agent-to-agent pair programming"); @@ -816,8 +848,8 @@ test("interactive tmux prompts tell both agents to wait for the human", () => { 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"); + expect(primaryPrompt).not.toContain("mcp__loop-bridge-repo-123-1__ prefix"); + expect(peerPrompt).toContain("mcp__loop-bridge-repo-123-1__ prefix"); }); test("runInTmux auto-confirms Claude startup prompts in paired mode", async () => { @@ -1406,8 +1438,10 @@ test("runInTmux reopens paired tmux panes without replaying the task", async () ); const env = ["LOOP_RUN_BASE=repo", "LOOP_RUN_ID=alpha"]; - const claudeChannelServer = - tmuxInternals.buildClaudeChannelServerName("alpha"); + const claudeChannelServer = tmuxInternals.buildClaudeChannelServerName( + "alpha", + storage.repoId + ); const claudeChannelConfig = tmuxInternals.buildClaudeChannelServerConfig( ["bun", "/repo/src/cli.ts"], storage.runDir @@ -2025,6 +2059,96 @@ test("runInTmux surfaces tmux startup errors", async () => { ).rejects.toThrow("Failed to start tmux session: boom"); }); +test("runInTmux removes the Claude bridge server when paired tmux startup fails", async () => { + const calls: string[][] = []; + 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", + }; + + await expect( + runInTmux( + ["--tmux", "--proof", "verify with tests"], + { + capturePane: () => "", + 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: (): void => undefined, + sendText: (): void => undefined, + sleep: () => Promise.resolve(), + startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"), + startPersistentAgentSession: () => Promise.resolve(undefined), + spawn: (args: string[]) => { + calls.push(args); + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 1, stderr: "" }; + } + if (args[0] === "tmux" && args[1] === "new-session") { + return { exitCode: 1, stderr: "boom" }; + } + return { exitCode: 0, stderr: "" }; + }, + updateRunManifest: (_path, update) => { + manifest = update(manifest) ?? manifest; + return manifest; + }, + }, + { opts, task: "Ship feature" } + ) + ).rejects.toThrow("Failed to start tmux session: boom"); + + expect(calls).toContainEqual([ + "claude", + "mcp", + "add-json", + "--scope", + "local", + tmuxInternals.buildClaudeChannelServerName("1", "repo-123"), + tmuxInternals.buildClaudeChannelServerConfig( + ["bun", "/repo/src/cli.ts"], + storage.runDir + ), + ]); + expect(calls).toContainEqual([ + "claude", + "mcp", + "remove", + "--scope", + "local", + tmuxInternals.buildClaudeChannelServerName("1", "repo-123"), + ]); + expect( + calls.some((args) => args[0] === "tmux" && args[1] === "kill-session") + ).toBe(false); +}); + test("runInTmux skips auto-attach for non-interactive sessions", async () => { const attaches: string[] = []; From 6b0bf05e2484307b1d69d6c31acb02f672fd6f98 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 14:25:57 -0700 Subject: [PATCH 04/12] Wait for bridge-injected app-server turns to finish --- src/loop/codex-app-server.ts | 171 +++++++++++++++++++++++----- tests/loop/codex-app-server.test.ts | 106 +++++++++++++++-- 2 files changed, 237 insertions(+), 40 deletions(-) diff --git a/src/loop/codex-app-server.ts b/src/loop/codex-app-server.ts index 3cf4333..716b35f 100644 --- a/src/loop/codex-app-server.ts +++ b/src/loop/codex-app-server.ts @@ -34,6 +34,12 @@ interface PendingRequest { timeout: ReturnType; } +interface PendingTurnCompletion { + reject: (error: Error) => void; + resolve: () => void; + timeout: ReturnType; +} + interface TurnState { combined: string; lastChunk: string; @@ -62,7 +68,6 @@ const METHOD_INITIALIZE = "initialize"; const METHOD_INITIALIZED = "initialized"; const METHOD_THREAD_START = "thread/start"; const METHOD_TURN_START = "turn/start"; -const METHOD_TURN_STARTED = "turn/started"; const METHOD_TURN_COMPLETED = "turn/completed"; const METHOD_ERROR = "error"; const METHOD_ITEM_COMPLETED = "item/completed"; @@ -80,11 +85,8 @@ const METHODS_TRIGGERING_FALLBACK = new Set([ METHOD_TURN_START, ]); const BRIDGE_OPT_OUT_NOTIFICATION_METHODS = [ - METHOD_ERROR, METHOD_ITEM_COMPLETED, METHOD_ITEM_DELTA, - METHOD_TURN_COMPLETED, - METHOD_TURN_STARTED, ] as const; type SpawnFn = (...args: Parameters) => ReturnType; @@ -313,17 +315,126 @@ const handleWsServerRequest = ( }); }; +const extractNotificationTurnId = ( + params: Record +): string | undefined => + extractTurnId(params) || asString(asRecord(params.turn).id); + +const settlePendingTurn = ( + pendingTurns: Map, + turnId: string, + error?: Error +): void => { + const turn = pendingTurns.get(turnId); + if (!turn) { + return; + } + clearTimeout(turn.timeout); + pendingTurns.delete(turnId); + if (error) { + turn.reject(error); + return; + } + turn.resolve(); +}; + +const turnNotificationError = ( + method: string, + turnId: string, + params: Record +): Error | undefined => { + if (method === METHOD_ERROR) { + return new Error(parseErrorText(params) || `turn ${turnId} failed`); + } + if (method !== METHOD_TURN_COMPLETED) { + return undefined; + } + const turn = asRecord(params.turn); + const status = asString(params.status) ?? asString(turn.status); + if (status !== "failed") { + return undefined; + } + return new Error( + parseErrorText(params) || parseErrorText(turn) || `turn ${turnId} failed` + ); +}; + +const handleRemoteTurnNotification = ( + pendingTurns: Map, + method: string, + params: Record +): void => { + const turnId = extractNotificationTurnId(params); + if (!turnId) { + return; + } + if (method !== METHOD_ERROR && method !== METHOD_TURN_COMPLETED) { + return; + } + settlePendingTurn( + pendingTurns, + turnId, + turnNotificationError(method, turnId, params) + ); +}; + +const handleRemoteClientResponse = ( + pending: Map, + requestId: string, + frame: JsonFrame +): void => { + const request = pending.get(requestId); + if (!request) { + return; + } + clearTimeout(request.timeout); + pending.delete(requestId); + if (frame.error !== undefined) { + request.reject( + new Error( + parseErrorText(frame.error) || + `codex app-server request "${request.method}" failed` + ) + ); + return; + } + request.resolve(frame.result); +}; + +const handleRemoteClientFrame = ( + ws: import("./ws-client").WsClient, + pending: Map, + pendingTurns: Map, + frame: JsonFrame +): void => { + const frameId = asRequestId(frame.id); + const method = asString(frame.method); + if (frameId && method) { + handleWsServerRequest(ws, frameId, method); + return; + } + if (frameId) { + handleRemoteClientResponse(pending, frameId, frame); + return; + } + if (method) { + handleRemoteTurnNotification(pendingTurns, method, asRecord(frame.params)); + } +}; + const createRemoteAppServerClient = async ( url: string ): Promise<{ close(): void; sendNotification(method: string, params?: Record): void; sendRequest(method: string, params: unknown): Promise; + waitForTurnCompletion(turnId: string): Promise; }> => { const ws = await connectWsFn(url); let closed = false; let requestId = 1; const pending = new Map(); + const pendingTurns = new Map(); const failAll = (error: Error): void => { for (const request of pending.values()) { @@ -331,6 +442,11 @@ const createRemoteAppServerClient = async ( request.reject(error); } pending.clear(); + for (const turn of pendingTurns.values()) { + clearTimeout(turn.timeout); + turn.reject(error); + } + pendingTurns.clear(); }; ws.onmessage = (data) => { @@ -339,31 +455,7 @@ const createRemoteAppServerClient = async ( if (!frame) { continue; } - const frameId = asRequestId(frame.id); - const method = asString(frame.method); - if (frameId && method) { - handleWsServerRequest(ws, frameId, method); - continue; - } - if (!frameId) { - continue; - } - const request = pending.get(frameId); - if (!request) { - continue; - } - clearTimeout(request.timeout); - pending.delete(frameId); - if (frame.error !== undefined) { - request.reject( - new Error( - parseErrorText(frame.error) || - `codex app-server request "${request.method}" failed` - ) - ); - continue; - } - request.resolve(frame.result); + handleRemoteClientFrame(ws, pending, pendingTurns, frame); } }; @@ -417,6 +509,20 @@ const createRemoteAppServerClient = async ( pending.set(nextRequestId, { method, reject, resolve, timeout }); }); }, + waitForTurnCompletion: (turnId: string): Promise => { + if (closed) { + return Promise.reject( + new Error("codex app-server remote connection closed") + ); + } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingTurns.delete(turnId); + reject(new Error(`codex app-server turn ${turnId} timed out`)); + }, AGENT_TURN_TIMEOUT_MS); + pendingTurns.set(turnId, { reject, resolve, timeout }); + }); + }, }; }; @@ -1280,10 +1386,15 @@ export const injectCodexMessage = async ( }, }); client.sendNotification(METHOD_INITIALIZED); - await client.sendRequest(METHOD_TURN_START, { + const response = await client.sendRequest(METHOD_TURN_START, { input: buildInput(prompt), threadId, }); + const turn = extractTurnFromStart(response); + if (!turn.id) { + throw new Error("codex app-server returned turn/start without turn id"); + } + await client.waitForTurnCompletion(turn.id); return true; } finally { client.close(); diff --git a/tests/loop/codex-app-server.test.ts b/tests/loop/codex-app-server.test.ts index ba54696..6bab3e4 100644 --- a/tests/loop/codex-app-server.test.ts +++ b/tests/loop/codex-app-server.test.ts @@ -374,6 +374,15 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", } if (request.method === "turn/start") { write({ id: request.id, result: { turn: { id: "turn-1" } } }); + queueMicrotask(() => { + write({ + method: "turn/completed", + params: { + turnId: "turn-1", + turn: { id: "turn-1", status: "completed" }, + }, + }); + }); } }; @@ -396,13 +405,7 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", expect(frames[0]?.params).toMatchObject({ capabilities: { experimentalApi: true, - optOutNotificationMethods: [ - "error", - "item/completed", - "item/agentMessage/delta", - "turn/completed", - "turn/started", - ], + optOutNotificationMethods: ["item/completed", "item/agentMessage/delta"], }, clientInfo: { name: "loop-bridge", @@ -421,6 +424,83 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", }); }); +test("injectCodexMessage waits for turn completion before resolving", async () => { + const appServer = await getModule(); + let finishTurn: (() => void) | undefined; + currentHandler = (request, write) => { + if (request.method === "initialize") { + write({ id: request.id, result: {} }); + return; + } + if (request.method === "turn/start") { + write({ id: request.id, result: { turn: { id: "turn-1" } } }); + finishTurn = () => { + write({ + method: "turn/completed", + params: { + turnId: "turn-1", + turn: { id: "turn-1", status: "completed" }, + }, + }); + }; + } + }; + + let resolved = false; + const pending = appServer + .injectCodexMessage( + "ws://127.0.0.1:4500", + "thread-1", + "Please review the final diff." + ) + .then((value) => { + resolved = true; + return value; + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(resolved).toBe(false); + + finishTurn?.(); + + await expect(pending).resolves.toBe(true); + expect(resolved).toBe(true); +}); + +test("injectCodexMessage rejects failed injected turns", async () => { + const appServer = await getModule(); + currentHandler = (request, write) => { + if (request.method === "initialize") { + write({ id: request.id, result: {} }); + return; + } + if (request.method === "turn/start") { + write({ id: request.id, result: { turn: { id: "turn-1" } } }); + queueMicrotask(() => { + write({ + method: "turn/completed", + params: { + turnId: "turn-1", + turn: { + error: { message: "policy blocked" }, + id: "turn-1", + status: "failed", + }, + }, + }); + }); + } + }; + + await expect( + appServer.injectCodexMessage( + "ws://127.0.0.1:4500", + "thread-1", + "Please review the final diff." + ) + ).rejects.toThrow("policy blocked"); +}); + test("runCodexTurn promotes thread/start unsupported errors to fallback errors", async () => { const appServer = await getModule(); currentHandler = (request, write) => { @@ -1110,6 +1190,15 @@ test("injectCodexMessage sends initialized and bridge notification opt-outs", as } if (request.method === "turn/start") { write({ id: request.id, result: { turn: { id: "turn-1" } } }); + queueMicrotask(() => { + write({ + method: "turn/completed", + params: { + turnId: "turn-1", + turn: { id: "turn-1", status: "completed" }, + }, + }); + }); } }; @@ -1127,11 +1216,8 @@ test("injectCodexMessage sends initialized and bridge notification opt-outs", as capabilities: { experimentalApi: true, optOutNotificationMethods: [ - "error", "item/completed", "item/agentMessage/delta", - "turn/completed", - "turn/started", ], }, }, From 8ce18836307c95896952f29ff7738dd549f15b4d Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 14:27:13 -0700 Subject: [PATCH 05/12] Retry queued app-server bridge messages --- src/loop/bridge-runtime.ts | 152 ++++++++++++++++++++++++++++++++----- src/loop/bridge.ts | 9 +++ tests/loop/bridge.test.ts | 112 +++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 17 deletions(-) diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index 126390f..bd83729 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -1,11 +1,15 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { spawnSync } from "bun"; +import { spawn, spawnSync } from "bun"; import { removeClaudeChannelServer } from "./bridge-claude-registration"; import { claudeChannelServerName, legacyClaudeChannelServerName, } from "./bridge-config"; -import { CLAUDE_CHANNEL_USER } from "./bridge-constants"; +import { + BRIDGE_WORKER_SUBCOMMAND, + CLAUDE_CHANNEL_USER, +} from "./bridge-constants"; import { acknowledgeBridgeDelivery, bridgeChatId, @@ -18,6 +22,8 @@ import { readBridgeStatus, } from "./bridge-store"; import { injectCodexMessage } from "./codex-app-server"; +import { buildLaunchArgv } from "./launch"; +import { DETACH_CHILD_PROCESS } from "./process"; import { isActiveRunState, parseRunLifecycleState, @@ -29,12 +35,65 @@ import { const CLAUDE_CHANNEL_METHOD = "notifications/claude/channel"; const CLAUDE_CHANNEL_SOURCE_TYPE = "codex"; const CLAUDE_CHANNEL_USER_ID = "codex"; +const BRIDGE_WORKER_FILE = "bridge-worker.json"; +const BRIDGE_WORKER_IDLE_DELAY_MS = 250; +const BRIDGE_WORKER_SUCCESS_DELAY_MS = 100; const CODEX_TMUX_PANE = "0.1"; const CODEX_TMUX_READY_DELAY_MS = 250; const CODEX_TMUX_READY_POLLS = 20; const CODEX_TMUX_SEND_FOOTER = "Ctrl+J newline"; -export const bridgeRuntimeCommandDeps = { spawnSync }; +export const bridgeRuntimeCommandDeps = { spawn, spawnSync }; + +const bridgeWorkerPath = (runDir: string): string => + join(runDir, BRIDGE_WORKER_FILE); + +const readBridgeWorkerPid = (runDir: string): number | undefined => { + const path = bridgeWorkerPath(runDir); + if (!existsSync(path)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { pid?: unknown }).pid === "number" && + Number.isInteger((parsed as { pid: number }).pid) && + (parsed as { pid: number }).pid > 0 + ) { + return (parsed as { pid: number }).pid; + } + } catch { + // ignore malformed worker state + } + return undefined; +}; + +const writeBridgeWorkerPid = (runDir: string, pid: number): void => { + writeFileSync( + bridgeWorkerPath(runDir), + `${JSON.stringify({ pid })}\n`, + "utf8" + ); +}; + +const clearBridgeWorkerPid = (runDir: string, pid?: number): void => { + const current = readBridgeWorkerPid(runDir); + if (pid !== undefined && current !== pid) { + return; + } + rmSync(bridgeWorkerPath(runDir), { force: true }); +}; + +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; const wait = async (ms: number): Promise => { await new Promise((resolve) => { @@ -150,6 +209,39 @@ export const readBridgeRuntimeStatus = ( }; }; +export const ensureBridgeWorker = (runDir: string): boolean => { + const status = readBridgeRuntimeStatus(runDir); + const state = parseRunLifecycleState(status.state); + if (!(status.hasCodexRemote && state && isActiveRunState(state))) { + return false; + } + const currentPid = readBridgeWorkerPid(runDir); + if (currentPid && isProcessAlive(currentPid)) { + return true; + } + clearBridgeWorkerPid(runDir); + try { + const child = bridgeRuntimeCommandDeps.spawn( + [...buildLaunchArgv(), BRIDGE_WORKER_SUBCOMMAND, runDir], + { + detached: DETACH_CHILD_PROCESS, + env: process.env, + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + } + ); + if (!(typeof child.pid === "number" && child.pid > 0)) { + return false; + } + writeBridgeWorkerPid(runDir, child.pid); + child.unref?.(); + return true; + } catch { + return false; + } +}; + export const hasLiveCodexTmuxSession = (runDir: string): boolean => { const manifest = readRunManifest(join(runDir, "manifest.json")); return Boolean( @@ -279,21 +371,47 @@ export const drainCodexTmuxMessages = async ( return true; }; +export const drainCodexAppServerMessages = ( + runDir: string +): Promise => { + const status = readBridgeRuntimeStatus(runDir); + if (status.hasLiveTmuxSession || !status.hasCodexRemote) { + return Promise.resolve(false); + } + const message = readNextPendingBridgeMessageForTarget(runDir, "codex"); + if (!message) { + return Promise.resolve(false); + } + return deliverCodexBridgeMessage(runDir, message); +}; + export const runBridgeWorker = async (runDir: string): Promise => { - while (true) { - const status = readBridgeRuntimeStatus(runDir); - const state = parseRunLifecycleState(status.state); - if (!(state && isActiveRunState(state))) { - return; - } - if (!status.tmuxSession) { - return; - } - if (!status.hasLiveTmuxSession) { - clearStaleTmuxBridgeState(runDir); - return; + try { + while (true) { + const claimedPid = readBridgeWorkerPid(runDir); + if (claimedPid && claimedPid !== process.pid) { + return; + } + const status = readBridgeRuntimeStatus(runDir); + const state = parseRunLifecycleState(status.state); + if (!(state && isActiveRunState(state))) { + return; + } + if (status.tmuxSession && !status.hasLiveTmuxSession) { + clearStaleTmuxBridgeState(runDir); + return; + } + const delivered = status.hasLiveTmuxSession + ? await drainCodexTmuxMessages(runDir) + : await drainCodexAppServerMessages(runDir); + if (!(status.hasLiveTmuxSession || status.hasCodexRemote)) { + return; + } + await wait( + delivered ? BRIDGE_WORKER_SUCCESS_DELAY_MS : BRIDGE_WORKER_IDLE_DELAY_MS + ); } - const delivered = await drainCodexTmuxMessages(runDir); - await wait(delivered ? 100 : CODEX_TMUX_READY_DELAY_MS); + } finally { + clearBridgeWorkerPid(runDir, process.pid); } }; diff --git a/src/loop/bridge.ts b/src/loop/bridge.ts index 064382f..af3dcf2 100644 --- a/src/loop/bridge.ts +++ b/src/loop/bridge.ts @@ -11,6 +11,7 @@ import { clearStaleTmuxBridgeState, deliverCodexBridgeMessage, drainCodexTmuxMessages, + ensureBridgeWorker, flushClaudeChannelMessages, hasLiveCodexTmuxSession, readBridgeRuntimeStatus, @@ -209,6 +210,13 @@ const handleSendToAgentTool = async ( : undefined, target === "codex" ? () => hasLiveCodexTmuxSession(runDir) : undefined ); + if ( + result.status === "queued" && + target === "codex" && + ensureBridgeWorker(runDir) + ) { + result.status = "accepted"; + } writeJsonRpc({ id, jsonrpc: "2.0", @@ -534,5 +542,6 @@ export const bridgeInternals = { commandDeps: bridgeRuntimeCommandDeps, drainCodexTmuxMessages, deliverCodexBridgeMessage, + ensureBridgeWorker, readBridgeEvents, }; diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 4af9c52..598ff5b 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -1384,6 +1384,118 @@ test("runBridgeWorker clears stale tmux routing and exits", async () => { rmSync(root, { recursive: true, force: true }); }); +test("ensureBridgeWorker launches one app-server worker per active run", async () => { + const bridge = await loadBridge(); + const spawn = mock(() => ({ + pid: process.pid, + unref: mock(() => undefined), + })); + bridge.bridgeRuntimeCommandDeps.spawn = spawn; + 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", + state: "working", + status: "running", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + + expect(bridge.ensureBridgeWorker(runDir)).toBe(true); + expect(bridge.ensureBridgeWorker(runDir)).toBe(true); + expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn.mock.calls[0]?.[0]).toEqual([ + "/opt/bun", + "src/loop/main.ts", + bridge.BRIDGE_WORKER_SUBCOMMAND, + runDir, + ]); + expect(spawn.mock.calls[0]?.[1]).toMatchObject({ + stderr: "ignore", + stdin: "ignore", + stdout: "ignore", + }); + + rmSync(root, { recursive: true, force: true }); +}); + +test("runBridgeWorker retries queued codex app-server messages", async () => { + let runDir = ""; + const injectCodexMessage = mock(() => { + if (injectCodexMessage.mock.calls.length === 1) { + throw new Error("turn still active"); + } + const manifestPath = join(runDir, "manifest.json"); + const manifest = readRunManifest(manifestPath); + writeFileSync( + manifestPath, + `${JSON.stringify({ + ...manifest, + state: "completed", + status: "completed", + })}\n`, + "utf8" + ); + return true; + }); + const bridge = await loadBridge({ injectCodexMessage }); + const root = makeTempDir(); + 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", + state: "working", + status: "running", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + bridge.bridgeInternals.appendBridgeEvent(runDir, { + at: "2026-03-23T10:01:00.000Z", + id: "msg-4", + kind: "message", + message: "Please review the final diff.", + source: "claude", + target: "codex", + }); + + await bridge.runBridgeWorker(runDir); + + expect(injectCodexMessage).toHaveBeenCalledTimes(2); + expect(injectCodexMessage.mock.calls).toEqual([ + ["ws://127.0.0.1:4500", "codex-thread-1", "Please review the final diff."], + ["ws://127.0.0.1:4500", "codex-thread-1", "Please review the final diff."], + ]); + expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); + expect( + bridge.bridgeInternals + .readBridgeEvents(runDir) + .filter((event) => event.kind === "delivered") + ).toHaveLength(1); + + 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 94e6af10cb2a50c71adcf4b3f5167b8550e71725 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 14:38:03 -0700 Subject: [PATCH 06/12] Queue Codex bridge messages via app-server steering --- src/loop/bridge-runtime.ts | 6 +- src/loop/codex-app-server.ts | 81 +++++++++++-- tests/loop/codex-app-server.test.ts | 172 ++++++++++++++++++++-------- 3 files changed, 201 insertions(+), 58 deletions(-) diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index bd83729..69ac264 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -337,7 +337,11 @@ export const deliverCodexBridgeMessage = async ( message.message ); if (delivered) { - acknowledgeBridgeDelivery(runDir, message, "sent to codex app-server"); + acknowledgeBridgeDelivery( + runDir, + message, + "accepted by codex app-server" + ); } return delivered; } catch { diff --git a/src/loop/codex-app-server.ts b/src/loop/codex-app-server.ts index 716b35f..b4abc07 100644 --- a/src/loop/codex-app-server.ts +++ b/src/loop/codex-app-server.ts @@ -67,7 +67,9 @@ export const DEFAULT_CODEX_TRANSPORT: TransportMode = const METHOD_INITIALIZE = "initialize"; const METHOD_INITIALIZED = "initialized"; const METHOD_THREAD_START = "thread/start"; +const METHOD_THREAD_READ = "thread/read"; const METHOD_TURN_START = "turn/start"; +const METHOD_TURN_STEER = "turn/steer"; const METHOD_TURN_COMPLETED = "turn/completed"; const METHOD_ERROR = "error"; const METHOD_ITEM_COMPLETED = "item/completed"; @@ -210,6 +212,18 @@ const parseErrorText = (value: unknown): string | undefined => { ); }; +const isBusyTurnError = (value: unknown): boolean => { + const message = parseErrorText(value)?.toLowerCase(); + return !!( + message && + (message.includes("active turn") || + message.includes("already active") || + message.includes("busy") || + message.includes("in progress") || + message.includes("turn still active")) + ); +}; + const extractTurnId = (value: unknown): string | undefined => { const record = asRecord(value); const fromValue = asString(record.turnId) ?? asString(record.turn_id); @@ -237,6 +251,20 @@ const extractTurnFromStart = (value: unknown): { id?: string } => { return { id: asString(turn.id) || asString(record.id) }; }; +const extractActiveTurnId = (value: unknown): string | undefined => { + const thread = asRecord(asRecord(value).thread); + if (!Array.isArray(thread.turns)) { + return undefined; + } + for (let index = thread.turns.length - 1; index >= 0; index -= 1) { + const turn = asRecord(thread.turns[index]); + if (asString(turn.status) === "inProgress") { + return asString(turn.id); + } + } + return undefined; +}; + const buildInput = (prompt: string): Record[] => [ { type: "text", @@ -1386,16 +1414,51 @@ export const injectCodexMessage = async ( }, }); client.sendNotification(METHOD_INITIALIZED); - const response = await client.sendRequest(METHOD_TURN_START, { - input: buildInput(prompt), - threadId, - }); - const turn = extractTurnFromStart(response); - if (!turn.id) { - throw new Error("codex app-server returned turn/start without turn id"); + + for (let attempt = 0; attempt < 2; attempt += 1) { + let activeTurnId: string | undefined; + try { + const thread = await client.sendRequest(METHOD_THREAD_READ, { + includeTurns: true, + threadId, + }); + activeTurnId = extractActiveTurnId(thread); + } catch { + activeTurnId = undefined; + } + + if (activeTurnId) { + try { + await client.sendRequest(METHOD_TURN_STEER, { + expectedTurnId: activeTurnId, + input: buildInput(prompt), + threadId, + }); + return true; + } catch { + // The active turn may have already completed. Fall through to start. + } + } + + try { + const response = await client.sendRequest(METHOD_TURN_START, { + input: buildInput(prompt), + threadId, + }); + const turn = extractTurnFromStart(response); + if (!turn.id) { + throw new Error( + "codex app-server returned turn/start without turn id" + ); + } + return true; + } catch (error) { + if (!(attempt === 0 && isBusyTurnError(error))) { + throw error; + } + } } - await client.waitForTurnCompletion(turn.id); - return true; + return false; } finally { client.close(); } diff --git a/tests/loop/codex-app-server.test.ts b/tests/loop/codex-app-server.test.ts index 6bab3e4..d990dd1 100644 --- a/tests/loop/codex-app-server.test.ts +++ b/tests/loop/codex-app-server.test.ts @@ -372,17 +372,12 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", write({ id: request.id, result: {} }); return; } + if (request.method === "thread/read") { + write({ id: request.id, result: { thread: { turns: [] } } }); + return; + } if (request.method === "turn/start") { write({ id: request.id, result: { turn: { id: "turn-1" } } }); - queueMicrotask(() => { - write({ - method: "turn/completed", - params: { - turnId: "turn-1", - turn: { id: "turn-1", status: "completed" }, - }, - }); - }); } }; @@ -400,6 +395,7 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", expect(frames.map((frame) => frame.method)).toEqual([ "initialize", "initialized", + "thread/read", "turn/start", ]); expect(frames[0]?.params).toMatchObject({ @@ -413,6 +409,10 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", }, }); expect(frames[2]?.params).toMatchObject({ + includeTurns: true, + threadId: "thread-1", + }); + expect(frames[3]?.params).toMatchObject({ input: [ { text: "Please review the latest diff.", @@ -424,25 +424,62 @@ test("injectCodexMessage sends the bridge handshake and notification opt-outs", }); }); -test("injectCodexMessage waits for turn completion before resolving", async () => { +test("injectCodexMessage steers an active turn instead of starting a new turn", async () => { const appServer = await getModule(); - let finishTurn: (() => void) | undefined; currentHandler = (request, write) => { if (request.method === "initialize") { write({ id: request.id, result: {} }); return; } + if (request.method === "thread/read") { + write({ + id: request.id, + result: { + thread: { + turns: [{ id: "turn-active", status: "inProgress" }], + }, + }, + }); + return; + } + if (request.method === "turn/steer") { + write({ id: request.id, result: { turnId: "turn-active" } }); + } + }; + + await expect( + appServer.injectCodexMessage( + "ws://127.0.0.1:4500", + "thread-1", + "Please review the final diff." + ) + ).resolves.toBe(true); + const frames = wsWrites.map((line) => JSON.parse(line) as RequestFrame); + expect(frames.map((frame) => frame.method)).toEqual([ + "initialize", + "initialized", + "thread/read", + "turn/steer", + ]); + expect(frames[3]?.params).toMatchObject({ + expectedTurnId: "turn-active", + threadId: "thread-1", + }); +}); + +test("injectCodexMessage returns once turn/start is accepted", async () => { + const appServer = await getModule(); + currentHandler = (request, write) => { + if (request.method === "initialize") { + write({ id: request.id, result: {} }); + return; + } + if (request.method === "thread/read") { + write({ id: request.id, result: { thread: { turns: [] } } }); + return; + } if (request.method === "turn/start") { write({ id: request.id, result: { turn: { id: "turn-1" } } }); - finishTurn = () => { - write({ - method: "turn/completed", - params: { - turnId: "turn-1", - turn: { id: "turn-1", status: "completed" }, - }, - }); - }; } }; @@ -459,36 +496,73 @@ test("injectCodexMessage waits for turn completion before resolving", async () = }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(resolved).toBe(false); - - finishTurn?.(); - - await expect(pending).resolves.toBe(true); expect(resolved).toBe(true); + await expect(pending).resolves.toBe(true); }); -test("injectCodexMessage rejects failed injected turns", async () => { +test("injectCodexMessage retries a busy turn/start with turn/steer", async () => { const appServer = await getModule(); + let threadReadCount = 0; currentHandler = (request, write) => { if (request.method === "initialize") { write({ id: request.id, result: {} }); return; } - if (request.method === "turn/start") { - write({ id: request.id, result: { turn: { id: "turn-1" } } }); - queueMicrotask(() => { - write({ - method: "turn/completed", - params: { - turnId: "turn-1", - turn: { - error: { message: "policy blocked" }, - id: "turn-1", - status: "failed", - }, + if (request.method === "thread/read") { + threadReadCount += 1; + write({ + id: request.id, + result: { + thread: { + turns: + threadReadCount === 1 + ? [] + : [{ id: "turn-active", status: "inProgress" }], }, - }); + }, }); + return; + } + if (request.method === "turn/start") { + write({ id: request.id, error: { message: "turn still active" } }); + return; + } + if (request.method === "turn/steer") { + write({ id: request.id, result: { turnId: "turn-active" } }); + } + }; + + await expect( + appServer.injectCodexMessage( + "ws://127.0.0.1:4500", + "thread-1", + "Please review the final diff." + ) + ).resolves.toBe(true); + const frames = wsWrites.map((line) => JSON.parse(line) as RequestFrame); + expect(frames.map((frame) => frame.method)).toEqual([ + "initialize", + "initialized", + "thread/read", + "turn/start", + "thread/read", + "turn/steer", + ]); +}); + +test("injectCodexMessage rejects failed turn/start requests", async () => { + const appServer = await getModule(); + currentHandler = (request, write) => { + if (request.method === "initialize") { + write({ id: request.id, result: {} }); + return; + } + if (request.method === "thread/read") { + write({ id: request.id, result: { thread: { turns: [] } } }); + return; + } + if (request.method === "turn/start") { + write({ id: request.id, error: { message: "policy blocked" } }); } }; @@ -1188,17 +1262,12 @@ test("injectCodexMessage sends initialized and bridge notification opt-outs", as write({ id: request.id, result: {} }); return; } + if (request.method === "thread/read") { + write({ id: request.id, result: { thread: { turns: [] } } }); + return; + } if (request.method === "turn/start") { write({ id: request.id, result: { turn: { id: "turn-1" } } }); - queueMicrotask(() => { - write({ - method: "turn/completed", - params: { - turnId: "turn-1", - turn: { id: "turn-1", status: "completed" }, - }, - }); - }); } }; @@ -1227,6 +1296,13 @@ test("injectCodexMessage sends initialized and bridge notification opt-outs", as method: "initialized", }); expect(frames[2]).toMatchObject({ + method: "thread/read", + params: { + includeTurns: true, + threadId: "thread-1", + }, + }); + expect(frames[3]).toMatchObject({ method: "turn/start", params: { threadId: "thread-1", From edef371622b24909f5968ebf39da2694b4c70d57 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 14:46:38 -0700 Subject: [PATCH 07/12] Deliver Codex bridge messages during live tmux turns --- src/loop/bridge-runtime.ts | 21 +++--- src/loop/codex-tmux-proxy.ts | 112 ++++++++++++++++++++++++---- tests/loop/bridge.test.ts | 60 ++++++++++++++- tests/loop/codex-tmux-proxy.test.ts | 92 +++++++++++++++++++++++ 4 files changed, 257 insertions(+), 28 deletions(-) diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index 69ac264..d7bea11 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -197,10 +197,10 @@ export const readBridgeRuntimeStatus = ( status.tmuxSession && tmuxSessionExists(status.tmuxSession) ); let codexDeliveryMode: BridgeRuntimeStatus["codexDeliveryMode"] = "none"; - if (hasLiveTmuxSession) { - codexDeliveryMode = "tmux"; - } else if (status.hasCodexRemote) { + if (status.hasCodexRemote) { codexDeliveryMode = "app-server"; + } else if (hasLiveTmuxSession) { + codexDeliveryMode = "tmux"; } return { ...status, @@ -321,10 +321,7 @@ export const deliverCodexBridgeMessage = async ( message: BridgeMessage ): Promise => { const status = readBridgeRuntimeStatus(runDir); - if (status.hasLiveTmuxSession) { - return false; - } - if (status.tmuxSession) { + if (status.tmuxSession && !status.hasLiveTmuxSession) { clearStaleTmuxBridgeState(runDir); } if (!status.hasCodexRemote) { @@ -379,7 +376,7 @@ export const drainCodexAppServerMessages = ( runDir: string ): Promise => { const status = readBridgeRuntimeStatus(runDir); - if (status.hasLiveTmuxSession || !status.hasCodexRemote) { + if (!status.hasCodexRemote) { return Promise.resolve(false); } const message = readNextPendingBridgeMessageForTarget(runDir, "codex"); @@ -405,10 +402,10 @@ export const runBridgeWorker = async (runDir: string): Promise => { clearStaleTmuxBridgeState(runDir); return; } - const delivered = status.hasLiveTmuxSession - ? await drainCodexTmuxMessages(runDir) - : await drainCodexAppServerMessages(runDir); - if (!(status.hasLiveTmuxSession || status.hasCodexRemote)) { + const delivered = status.hasCodexRemote + ? await drainCodexAppServerMessages(runDir) + : await drainCodexTmuxMessages(runDir); + if (!(status.hasCodexRemote || status.hasLiveTmuxSession)) { return; } await wait( diff --git a/src/loop/codex-tmux-proxy.ts b/src/loop/codex-tmux-proxy.ts index d4ada35..e47cd1c 100644 --- a/src/loop/codex-tmux-proxy.ts +++ b/src/loop/codex-tmux-proxy.ts @@ -23,6 +23,7 @@ const THREAD_START_METHOD = "thread/start"; const TURN_COMPLETED_METHOD = "turn/completed"; const TURN_STARTED_METHOD = "turn/started"; const TURN_START_METHOD = "turn/start"; +const TURN_STEER_METHOD = "turn/steer"; const USER_INPUT_TEXT_ELEMENTS = "text_elements"; export const CODEX_TMUX_PROXY_SUBCOMMAND = "__codex-tmux-proxy"; @@ -46,6 +47,11 @@ interface ProxyRoute { threadId?: string; } +interface BridgeRequest { + message: BridgeMessage; + method: string; +} + type StopReason = "dead-tmux" | "inactive-run"; const isRecord = (value: unknown): value is Record => @@ -112,6 +118,59 @@ const extractThreadId = (value: unknown): string | undefined => { return asString(thread?.id) ?? asString(value.threadId); }; +const latestActiveTurnId = (turnIds: Set): string | undefined => { + let latest: string | undefined; + for (const turnId of turnIds) { + latest = turnId; + } + return latest; +}; + +const shouldPauseBridgeDrain = ( + turnInProgress: boolean, + activeTurnId: string | undefined, + pendingBridgeRequests: number +): boolean => { + if (pendingBridgeRequests > 0) { + return true; + } + return turnInProgress && !activeTurnId; +}; + +const buildBridgeInjectionFrame = ( + requestId: number, + threadId: string, + message: BridgeMessage, + activeTurnId?: string +): JsonFrame => { + if (activeTurnId) { + return { + id: requestId, + method: TURN_STEER_METHOD, + params: { + expectedTurnId: activeTurnId, + input: buildInput(message.message), + threadId, + }, + }; + } + return { + id: requestId, + method: TURN_START_METHOD, + params: { + input: buildInput(message.message), + threadId, + }, + }; +}; + +const noteStartedTurn = (turnIds: Set, value: unknown): void => { + const turnId = extractTurnId(value); + if (turnId) { + turnIds.add(turnId); + } +}; + const isTmuxSessionAlive = (session: string): boolean => { if (!session) { return false; @@ -156,7 +215,7 @@ const patchInitializeError = (frame: JsonFrame): JsonFrame => { class CodexTmuxProxy { private readonly activeTurnIds = new Set(); - private readonly bridgeRequests = new Map(); + private readonly bridgeRequests = new Map(); private readonly port: number; private readonly remoteUrl: string; private readonly routes = new Map(); @@ -382,20 +441,30 @@ class CodexTmuxProxy { if (!frame.error && route.method === TURN_START_METHOD) { this.threadId = route.threadId ?? this.threadId; + noteStartedTurn(this.activeTurnIds, frame.result); + this.turnInProgress = true; } } private handleBridgeResponse(id: number, frame: JsonFrame): void { - const message = this.bridgeRequests.get(id); - if (!message) { + const request = this.bridgeRequests.get(id); + if (!request) { return; } this.bridgeRequests.delete(id); if (frame.error) { - this.turnInProgress = false; + this.turnInProgress = this.activeTurnIds.size > 0; return; } - acknowledgeBridgeDelivery(this.runDir, message, "sent to codex tmux proxy"); + if (request.method === TURN_START_METHOD) { + noteStartedTurn(this.activeTurnIds, frame.result); + this.turnInProgress = true; + } + acknowledgeBridgeDelivery( + this.runDir, + request.message, + "sent to codex tmux proxy" + ); } private handleNotification(frame: JsonFrame): void { @@ -458,7 +527,14 @@ class CodexTmuxProxy { ) { return; } - if (this.turnInProgress || this.bridgeRequests.size > 0) { + const activeTurnId = latestActiveTurnId(this.activeTurnIds); + if ( + shouldPauseBridgeDrain( + this.turnInProgress, + activeTurnId, + this.bridgeRequests.size + ) + ) { return; } const message = readNextPendingBridgeMessageForTarget(this.runDir, "codex"); @@ -467,16 +543,18 @@ class CodexTmuxProxy { } const requestId = this.nextBridgeRequestId--; - this.bridgeRequests.set(requestId, message); - this.turnInProgress = true; - this.forwardToUpstream({ - id: requestId, - method: TURN_START_METHOD, - params: { - input: buildInput(message.message), - threadId: this.threadId, - }, + const frame = buildBridgeInjectionFrame( + requestId, + this.threadId, + message, + activeTurnId + ); + this.bridgeRequests.set(requestId, { + message, + method: frame.method ?? TURN_START_METHOD, }); + this.turnInProgress = true; + this.forwardToUpstream(frame); } } @@ -516,7 +594,11 @@ export const runCodexTmuxProxy = async ( }; export const codexTmuxProxyInternals = { + buildBridgeInjectionFrame, + latestActiveTurnId, buildProxyUrl, + noteStartedTurn, patchInitializeError, + shouldPauseBridgeDrain, shouldStopForTmuxSession, }; diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 598ff5b..5f4ebb5 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -302,7 +302,7 @@ test("readBridgeRuntimeStatus distinguishes live and stale tmux delivery", async expect(bridge.readBridgeRuntimeStatus(liveRunDir)).toMatchObject({ claudeBridgeMode: "local-registration", claudeChannelServer: bridge.claudeChannelServerName("8", "repo-123"), - codexDeliveryMode: "tmux", + codexDeliveryMode: "app-server", hasCodexRemote: true, hasLiveTmuxSession: true, hasTmuxSession: true, @@ -1041,6 +1041,64 @@ test("bridge delivers Claude replies directly to Codex when app-server state is rmSync(root, { recursive: true, force: true }); }); +test("bridge prefers Codex app-server delivery even when tmux is live", async () => { + const injectCodexMessage = mock(async () => true); + const spawnSync = mock((args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 0 }; + } + return { exitCode: 1 }; + }); + const bridge = await loadBridge({ injectCodexMessage }); + bridge.bridgeRuntimeCommandDeps.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: "7", + status: "running", + tmuxSession: "repo-loop-7", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + const message = { + at: "2026-03-23T10:01:00.000Z", + id: "msg-live", + kind: "message" as const, + message: "Please steer this into the active turn.", + source: "claude" as const, + target: "codex" as const, + }; + + bridge.bridgeInternals.appendBridgeEvent(runDir, message); + const delivered = await bridge.deliverCodexBridgeMessage(runDir, message); + + expect(delivered).toBe(true); + expect(injectCodexMessage).toHaveBeenCalledWith( + "ws://127.0.0.1:4500", + "codex-thread-1", + "Please steer this into the active turn." + ); + expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); + expect( + bridge.bridgeInternals + .readBridgeEvents(runDir) + .filter((event) => event.kind === "delivered") + ).toHaveLength(1); + + rmSync(root, { recursive: true, force: true }); +}); + 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[]) => { diff --git a/tests/loop/codex-tmux-proxy.test.ts b/tests/loop/codex-tmux-proxy.test.ts index 0a73886..ee2aab8 100644 --- a/tests/loop/codex-tmux-proxy.test.ts +++ b/tests/loop/codex-tmux-proxy.test.ts @@ -1,6 +1,15 @@ import { expect, test } from "bun:test"; import { codexTmuxProxyInternals } from "../../src/loop/codex-tmux-proxy"; +const bridgeMessage = { + at: "2026-03-29T00:00:00.000Z", + id: "msg-1", + kind: "message" as const, + message: "Please review the latest diff.", + source: "claude" as const, + target: "codex" as const, +}; + test("codex tmux proxy waits briefly for the tmux session to appear", () => { const now = Date.now(); @@ -42,3 +51,86 @@ test("codex tmux proxy stops immediately after a seen tmux session disappears", ) ).toBe(false); }); + +test("codex tmux proxy records turn ids from turn/start responses", () => { + const turnIds = new Set(["turn-1"]); + + codexTmuxProxyInternals.noteStartedTurn(turnIds, { + turn: { id: "turn-2" }, + }); + + expect([...turnIds]).toEqual(["turn-1", "turn-2"]); + expect(codexTmuxProxyInternals.latestActiveTurnId(turnIds)).toBe("turn-2"); +}); + +test("codex tmux proxy keeps the newest active turn id", () => { + const activeTurns = new Set(["turn-1", "turn-2"]); + + expect(codexTmuxProxyInternals.latestActiveTurnId(activeTurns)).toBe( + "turn-2" + ); + expect(codexTmuxProxyInternals.latestActiveTurnId(new Set())).toBe(undefined); +}); + +test("codex tmux proxy steers bridge messages into an active turn", () => { + expect( + codexTmuxProxyInternals.buildBridgeInjectionFrame( + -1, + "thread-1", + bridgeMessage, + "turn-active" + ) + ).toEqual({ + id: -1, + method: "turn/steer", + params: { + expectedTurnId: "turn-active", + input: [ + { + text: "Please review the latest diff.", + text_elements: [], + type: "text", + }, + ], + threadId: "thread-1", + }, + }); +}); + +test("codex tmux proxy starts a new turn when no active turn exists", () => { + expect( + codexTmuxProxyInternals.buildBridgeInjectionFrame( + -1, + "thread-1", + bridgeMessage + ) + ).toEqual({ + id: -1, + method: "turn/start", + params: { + input: [ + { + text: "Please review the latest diff.", + text_elements: [], + type: "text", + }, + ], + threadId: "thread-1", + }, + }); +}); + +test("codex tmux proxy only pauses bridge drain when it cannot steer", () => { + expect( + codexTmuxProxyInternals.shouldPauseBridgeDrain(false, undefined, 0) + ).toBe(false); + expect( + codexTmuxProxyInternals.shouldPauseBridgeDrain(true, "turn-active", 0) + ).toBe(false); + expect( + codexTmuxProxyInternals.shouldPauseBridgeDrain(true, undefined, 0) + ).toBe(true); + expect( + codexTmuxProxyInternals.shouldPauseBridgeDrain(false, undefined, 1) + ).toBe(true); +}); From 814ef4063736b2514513eae87f5ef43849dc26eb Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 15:09:29 -0700 Subject: [PATCH 08/12] Sync bridge delivery with the live Codex thread --- src/loop/codex-tmux-proxy.ts | 41 ++++++++++++++++++++++++++--- tests/loop/codex-tmux-proxy.test.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/loop/codex-tmux-proxy.ts b/src/loop/codex-tmux-proxy.ts index e47cd1c..669f37e 100644 --- a/src/loop/codex-tmux-proxy.ts +++ b/src/loop/codex-tmux-proxy.ts @@ -8,7 +8,12 @@ import { import { clearStaleTmuxBridgeState } from "./bridge-runtime"; import type { BridgeMessage } from "./bridge-store"; import { findFreePort } from "./ports"; -import { isActiveRunState, readRunManifest } from "./run-state"; +import { + isActiveRunState, + readRunManifest, + touchRunManifest, + updateRunManifest, +} from "./run-state"; import { connectWs, type WsClient } from "./ws-client"; const CODEX_PROXY_BASE_PORT = 4600; @@ -137,6 +142,24 @@ const shouldPauseBridgeDrain = ( return turnInProgress && !activeTurnId; }; +const persistCodexThreadId = (runDir: string, threadId: string): void => { + if (!threadId) { + return; + } + updateRunManifest(join(runDir, "manifest.json"), (manifest) => { + if (!manifest || manifest.codexThreadId === threadId) { + return manifest; + } + return touchRunManifest( + { + ...manifest, + codexThreadId: threadId, + }, + new Date().toISOString() + ); + }); +}; + const buildBridgeInjectionFrame = ( requestId: number, threadId: string, @@ -339,6 +362,14 @@ class CodexTmuxProxy { this.upstream?.send(`${JSON.stringify(frame)}\n`); } + private rememberThreadId(threadId: string | undefined): void { + if (!threadId || threadId === this.threadId) { + return; + } + this.threadId = threadId; + persistCodexThreadId(this.runDir, threadId); + } + private handleTuiFrame(raw: string): void { const frame = asJsonFrame(raw); if (!(frame?.method && frame.id !== undefined)) { @@ -434,13 +465,14 @@ class CodexTmuxProxy { (route.method === THREAD_START_METHOD || route.method === THREAD_RESUME_METHOD) ) { - this.threadId = - extractThreadId(frame.result) ?? route.threadId ?? this.threadId; + this.rememberThreadId( + extractThreadId(frame.result) ?? route.threadId ?? this.threadId + ); return; } if (!frame.error && route.method === TURN_START_METHOD) { - this.threadId = route.threadId ?? this.threadId; + this.rememberThreadId(route.threadId ?? this.threadId); noteStartedTurn(this.activeTurnIds, frame.result); this.turnInProgress = true; } @@ -599,6 +631,7 @@ export const codexTmuxProxyInternals = { buildProxyUrl, noteStartedTurn, patchInitializeError, + persistCodexThreadId, shouldPauseBridgeDrain, shouldStopForTmuxSession, }; diff --git a/tests/loop/codex-tmux-proxy.test.ts b/tests/loop/codex-tmux-proxy.test.ts index ee2aab8..4bb4a94 100644 --- a/tests/loop/codex-tmux-proxy.test.ts +++ b/tests/loop/codex-tmux-proxy.test.ts @@ -1,5 +1,13 @@ import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { codexTmuxProxyInternals } from "../../src/loop/codex-tmux-proxy"; +import { + createRunManifest, + readRunManifest, + writeRunManifest, +} from "../../src/loop/run-state"; const bridgeMessage = { at: "2026-03-29T00:00:00.000Z", @@ -10,6 +18,8 @@ const bridgeMessage = { target: "codex" as const, }; +const makeTempDir = (): string => mkdtempSync(join(tmpdir(), "loop-proxy-")); + test("codex tmux proxy waits briefly for the tmux session to appear", () => { const now = Date.now(); @@ -134,3 +144,30 @@ test("codex tmux proxy only pauses bridge drain when it cannot steer", () => { codexTmuxProxyInternals.shouldPauseBridgeDrain(false, undefined, 1) ).toBe(true); }); + +test("codex tmux proxy persists newer live thread ids to the run manifest", () => { + const root = makeTempDir(); + const manifestPath = join(root, "manifest.json"); + writeRunManifest( + manifestPath, + createRunManifest({ + claudeSessionId: "claude-1", + codexRemoteUrl: "ws://127.0.0.1:4500", + codexThreadId: "codex-thread-startup", + cwd: "/repo", + mode: "paired", + pid: 1234, + repoId: "repo-123", + runId: "7", + tmuxSession: "loop-loop-7", + }) + ); + + codexTmuxProxyInternals.persistCodexThreadId(root, "codex-thread-live"); + + expect(readRunManifest(manifestPath)?.codexThreadId).toBe( + "codex-thread-live" + ); + + rmSync(root, { recursive: true, force: true }); +}); From 04203d3f7f0c142cb5733bc5a8fbcb23f566f032 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 17:18:19 -0700 Subject: [PATCH 09/12] Prefix Claude bridge messages for Codex --- src/loop/bridge-message-format.ts | 18 +++++ src/loop/bridge-runtime.ts | 5 +- src/loop/bridge-store.ts | 6 +- src/loop/codex-tmux-proxy.ts | 9 ++- src/loop/paired-loop.ts | 23 ++++-- tests/loop/bridge.test.ts | 114 ++++++++++++++++++++-------- tests/loop/codex-tmux-proxy.test.ts | 4 +- tests/loop/paired-loop.test.ts | 8 +- 8 files changed, 131 insertions(+), 56 deletions(-) create mode 100644 src/loop/bridge-message-format.ts diff --git a/src/loop/bridge-message-format.ts b/src/loop/bridge-message-format.ts new file mode 100644 index 0000000..35a6408 --- /dev/null +++ b/src/loop/bridge-message-format.ts @@ -0,0 +1,18 @@ +import type { Agent } from "./types"; + +const BRIDGE_PREFIX_RE = + /^(?:Message from (?:Claude|Codex) via the loop bridge:|(?:Claude|Codex):)\s*/i; + +export const formatCodexBridgeMessage = ( + source: Agent, + message: string +): string => { + const trimmed = message.trim(); + if (!trimmed) { + return ""; + } + return source === "claude" ? `Claude: ${trimmed}` : trimmed; +}; + +export const normalizeBridgeMessage = (message: string): string => + message.trim().replace(BRIDGE_PREFIX_RE, "").replace(/\s+/g, " "); diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index d7bea11..d6c11be 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -15,6 +15,7 @@ import { bridgeChatId, readNextPendingBridgeMessageForTarget, } from "./bridge-dispatch"; +import { formatCodexBridgeMessage } from "./bridge-message-format"; import { type BridgeMessage, type BridgeStatus, @@ -331,7 +332,7 @@ export const deliverCodexBridgeMessage = async ( const delivered = await injectCodexMessage( status.codexRemoteUrl, status.codexThreadId, - message.message + formatCodexBridgeMessage(message.source, message.message) ); if (delivered) { acknowledgeBridgeDelivery( @@ -363,7 +364,7 @@ export const drainCodexTmuxMessages = async ( } const delivered = await injectCodexTmuxMessage( status.tmuxSession, - message.message + formatCodexBridgeMessage(message.source, message.message) ); if (!delivered) { return false; diff --git a/src/loop/bridge-store.ts b/src/loop/bridge-store.ts index a38d0a2..fdb2dcc 100644 --- a/src/loop/bridge-store.ts +++ b/src/loop/bridge-store.ts @@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { claudeChannelServerName } from "./bridge-config"; import { BRIDGE_SERVER } from "./bridge-constants"; +import { normalizeBridgeMessage } from "./bridge-message-format"; import { appendRunTranscriptEntry, buildTranscriptPath, @@ -13,8 +14,6 @@ import type { Agent } from "./types"; const BRIDGE_FILE = "bridge.jsonl"; const LINE_SPLIT_RE = /\r?\n/; const MAX_STATUS_MESSAGES = 100; -const BRIDGE_PREFIX_RE = - /^Message from (Claude|Codex) via the loop bridge:\s*/i; interface BridgeBaseEvent { at: string; @@ -66,9 +65,6 @@ export const normalizeAgent = (value: unknown): Agent | undefined => { return undefined; }; -const normalizeBridgeMessage = (message: string): string => - message.trim().replace(BRIDGE_PREFIX_RE, "").replace(/\s+/g, " "); - const orderedBridgePairKey = (source: Agent, target: Agent): string => `${source}>${target}`; diff --git a/src/loop/codex-tmux-proxy.ts b/src/loop/codex-tmux-proxy.ts index 669f37e..bb6c8e0 100644 --- a/src/loop/codex-tmux-proxy.ts +++ b/src/loop/codex-tmux-proxy.ts @@ -5,6 +5,7 @@ import { acknowledgeBridgeDelivery, readNextPendingBridgeMessageForTarget, } from "./bridge-dispatch"; +import { formatCodexBridgeMessage } from "./bridge-message-format"; import { clearStaleTmuxBridgeState } from "./bridge-runtime"; import type { BridgeMessage } from "./bridge-store"; import { findFreePort } from "./ports"; @@ -172,7 +173,9 @@ const buildBridgeInjectionFrame = ( method: TURN_STEER_METHOD, params: { expectedTurnId: activeTurnId, - input: buildInput(message.message), + input: buildInput( + formatCodexBridgeMessage(message.source, message.message) + ), threadId, }, }; @@ -181,7 +184,9 @@ const buildBridgeInjectionFrame = ( id: requestId, method: TURN_START_METHOD, params: { - input: buildInput(message.message), + input: buildInput( + formatCodexBridgeMessage(message.source, message.message) + ), threadId, }, }; diff --git a/src/loop/paired-loop.ts b/src/loop/paired-loop.ts index 0455c20..0c5a414 100644 --- a/src/loop/paired-loop.ts +++ b/src/loop/paired-loop.ts @@ -3,6 +3,7 @@ import { acknowledgeBridgeDelivery, readNextPendingBridgeMessage, } from "./bridge-dispatch"; +import { formatCodexBridgeMessage } from "./bridge-message-format"; import { getLastClaudeSessionId } from "./claude-sdk-server"; import { getLastCodexThreadId } from "./codex-app-server"; import { @@ -151,13 +152,21 @@ const reviewBridgePrompt = ( .join("\n\n"); 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. Do not reply to the human.", - 'Send a message 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"); + (source === "claude" + ? [ + formatCodexBridgeMessage(source, message), + "Treat this as direct agent-to-agent coordination. Do not reply to the human.", + 'Send a message 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.", + ] + : [ + `Message from ${capitalize(source)} via the loop bridge:`, + message.trim(), + "Treat this as direct agent-to-agent coordination. Do not reply to the human.", + 'Send a message 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 => { const next = touchRunManifest( diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 5f4ebb5..344a1f8 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -378,6 +378,50 @@ test("readPendingBridgeMessages keeps repeated messages until each is acknowledg rmSync(root, { recursive: true, force: true }); }); +test("bridge normalization treats short and legacy Claude prefixes as equivalent", async () => { + const bridge = await loadBridge(); + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + const bridgeFile = bridge.bridgeInternals.bridgePath(runDir); + + writeFileSync( + bridgeFile, + `${[ + { + at: "2026-03-22T10:00:00.000Z", + id: "msg-1", + kind: "message", + message: + "Message from Claude via the loop bridge:\n\nPlease verify the final diff.", + source: "claude", + target: "codex", + }, + { + at: "2026-03-22T10:01:00.000Z", + id: "msg-1", + kind: "delivered", + source: "claude", + target: "codex", + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n")}\n`, + "utf8" + ); + + expect( + bridge.blocksBridgeBounce( + runDir, + "codex", + "claude", + "Claude: Please verify the final diff." + ) + ).toBe(true); + + rmSync(root, { recursive: true, force: true }); +}); + test("bridge MCP send_to_agent queues a direct message through the CLI path", async () => { const bridge = await loadBridge(); const root = makeTempDir(); @@ -1029,7 +1073,7 @@ test("bridge delivers Claude replies directly to Codex when app-server state is expect(injectCodexMessage).toHaveBeenCalledWith( "ws://127.0.0.1:4500", "codex-thread-1", - "The files look good to me." + "Claude: The files look good to me." ); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); expect( @@ -1087,7 +1131,7 @@ test("bridge prefers Codex app-server delivery even when tmux is live", async () expect(injectCodexMessage).toHaveBeenCalledWith( "ws://127.0.0.1:4500", "codex-thread-1", - "Please steer this into the active turn." + "Claude: Please steer this into the active turn." ); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); expect( @@ -1148,7 +1192,7 @@ test("bridge falls back to direct Codex delivery when the stored tmux session is expect(injectCodexMessage).toHaveBeenCalledWith( "ws://127.0.0.1:4500", "codex-thread-1", - "Please review the final state." + "Claude: Please review the final state." ); expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( undefined @@ -1224,34 +1268,32 @@ test("bridge drains pending codex tmux messages through the injected command dep expect(delivered).toBe(true); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); - expect(spawnSync.mock.calls).toEqual( - expect.arrayContaining([ - [ - ["tmux", "has-session", "-t", "repo-loop-8"], - expect.objectContaining({ stderr: "ignore", stdout: "ignore" }), - ], - [ - ["tmux", "capture-pane", "-p", "-t", "repo-loop-8:0.1"], - expect.objectContaining({ stderr: "ignore", stdout: "pipe" }), - ], - [ - [ - "tmux", - "send-keys", - "-t", - "repo-loop-8:0.1", - "-l", - "--", - "Please check the tmux path.", - ], - expect.objectContaining({ stderr: "ignore" }), - ], + expect(spawnSync.mock.calls).toEqual([ + [ + ["tmux", "has-session", "-t", "repo-loop-8"], + { stderr: "ignore", stdout: "ignore" }, + ], + [ + ["tmux", "capture-pane", "-p", "-t", "repo-loop-8:0.1"], + { stderr: "ignore", stdout: "pipe" }, + ], + [ [ - ["tmux", "send-keys", "-t", "repo-loop-8:0.1", "Enter"], - expect.objectContaining({ stderr: "ignore" }), + "tmux", + "send-keys", + "-t", + "repo-loop-8:0.1", + "-l", + "--", + "Claude: Please check the tmux path.", ], - ]) - ); + { stderr: "ignore" }, + ], + [ + ["tmux", "send-keys", "-t", "repo-loop-8:0.1", "Enter"], + { stderr: "ignore" }, + ], + ]); rmSync(root, { recursive: true, force: true }); }); @@ -1541,8 +1583,16 @@ test("runBridgeWorker retries queued codex app-server messages", async () => { expect(injectCodexMessage).toHaveBeenCalledTimes(2); expect(injectCodexMessage.mock.calls).toEqual([ - ["ws://127.0.0.1:4500", "codex-thread-1", "Please review the final diff."], - ["ws://127.0.0.1:4500", "codex-thread-1", "Please review the final diff."], + [ + "ws://127.0.0.1:4500", + "codex-thread-1", + "Claude: Please review the final diff.", + ], + [ + "ws://127.0.0.1:4500", + "codex-thread-1", + "Claude: Please review the final diff.", + ], ]); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); expect( @@ -1949,7 +1999,7 @@ test("dispatchBridgeMessage reports delivered when direct codex delivery succeed expect(injectCodexMessage).toHaveBeenCalledWith( "ws://127.0.0.1:4500", "codex-thread-1", - "Please review the final diff." + "Claude: Please review the final diff." ); expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); diff --git a/tests/loop/codex-tmux-proxy.test.ts b/tests/loop/codex-tmux-proxy.test.ts index 4bb4a94..92fe25c 100644 --- a/tests/loop/codex-tmux-proxy.test.ts +++ b/tests/loop/codex-tmux-proxy.test.ts @@ -97,7 +97,7 @@ test("codex tmux proxy steers bridge messages into an active turn", () => { expectedTurnId: "turn-active", input: [ { - text: "Please review the latest diff.", + text: "Claude: Please review the latest diff.", text_elements: [], type: "text", }, @@ -120,7 +120,7 @@ test("codex tmux proxy starts a new turn when no active turn exists", () => { params: { input: [ { - text: "Please review the latest diff.", + text: "Claude: Please review the latest diff.", text_elements: [], type: "text", }, diff --git a/tests/loop/paired-loop.test.ts b/tests/loop/paired-loop.test.ts index 333c5e9..550b8bf 100644 --- a/tests/loop/paired-loop.test.ts +++ b/tests/loop/paired-loop.test.ts @@ -772,9 +772,7 @@ test("runPairedLoop delivers peer messages back to the primary agent", async () expect(calls).toHaveLength(3); expect(calls[0]?.agent).toBe("claude"); expect(calls[1]?.agent).toBe("codex"); - expect(calls[1]?.prompt).toContain( - "Message from Claude via the loop bridge:" - ); + expect(calls[1]?.prompt).toContain("Claude: Please verify"); expect(calls[1]?.prompt).toContain( "Please verify the implementation details." ); @@ -816,9 +814,7 @@ test("runPairedLoop skips the default work turn after draining input for the pri expect(calls).toHaveLength(1); expect(calls[0]?.agent).toBe("codex"); - expect(calls[0]?.prompt).toContain( - "Message from Claude via the loop bridge:" - ); + expect(calls[0]?.prompt).toContain("Claude: Please verify"); expect(calls[0]?.prompt).toContain( "Please verify the implementation details." ); From 7243731ac596867199920b87142c2a9fac7ec210 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 18:50:25 -0700 Subject: [PATCH 10/12] Fix stale tmux bridge worker fallback --- src/loop/bridge-runtime.ts | 17 +++++- tests/loop/bridge.test.ts | 118 +++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index d6c11be..dfeabdf 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -257,9 +257,10 @@ export const clearStaleTmuxBridgeState = (runDir: string): boolean => { return manifest; } removedServerNames = [ + manifest.claudeChannelServer, claudeChannelServerName(manifest.runId, manifest.repoId), legacyClaudeChannelServerName(manifest.runId), - ]; + ].filter((name): name is string => Boolean(name)); return touchRunManifest( { ...manifest, @@ -387,6 +388,17 @@ export const drainCodexAppServerMessages = ( return deliverCodexBridgeMessage(runDir, message); }; +const clearStaleTmuxWorkerState = ( + runDir: string, + status: BridgeRuntimeStatus +): boolean => { + if (!(status.tmuxSession && !status.hasLiveTmuxSession)) { + return true; + } + clearStaleTmuxBridgeState(runDir); + return status.hasCodexRemote; +}; + export const runBridgeWorker = async (runDir: string): Promise => { try { while (true) { @@ -399,8 +411,7 @@ export const runBridgeWorker = async (runDir: string): Promise => { if (!(state && isActiveRunState(state))) { return; } - if (status.tmuxSession && !status.hasLiveTmuxSession) { - clearStaleTmuxBridgeState(runDir); + if (!clearStaleTmuxWorkerState(runDir, status)) { return; } const delivered = status.hasCodexRemote diff --git a/tests/loop/bridge.test.ts b/tests/loop/bridge.test.ts index 344a1f8..c26ad88 100644 --- a/tests/loop/bridge.test.ts +++ b/tests/loop/bridge.test.ts @@ -1424,6 +1424,52 @@ test("bridge stale tmux cleanup logs thrown Claude MCP remove errors", async () } }); +test("bridge stale tmux cleanup removes a persisted Claude server name", async () => { + const spawnSync = mock((args: string[]) => { + if (args[0] === "claude" && args[1] === "mcp" && args[2] === "remove") { + return { exitCode: 0, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + } + return { exitCode: 0, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + }); + const bridge = await loadBridge(); + bridge.bridgeRuntimeCommandDeps.spawnSync = spawnSync; + const root = makeTempDir(); + const runDir = join(root, "run"); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "manifest.json"), + `${JSON.stringify({ + claudeChannelServer: "loop-bridge-custom-8", + 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" + ); + + expect(bridge.clearStaleTmuxBridgeState(runDir)).toBe(true); + expect( + spawnSync.mock.calls.filter( + (call) => call[0]?.[0] === "claude" && call[0]?.[2] === "remove" + ) + ).toEqual( + expect.arrayContaining([ + [ + ["claude", "mcp", "remove", "--scope", "local", "loop-bridge-custom-8"], + expect.objectContaining({ stderr: "pipe", stdout: "ignore" }), + ], + ]) + ); + + rmSync(root, { recursive: true, force: true }); +}); + test("runBridgeWorker clears stale tmux routing and exits", async () => { const spawnSync = mock((args: string[]) => { if (args[0] === "tmux" && args[1] === "has-session") { @@ -1484,6 +1530,78 @@ test("runBridgeWorker clears stale tmux routing and exits", async () => { rmSync(root, { recursive: true, force: true }); }); +test("runBridgeWorker falls back to app-server delivery after stale tmux cleanup", async () => { + let runDir = ""; + const injectCodexMessage = mock(() => { + const manifestPath = join(runDir, "manifest.json"); + const manifest = readRunManifest(manifestPath); + writeFileSync( + manifestPath, + `${JSON.stringify({ + ...manifest, + state: "completed", + status: "completed", + })}\n`, + "utf8" + ); + return true; + }); + const spawnSync = mock((args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 1, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + } + if (args[0] === "claude" && args[1] === "mcp" && args[2] === "remove") { + return { exitCode: 0, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + } + return { exitCode: 0, stderr: Buffer.alloc(0), stdout: Buffer.alloc(0) }; + }); + const bridge = await loadBridge({ injectCodexMessage }); + bridge.bridgeRuntimeCommandDeps.spawnSync = spawnSync; + const root = makeTempDir(); + 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", + state: "working", + status: "running", + tmuxSession: "repo-loop-8", + updatedAt: "2026-03-23T10:00:00.000Z", + })}\n`, + "utf8" + ); + bridge.bridgeInternals.appendBridgeEvent(runDir, { + at: "2026-03-23T10:01:00.000Z", + id: "msg-stale-fallback", + kind: "message", + message: "Please deliver this after tmux cleanup.", + source: "claude", + target: "codex", + }); + + await bridge.runBridgeWorker(runDir); + + expect(injectCodexMessage).toHaveBeenCalledWith( + "ws://127.0.0.1:4500", + "codex-thread-1", + "Claude: Please deliver this after tmux cleanup." + ); + expect(readRunManifest(join(runDir, "manifest.json"))?.tmuxSession).toBe( + undefined + ); + expect(bridge.readPendingBridgeMessages(runDir)).toEqual([]); + + rmSync(root, { recursive: true, force: true }); +}); + test("ensureBridgeWorker launches one app-server worker per active run", async () => { const bridge = await loadBridge(); const spawn = mock(() => ({ From 7b282a2694dd6a506a9acc6bd78cba991d908627 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 20:17:15 -0700 Subject: [PATCH 11/12] Simplify Claude bridge server names --- src/loop/bridge-config.ts | 53 +++++++++++++++++++++++-- src/loop/paired-options.ts | 13 ++++--- tests/loop/bridge-config.test.ts | 23 +++++++++++ tests/loop/paired-options.test.ts | 64 +++++++++++++++++++++++++++---- 4 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 tests/loop/bridge-config.test.ts diff --git a/src/loop/bridge-config.ts b/src/loop/bridge-config.ts index c04b8bd..193a7c0 100644 --- a/src/loop/bridge-config.ts +++ b/src/loop/bridge-config.ts @@ -11,6 +11,7 @@ const CODEX_AUTO_APPROVED_BRIDGE_TOOLS = [ "receive_messages", ] as const; const CODEX_BRIDGE_APPROVAL_MODE = "approve"; +const REPO_ID_HASH_SUFFIX_RE = /-[0-9a-f]{12}$/; const ensureParentDir = (path: string): void => { mkdirSync(dirname(path), { recursive: true }); @@ -49,13 +50,59 @@ const buildBridgeFileConfig = ( export const legacyClaudeChannelServerName = (runId: string): string => `${BRIDGE_SERVER}-${sanitizeBase(runId)}`; +const readableRepoSegment = (repoId?: string): string | undefined => { + const value = repoId?.trim(); + if (!value) { + return undefined; + } + return sanitizeBase(value.replace(REPO_ID_HASH_SUFFIX_RE, "")); +}; + +export const legacyRepoScopedClaudeChannelServerName = ( + runId: string, + repoId: string +): string => `${BRIDGE_SERVER}-${sanitizeBase(repoId)}-${sanitizeBase(runId)}`; + export const claudeChannelServerName = ( runId: string, repoId?: string -): string => - repoId?.trim() - ? `${BRIDGE_SERVER}-${sanitizeBase(repoId)}-${sanitizeBase(runId)}` +): string => { + const repoSegment = readableRepoSegment(repoId); + return repoSegment + ? `${BRIDGE_SERVER}-${repoSegment}-${sanitizeBase(runId)}` : legacyClaudeChannelServerName(runId); +}; + +export const generatedClaudeChannelServerNames = ( + runId: string, + repoId?: string +): string[] => { + const names = [legacyClaudeChannelServerName(runId)]; + if (!repoId?.trim()) { + return names; + } + return Array.from( + new Set([ + claudeChannelServerName(runId, repoId), + legacyRepoScopedClaudeChannelServerName(runId, repoId), + ...names, + ]) + ); +}; + +export const resolveClaudeChannelServerName = ( + runId: string, + repoId: string | undefined, + storedName?: string +): string => { + const nextServer = claudeChannelServerName(runId, repoId); + if (!storedName?.trim()) { + return nextServer; + } + return generatedClaudeChannelServerNames(runId, repoId).includes(storedName) + ? nextServer + : storedName; +}; export const buildClaudeChannelServerConfig = ( launchArgv: string[], diff --git a/src/loop/paired-options.ts b/src/loop/paired-options.ts index 1af1355..67644a4 100644 --- a/src/loop/paired-options.ts +++ b/src/loop/paired-options.ts @@ -1,7 +1,8 @@ import { buildCodexBridgeConfigArgs, - claudeChannelServerName, ensureClaudeBridgeConfig, + claudeChannelServerName, + resolveClaudeChannelServerName, } from "./bridge-config"; import { createRunManifest, @@ -43,12 +44,12 @@ export const canResumePairedManifest = (manifest?: RunManifest): boolean => { const resolveClaudeBridgeServer = ( storage: RunStorage, manifest?: RunManifest -): string => { - return ( - manifest?.claudeChannelServer ?? - claudeChannelServerName(storage.runId, storage.repoId) +): string => + resolveClaudeChannelServerName( + storage.runId, + storage.repoId, + manifest?.claudeChannelServer ); -}; const resolveRequestedRunState = ( opts: Options, diff --git a/tests/loop/bridge-config.test.ts b/tests/loop/bridge-config.test.ts new file mode 100644 index 0000000..0706834 --- /dev/null +++ b/tests/loop/bridge-config.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { + claudeChannelServerName, + legacyClaudeChannelServerName, +} from "../../src/loop/bridge-config"; + +test("claudeChannelServerName drops the repo hash suffix from storage ids", () => { + expect(claudeChannelServerName("55", "loop-0d5b6b77c881")).toBe( + "loop-bridge-loop-55" + ); +}); + +test("claudeChannelServerName preserves readable repo ids", () => { + expect(claudeChannelServerName("55", "repo-123")).toBe( + "loop-bridge-repo-123-55" + ); +}); + +test("claudeChannelServerName falls back to the legacy run-scoped name", () => { + expect(claudeChannelServerName("55")).toBe( + legacyClaudeChannelServerName("55") + ); +}); diff --git a/tests/loop/paired-options.test.ts b/tests/loop/paired-options.test.ts index 7cbf349..c06fe6e 100644 --- a/tests/loop/paired-options.test.ts +++ b/tests/loop/paired-options.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { claudeChannelServerName } from "../../src/loop/bridge-config"; import { preparePairedOptions, preparePairedRun, @@ -66,7 +67,7 @@ test("preparePairedOptions accepts a raw session id without creating a paired ma } }); -test("preparePairedOptions writes a repo-scoped Claude bridge server for fresh runs", () => { +test("preparePairedOptions writes a readable Claude bridge server for fresh runs", () => { const home = makeTempHome(); const originalHome = process.env.HOME; const originalRunId = process.env.LOOP_RUN_ID; @@ -80,15 +81,64 @@ test("preparePairedOptions writes a repo-scoped Claude bridge server for fresh r const storage = resolveRunStorage("1", process.cwd(), home); const manifest = readRunManifest(storage.manifestPath); - expect(manifest?.claudeChannelServer).toBe( - `loop-bridge-${storage.repoId}-1` - ); + const serverName = claudeChannelServerName(storage.runId, storage.repoId); + expect(manifest?.claudeChannelServer).toBe(serverName); + expect(manifest?.claudeChannelServer).not.toContain(storage.repoId); const configPath = opts.claudeMcpConfigPath; expect(configPath).toBeDefined(); const config = JSON.parse(readFileSync(configPath ?? "", "utf8")); - expect(Object.keys(config.mcpServers)).toEqual([ - `loop-bridge-${storage.repoId}-1`, - ]); + expect(Object.keys(config.mcpServers)).toEqual([serverName]); + } finally { + if (originalHome === undefined) { + Reflect.deleteProperty(process.env, "HOME"); + } else { + process.env.HOME = originalHome; + } + if (originalRunId === undefined) { + Reflect.deleteProperty(process.env, "LOOP_RUN_ID"); + } else { + process.env.LOOP_RUN_ID = originalRunId; + } + rmSync(home, { recursive: true, force: true }); + } +}); + +test("preparePairedRun upgrades an older hashed Claude bridge server name", () => { + const home = makeTempHome(); + const originalHome = process.env.HOME; + const originalRunId = process.env.LOOP_RUN_ID; + process.env.HOME = home; + Reflect.deleteProperty(process.env, "LOOP_RUN_ID"); + + try { + const storage = resolveRunStorage("alpha", process.cwd(), home); + writeRunManifest( + storage.manifestPath, + createRunManifest( + { + claudeChannelServer: `loop-bridge-${storage.repoId}-alpha`, + claudeSessionId: "claude-session-1", + codexThreadId: "codex-thread-1", + cwd: process.cwd(), + mode: "paired", + pid: 1234, + repoId: storage.repoId, + runId: "alpha", + status: "running", + }, + "2026-03-29T10:00:00.000Z" + ) + ); + const opts = makeOptions({ resumeRunId: "alpha" }); + + const prepared = preparePairedRun(opts, process.cwd()); + const serverName = claudeChannelServerName(storage.runId, storage.repoId); + + expect(prepared.manifest.claudeChannelServer).toBe(serverName); + expect(readRunManifest(storage.manifestPath)?.claudeChannelServer).toBe( + serverName + ); + expect(serverName).not.toContain(storage.repoId); } finally { if (originalHome === undefined) { Reflect.deleteProperty(process.env, "HOME"); From 2c556e1bd0acc582eef2a87e85422df68cdf3bc9 Mon Sep 17 00:00:00 2001 From: Axel Delafosse Date: Sun, 29 Mar 2026 20:19:35 -0700 Subject: [PATCH 12/12] Use resolved Claude bridge server names consistently --- src/loop/bridge-runtime.ts | 8 ++------ src/loop/bridge-store.ts | 14 ++++++++------ src/loop/paired-options.ts | 2 +- src/loop/tmux.ts | 9 ++++++--- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/loop/bridge-runtime.ts b/src/loop/bridge-runtime.ts index dfeabdf..a46df79 100644 --- a/src/loop/bridge-runtime.ts +++ b/src/loop/bridge-runtime.ts @@ -2,10 +2,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { spawn, spawnSync } from "bun"; import { removeClaudeChannelServer } from "./bridge-claude-registration"; -import { - claudeChannelServerName, - legacyClaudeChannelServerName, -} from "./bridge-config"; +import { generatedClaudeChannelServerNames } from "./bridge-config"; import { BRIDGE_WORKER_SUBCOMMAND, CLAUDE_CHANNEL_USER, @@ -258,8 +255,7 @@ export const clearStaleTmuxBridgeState = (runDir: string): boolean => { } removedServerNames = [ manifest.claudeChannelServer, - claudeChannelServerName(manifest.runId, manifest.repoId), - legacyClaudeChannelServerName(manifest.runId), + ...generatedClaudeChannelServerNames(manifest.runId, manifest.repoId), ].filter((name): name is string => Boolean(name)); return touchRunManifest( { diff --git a/src/loop/bridge-store.ts b/src/loop/bridge-store.ts index fdb2dcc..565f6c3 100644 --- a/src/loop/bridge-store.ts +++ b/src/loop/bridge-store.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { claudeChannelServerName } from "./bridge-config"; +import { resolveClaudeChannelServerName } from "./bridge-config"; import { BRIDGE_SERVER } from "./bridge-constants"; import { normalizeBridgeMessage } from "./bridge-message-format"; import { @@ -242,11 +242,13 @@ export const readBridgeStatus = (runDir: string): BridgeStatus => { return { bridgeServer: BRIDGE_SERVER, claudeBridgeMode: hasTmuxSession ? "local-registration" : "mcp-config", - claudeChannelServer: - manifest?.claudeChannelServer ?? - (runId - ? claudeChannelServerName(runId, manifest?.repoId) - : BRIDGE_SERVER), + claudeChannelServer: runId + ? resolveClaudeChannelServerName( + runId, + manifest?.repoId, + manifest?.claudeChannelServer + ) + : BRIDGE_SERVER, claudeSessionId: manifest?.claudeSessionId ?? "", codexRemoteUrl, codexThreadId, diff --git a/src/loop/paired-options.ts b/src/loop/paired-options.ts index 67644a4..bb8159e 100644 --- a/src/loop/paired-options.ts +++ b/src/loop/paired-options.ts @@ -1,7 +1,7 @@ import { buildCodexBridgeConfigArgs, - ensureClaudeBridgeConfig, claudeChannelServerName, + ensureClaudeBridgeConfig, resolveClaudeChannelServerName, } from "./bridge-config"; import { diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index db9c533..a456be0 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -10,6 +10,7 @@ import { buildClaudeChannelServerConfig, claudeChannelServerName, legacyClaudeChannelServerName, + resolveClaudeChannelServerName, } from "./bridge-config"; import { receiveMessagesStuckGuidance, @@ -841,9 +842,11 @@ const startPairedSession = async ( codexRemoteUrl, codexThreadId ); - const claudeChannelServer = - manifest.claudeChannelServer || - claudeChannelServerName(storage.runId, storage.repoId); + const claudeChannelServer = resolveClaudeChannelServerName( + storage.runId, + storage.repoId, + manifest.claudeChannelServer + ); registerClaudeChannelServerForRun(deps, claudeChannelServer, storage.runDir); try { const env = [