@minhspark/codex-mcp-bridge 1.22.12 → 1.22.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.12",
3
+ "version": "1.22.14",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.14] - 2026-10-21
8
+
9
+ ### Fixed
10
+
11
+ - Start the Codex Desktop native relay when it is missing instead of failing every Claude-to-Codex call. Codex Desktop runs `codex-native-relay` only while a task has its MCP servers loaded: at launch it starts them for about a second to build its tool catalog and then cancels them, so after a Desktop restart `codex_bridge_status`, `list_codex_threads`, `send_to_codex_thread` and `delegate_to_codex` failed with `connect ENOENT .../native-relay.sock-accounts-v2` until a task was opened. When a Desktop request cannot reach the relay before anything was written and Codex Desktop is running on macOS or Windows, the bridge now opens the destination task, or the most recent Codex Desktop task of the sender's project, in the background through `codex://threads/<id>`, waits up to 20 seconds for the relay socket and retries once. A failed wake backs off for one minute. `CODEX_BRIDGE_RELAY_WAKE=0` turns this off. On Windows the bridge recognises Codex Desktop by the packaged `OpenAI.Codex_*\app\ChatGPT.exe`, and the project fallback also accepts a task of the same git repository, so a Claude Code session in a linked worktree can wake the relay through the main checkout's task.
12
+ - Open Codex Desktop tasks on Windows without reporting a false failure. `explorer.exe codex://threads/<id>` opens the task but always exits with code 1, so `open_codex_thread` and the `open` option of the send tools reported an error after a successful open, and the relay wake gave up although Codex had loaded the task. Links now go through PowerShell `Start-Process`, which returns 0 on success and fails when no `codex://` handler is registered. The URL is passed base64-encoded, never interpolated into the command.
13
+ - Report the relay-down cause accurately. The guidance now says that the relay runs only while a task has its MCP servers loaded, points to a regular project task rather than the internal Native Relay executor task (which reports a missing rollout when opened, by design), and appears only when the relay was actually unreachable. The Desktop failure text no longer doubles its final period.
14
+ - Read compressed Codex rollouts. Codex compresses rollouts that have been idle for about a week to `rollout-*.jsonl.zst` (244 of 269 files on the test Mac). The bridge matched only `.jsonl`, so a send to such a task delivered the message but returned an empty reply (`No trusted pre-send rollout watermark is available`), and `read_codex_thread` with a `turnId` failed. Discovery now accepts both forms, decompresses with Node's zstd support (22.15+ or 23.8+; older runtimes get a clear error) under a separate 256 MiB limit, follows Codex rewriting the file as plain `.jsonl` when the task resumes, retries reads that race that rewrite, and caches unchanged compressed scans. The VS Code task bridge also lists compressed tasks.
15
+ - Prune the runtime snapshot cache. Nothing removed snapshots from `~/.codex/bridge-runtimes` (76 directories, 1.8 GB measured). Supervisors now lease their worker runtimes and prune one minute after start and after each activation: runtimes that are leased, referenced by a running process, among the three most recently used, or used in the last 24 hours are kept, at most eight are removed per hourly pass, and orphaned digest files, stale leases and abandoned temporaries are cleaned up. On macOS snapshots are APFS clones (`/bin/cp -c`), because `COPYFILE_FICLONE` never clones there.
16
+ - `inspect_bridge_project` reports a missing `CODEX_BRIDGE_PROJECT_POLICY` as `policy_not_configured` with next steps instead of failing. Errors from read-only tools no longer carry the "No Desktop mutation was dispatched ... do not resend" boilerplate, `wait_codex_reply` and `read_codex_thread` keep their no-resend guidance only while the turn can still change, and error sentences are punctuated.
17
+ - The Claude Code registration check detects an unsupervised shared Claude Desktop bridge entry, including one renamed to `codex-bridge-desktop`, and `install-claude-desktop` updates that entry instead of adding a second one. Neither claims or removes entries that run another program.
18
+ - The relay companion and `claude-bridge` drain accepted work on SIGTERM, SIGINT and SIGHUP like they do when the MCP client closes; a second signal exits at once. A relay listener whose startup is refused or cancelled no longer leaves a dead public socket. `install-native-relay --remove` also removes the account and Desktop task sockets and names the retired executor task.
19
+ - Adding a missing name to an existing `env_vars` array keeps its comments and layout.
20
+
21
+ ### Verification
22
+
23
+ - 1,489 tests before, 1,564 after, 0 failures (23 platform skips). New tests cover the relay wake (destination and project fallback, executor exclusion, back-off, coalescing, retry only before a write), compressed discovery, reads, rematerialization races and the descriptor-leak path, snapshot pruning and liveness re-checks, the read-only tool surface, and registration detection. Each behaviour test was confirmed to fail on the previous code.
24
+ - Measured on Codex Desktop 26.1016.6573.0 on Windows 11: after the relay processes were stopped, opening a regular task that was not loaded brought all three `\\.\pipe\LOCAL\codex-native-relay*` pipes back and the wake returned in 4.8 seconds. Reopening the task whose MCP session was still loaded did not restart the relay within 20 seconds, which the wake reported and backed off from.
25
+ - Measured on Codex Desktop 26.1013 (Codex Framework 155.0.8059.80) on macOS: a relay process outside the app-server's process tree is refused with `dynamic_app_tools_peer_rejected reason=untrusted-process-ancestry`, so the relay cannot be kept alive outside a task's MCP session. Opening a regular task with `open -g codex://threads/<id>` started a new `codex-native-relay` supervisor within about 10 seconds, after which `delegate_to_codex` created a task and returned Codex's reply (`RELAY-OK 22:17`) through the relay.
26
+ - A read-only probe on a real compressed task returned an available watermark (57.9 MB decompressed in 185 ms) and an authoritative turn read, both `unavailable` before the fix.
27
+ - Three adversarial reviews ran over the changes; every confirmed finding is fixed with a test.
28
+
29
+ ### Limitations
30
+
31
+ - On macOS the wake opens the task without activating Codex Desktop. On Windows the `codex://` handler brings the Codex Desktop window forward.
32
+ - The wake helps only when the opened task does not already have its MCP servers loaded. If the relay process died while its task stayed loaded, reopening that task does not restart it; open another task or reconnect the task's MCP servers.
33
+ - The wake It needs either the destination task or an earlier Codex Desktop task in the sender's project; otherwise the bridge reports the relay-down guidance.
34
+ - Codex can unload a task's MCP servers when the task is idle; the next request wakes the relay again.
35
+
36
+ ## [1.22.13] - 2026-10-20
37
+
38
+ ### Fixed
39
+
40
+ - Find the Codex Desktop native tools pipe on Desktop 26.1013. That build serves the native app tools on `\\.\pipe\codex-app-tools-<id>` and no longer passes `CODEX_APP_TOOLS_PIPE_PATH` on the app-server command line. Windows discovery only looked at `codex-browser-use-*` pipes, which answer `No handler registered for method: tools/call`, so the relay companion never opened `codex-native-relay-accounts-v2` and every Claude-to-Codex send failed with `RELAY_UNAVAILABLE` (`connect ENOENT`). Discovery now also accepts `codex-app-tools-*` pipes. The existing checks still apply: the pipe must be local, owned by the exact `ChatGPT.exe` ancestor of the companion's app-server, and answer a read-only `list_projects` call. `codex-app-tools-*` sorts first, so it is tried before the browser pipes.
41
+
42
+ ### Verification
43
+
44
+ - One new test covers a Desktop 26.1013 snapshot with both pipe kinds and checks that only the app-tools pipe is probed. It fails on v1.22.12. A pipe with another `codex-` prefix, and an app-tools pipe owned by another process, are still rejected.
45
+ - On a Windows machine running Codex Desktop 26.1013.4894.0, `resolveNativeToolsPipePath` from v1.22.12 returned `null` and the fixed code returned the live `codex-app-tools-*` pipe, owned by the Desktop process that started the app-server. `list_projects` through that pipe returned the saved projects; the five `codex-browser-use-*` pipes returned `No handler registered for method: tools/call`.
46
+
47
+ ### Limitations
48
+
49
+ - A running companion keeps its old discovery code until it reloads. After updating, restart Codex Desktop, or let automatic reload pick up the new source, then check `native_relay_status`.
50
+
7
51
  ## [1.22.12] - 2026-10-20
8
52
 
9
53
  ### Fixed
package/README.md CHANGED
@@ -198,7 +198,7 @@ Dispatch expires after two minutes; operation history remains stored. Disconnect
198
198
  - **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory (or Homebrew's `bin`) on PATH.
199
199
  - **PowerShell blocks scripts:** use `npm.cmd`, installer `.cmd` launchers, and `codex.exe`.
200
200
  - **No sessions or wrong project:** open the intended Claude **Code** session and use the exact project directory saved in Codex Desktop.
201
- - **Relay unavailable:** check [pipe forwarding](#native-relay-pipe-forwarding), reconnect, and run `native_relay_status` in the task.
201
+ - **Relay unavailable:** Codex Desktop runs the relay only while a task has its MCP servers loaded. On macOS the bridge opens the destination task (or your project's latest Codex task) in the background to start it; otherwise open a regular project task and send it a turn. If it still fails, check [pipe forwarding](#native-relay-pipe-forwarding) and run `native_relay_status` in that task.
202
202
  - **Timeout or unconfirmed send:** inspect the original task or receipt before sending again.
203
203
 
204
204
  [More fixes](https://github.com/buidangminh23/codex-mcp-bridge/blob/main/REFERENCE.md#troubleshooting) · [Report an issue](https://github.com/buidangminh23/codex-mcp-bridge/issues)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.12",
3
+ "version": "1.22.14",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.12",
4
+ "version": "1.22.14",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { randomUUID } from "node:crypto";
6
6
 
7
- import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
7
+ import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, homeDir, resolveCodexBin } from "../src/platform.mjs";
8
+ import { readRegularConfigFile } from "../src/project-policy.mjs";
8
9
  import { desktopTasksConfigured } from "../src/native-relay.mjs";
9
10
  import { exitForVersionRequest } from "../src/cli-version.mjs";
10
11
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
@@ -18,7 +19,6 @@ const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
18
19
  const reset = process.argv.includes("--reset");
19
20
  const forceDesktopTasks = process.argv.includes("--desktop-tasks");
20
21
  const fullAccess = process.argv.includes("--full-access") || fullAccessEnabled();
21
- if (fullAccess) enableCodexFullAccess();
22
22
 
23
23
  /**
24
24
  * This bridge is intentionally the local hand-off point between Claude and
@@ -88,7 +88,22 @@ cfg.mcpServers = cfg.mcpServers ?? {};
88
88
  * already says, then the default. `--reset` drops the middle one for the rare
89
89
  * case of wanting the defaults back.
90
90
  */
91
- const previousEntry = (reset ? {} : cfg.mcpServers["codex-bridge"]) ?? {};
91
+ const KNOWN_ENTRY_NAMES = new Set(["codex-bridge", "codex-bridge-desktop"]);
92
+ const sourceDirectories = new Set([path.join(root, "src"), path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "src")]);
93
+ const runsThisBridge = (entry) => {
94
+ const script = entry?.args?.[0];
95
+ if (typeof script !== "string" || !sourceDirectories.has(path.dirname(path.resolve(script)))) return false;
96
+ const name = path.basename(script);
97
+ return name === "index.mjs" || name === "mcp-supervisor.mjs" && entry.args[1] === "index.mjs";
98
+ };
99
+ const bridgeEntries = Object.keys(cfg.mcpServers).filter((name) => KNOWN_ENTRY_NAMES.has(name) || runsThisBridge(cfg.mcpServers[name]));
100
+ if (bridgeEntries.length > 1) {
101
+ throw new Error(`Claude Desktop already has several bridge entries (${bridgeEntries.join(", ")}); remove all but one and re-run. No configuration was changed.`);
102
+ }
103
+ const entryName = bridgeEntries[0] ?? "codex-bridge";
104
+ if (fullAccess) enableCodexFullAccess();
105
+
106
+ const previousEntry = (reset ? {} : cfg.mcpServers[entryName]) ?? {};
92
107
  const previousEnv = previousEntry.env ?? {};
93
108
  const settled = (name, fallback) => process.env[name] ?? previousEnv[name] ?? fallback;
94
109
  const desktopMode = forceDesktopTasks ? "1" : settled("CODEX_BRIDGE_DESKTOP_TASKS");
@@ -100,7 +115,7 @@ const kept = Object.keys(previousEnv).filter(
100
115
  !(desktopEnabled && name === "CODEX_BRIDGE_AUTOSTART"),
101
116
  );
102
117
 
103
- cfg.mcpServers["codex-bridge"] = {
118
+ cfg.mcpServers[entryName] = {
104
119
  ...previousEntry,
105
120
  command: nodeBin,
106
121
  args: [path.join(root, "src", "mcp-supervisor.mjs"), "index.mjs"],
@@ -149,7 +164,7 @@ cfg.mcpServers["codex-bridge"] = {
149
164
  },
150
165
  };
151
166
 
152
- createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...cfg.mcpServers["codex-bridge"].env }) });
167
+ createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...cfg.mcpServers[entryName].env }) });
153
168
 
154
169
  const backup = `${cfgPath}.bak-${new Date().toISOString().slice(0, 10)}-codexbridge`;
155
170
  if (original) {
@@ -170,8 +185,8 @@ try {
170
185
  }
171
186
 
172
187
  console.log(`platform: ${PLATFORM_LABEL}`);
173
- console.log(`updated ${cfgPath}`);
174
- console.log(JSON.stringify(cfg.mcpServers["codex-bridge"], null, 2));
188
+ console.log(`updated ${cfgPath} (entry ${entryName})`);
189
+ console.log(JSON.stringify(cfg.mcpServers[entryName], null, 2));
175
190
  console.log(`Desktop tasks: ${desktopEnabled ? "enabled" : "disabled"} (${desktopMode === undefined ? "automatic relay configuration" : forceDesktopTasks ? "--desktop-tasks" : "explicit environment setting"}).`);
176
191
 
177
192
  if (reset) {
@@ -181,7 +196,7 @@ if (reset) {
181
196
  console.log("Pass the variable to override one, or --reset to drop them all.");
182
197
  }
183
198
 
184
- const writtenRoots = cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_ALLOWED_ROOTS;
199
+ const writtenRoots = cfg.mcpServers[entryName].env.CODEX_BRIDGE_ALLOWED_ROOTS;
185
200
  if (writtenRoots.split(path.delimiter).some((entry) => entry.split(path.sep).includes("node_modules"))) {
186
201
  console.log(
187
202
  "\nWARNING: CODEX_BRIDGE_ALLOWED_ROOTS points inside an install directory, which is not a workspace.\n" +
@@ -190,7 +205,7 @@ if (writtenRoots.split(path.delimiter).some((entry) => entry.split(path.sep).inc
190
205
  );
191
206
  }
192
207
 
193
- if (cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_THREAD_POLICY === "owned") {
208
+ if (cfg.mcpServers[entryName].env.CODEX_BRIDGE_THREAD_POLICY === "owned") {
194
209
  console.log(
195
210
  "\nNOTE: thread policy is `owned` - this bridge may only act on threads it created itself.\n" +
196
211
  "A thread you open in the Codex app or the VS Code extension will answer NOT AUTHORIZED,\n" +
@@ -200,6 +215,19 @@ if (cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_THREAD_POLICY === "owned") {
200
215
  );
201
216
  }
202
217
 
218
+ if (entryName === "codex-bridge") {
219
+ const codeConfig = path.join(process.env.CLAUDE_CONFIG_DIR || homeDir(), ".claude.json");
220
+ let codeEntry;
221
+ try { codeEntry = JSON.parse(readRegularConfigFile(codeConfig, { missing: true, maxBytes: 8 * 1024 * 1024 }) ?? "{}").mcpServers?.["codex-bridge"]; } catch {}
222
+ if (codeEntry) {
223
+ console.log(
224
+ `\nWARNING: ${codeConfig} also registers codex-bridge for Claude Code. A Desktop entry with the same name can\n` +
225
+ "shadow it inside Code sessions and hide the calling task. Rename this Desktop entry, for example to\n" +
226
+ "codex-bridge-desktop; later installer runs update the renamed entry in place.",
227
+ );
228
+ }
229
+ }
230
+
203
231
  console.log("\nReconnect codex-bridge once in the existing Claude task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle.");
204
232
  console.log("Verify codex_bridge_status from that task: autoReload must be enabled and runtime state must be current. A separate diagnostic process does not verify the app's loaded MCP process.");
205
233
  if (desktopEnabled) console.log("Verify Desktop tasks are enabled and the native relay is available. Configuration changes require MCP reconnect even when source auto-reload is enabled; keep the destination task open in Codex Desktop.");
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
- import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath, writeRelayConfig } from "../src/native-relay.mjs";
8
+ import { accountRelaySocketPath, bootstrapRelayThread, desktopTaskSocketPath, readRelayConfig, relayConfigPath, relaySocketPath, writeRelayConfig } from "../src/native-relay.mjs";
9
9
  import {
10
10
  IS_MACOS,
11
11
  IS_WINDOWS,
@@ -24,7 +24,7 @@ import { stableInstallPath } from "../src/install-paths.mjs";
24
24
 
25
25
  exitForVersionRequest(import.meta.url);
26
26
 
27
- const VERSION = "1.22.12";
27
+ const VERSION = "1.22.14";
28
28
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
29
29
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
30
30
  const entryArgs = ["native-relay-companion.mjs"];
@@ -47,12 +47,15 @@ if (remove) {
47
47
  } catch (err) {
48
48
  console.log(`${serverName} was not registered (${err.message.trim().split("\n").at(-1)})`);
49
49
  }
50
- for (const leftover of [relayConfigPath(), ...(IS_WINDOWS ? [] : [relaySocketPath()])]) {
50
+ const retiredExecutor = readRelayConfig()?.relayThreadId;
51
+ const sockets = IS_WINDOWS ? [] : [...new Set([relaySocketPath(), desktopTaskSocketPath(), accountRelaySocketPath()])];
52
+ for (const leftover of [relayConfigPath(), ...sockets]) {
51
53
  if (fs.existsSync(leftover)) {
52
54
  fs.rmSync(leftover, { force: true });
53
55
  console.log(`removed ${leftover}`);
54
56
  }
55
57
  }
58
+ if (retiredExecutor) console.log(`archive the retired relay executor task ${retiredExecutor} in Codex Desktop if it is still listed; a reinstall bootstraps a new one`);
56
59
  process.exit(0);
57
60
  }
58
61
 
@@ -1,3 +1,5 @@
1
+ import { RELAY_DOWN_GUIDANCE } from "./thread-delivery.mjs";
2
+
1
3
  export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = true, verifyTargetProject }) {
2
4
  const issues = [];
3
5
  if (sender?.status !== "verified") issues.push({ code: "SENDER_UNVERIFIED", detail: sender?.reason ?? "No current Code session identity", action: "Load the user-level Claude Code bridge entry in this existing task; a shared Desktop entry cannot identify the caller. Run the Code registration check, then reload the affected client." });
@@ -18,6 +20,6 @@ export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = tr
18
20
  issues.push({ code: error.code ?? "PROJECT_SCOPE_UNVERIFIED", detail: error.message, action: "Select a destination in the verified sender's project. A project grant does not authorize cross-project delivery." });
19
21
  }
20
22
  }
21
- if (!nativeAvailable) issues.push({ code: "RELAY_UNAVAILABLE", action: "Open Codex Desktop and check native_relay_status in its existing task." });
23
+ if (!nativeAvailable) issues.push({ code: "RELAY_UNAVAILABLE", action: RELAY_DOWN_GUIDANCE });
22
24
  return { ready: issues.length === 0, readyScope: "transport-identity-and-project", hostToolPermission: "evaluated-by-client-at-call-time", sender, projectPolicy, target, issues };
23
25
  }
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.22.12";
33
+ const VERSION = "1.22.14";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
@@ -46,6 +46,7 @@ const peer = new PeerEndpoint({
46
46
  name: defaultPeerName,
47
47
  cwd: process.env.CLAUDE_BRIDGE_CWD ?? process.cwd(),
48
48
  log,
49
+ signalExit: false,
49
50
  });
50
51
 
51
52
  const codex = new CodexAppServerClient({
@@ -337,7 +338,7 @@ function registerTool(name, definition, handler) {
337
338
  });
338
339
  }
339
340
 
340
- registerProjectOnboardingTools(registerTool, { beforePrepare: async (extra) => {
341
+ registerProjectOnboardingTools(registerTool, { options: { policyRegistration: "codex" }, beforePrepare: async (extra) => {
341
342
  runtime.assertCurrent();
342
343
  if (!desktopOnly) throw new Error("Project onboarding requires a verified Desktop caller");
343
344
  await assertSender(extra?._meta);
@@ -811,7 +812,7 @@ try {
811
812
 
812
813
  const transport = new StdioServerTransport();
813
814
  await server.connect(transport);
814
- exitWhenClientCloses({
815
+ const shutdown = exitWhenClientCloses({
815
816
  prepare: () => peer.closeIntake(),
816
817
  busy: () => peer.connections.size ? "Peer socket connections are still open"
817
818
  : peer.requestQueues.size ? "Claude messages are still being sent"
@@ -819,5 +820,7 @@ exitWhenClientCloses({
819
820
  stop: async () => { replyForwarder.close(); peer.stop(); codex.close(); await peer.closed; },
820
821
  log,
821
822
  });
823
+ let signals = 0;
824
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => { if (++signals > 1) process.exit(1); void shutdown.close(signal); });
822
825
  reload.listen();
823
826
  log(`ready on ${PLATFORM_LABEL} as peer "${peer.name}" (${peer.started ? peer.socketPath : "peer endpoint down"})`);
@@ -1,8 +1,22 @@
1
1
  import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const PACKAGE_SOURCE = path.dirname(fileURLToPath(import.meta.url));
2
5
 
3
6
  function worker(entry) {
4
7
  return entry?.args?.length === 2 && path.basename(entry.args[0]) === "mcp-supervisor.mjs" && entry.args[1] === "index.mjs";
5
8
  }
9
+ function bridgeSource(entry) {
10
+ const script = entry?.args?.[0];
11
+ if (typeof script !== "string" || !(worker(entry) || path.basename(script) === "index.mjs")) return null;
12
+ return path.dirname(path.resolve(script));
13
+ }
14
+ function bridgeSources(source) {
15
+ return new Set([PACKAGE_SOURCE, ...(worker(source) ? [bridgeSource(source)] : [])]);
16
+ }
17
+ function bridgeEntry(entry, sources) {
18
+ return worker(entry) || sources.has(bridgeSource(entry));
19
+ }
6
20
  const stable = (value) => JSON.stringify(value, Object.keys(value ?? {}).sort());
7
21
 
8
22
  export function planCodeRegistration({ code, desktop, policyFile, name = "codex-bridge" }) {
@@ -19,20 +33,27 @@ export function planCodeRegistration({ code, desktop, policyFile, name = "codex-
19
33
  const changes = [{ kind: "user-code-entry", name }];
20
34
  const comparable = (entry) => {
21
35
  const normalized = structuredClone(entry);
36
+ if (worker(entry) || entry.args.length === 1) normalized.args = [path.join(bridgeSource(entry), "mcp-supervisor.mjs"), "index.mjs"];
22
37
  delete normalized.env?.CODEX_BRIDGE_ALLOWED_ROOTS;
23
38
  delete normalized.env?.CODEX_BRIDGE_PROJECT_POLICY;
24
39
  return JSON.stringify({ ...normalized, env: stable(normalized.env) });
25
40
  };
41
+ const sources = bridgeSources(source);
42
+ const sameBridge = (entry, location) => {
43
+ if (bridgeSource(entry) === bridgeSource(source)) return true;
44
+ if (bridgeEntry(entry, sources)) throw new Error(`${location} runs another bridge installation from ${bridgeSource(entry)}; update or remove it before migration`);
45
+ return false;
46
+ };
26
47
  for (const [project, config] of Object.entries(updated.projects ?? {})) {
27
48
  for (const [alias, entry] of Object.entries(config.mcpServers ?? {})) {
28
- if (!worker(entry) || path.resolve(entry.args[0]) !== path.resolve(source.args[0])) continue;
49
+ if (!sameBridge(entry, `Project ${project} entry ${alias}`)) continue;
29
50
  if (comparable(entry) !== comparable(source)) throw new Error(`Project ${project} has a customized ${alias} entry; review its settings instead of replacing it`);
30
51
  delete config.mcpServers[alias];
31
52
  changes.push({ kind: "remove-duplicate-project-entry", project, name: alias });
32
53
  }
33
54
  }
34
55
  for (const [alias, entry] of Object.entries(updatedDesktop.mcpServers ?? {})) {
35
- if (!worker(entry) || path.resolve(entry.args[0]) !== path.resolve(source.args[0])) continue;
56
+ if (!sameBridge(entry, `Shared Desktop entry ${alias}`)) continue;
36
57
  if (comparable(entry) !== comparable(source)) throw new Error(`Shared Desktop entry ${alias} has customized settings; review it before migration`);
37
58
  delete updatedDesktop.mcpServers[alias];
38
59
  changes.push({ kind: "remove-shared-desktop-entry", name: alias });
@@ -44,10 +65,13 @@ export function checkCodeRegistration({ code, desktop, name = "codex-bridge" })
44
65
  const entry = code.mcpServers?.[name];
45
66
  const issues = [];
46
67
  if (!worker(entry)) issues.push("User-level Claude Code bridge is missing or does not use the supervisor");
47
- const shared = Object.keys(desktop.mcpServers ?? {}).filter((key) => worker(desktop.mcpServers[key]));
68
+ const sources = bridgeSources(entry);
69
+ const shared = Object.keys(desktop.mcpServers ?? {}).filter((key) => bridgeEntry(desktop.mcpServers[key], sources));
48
70
  if (shared.length) issues.push(`Shared Desktop bridge entries can hide the Code caller identity: ${shared.join(", ")}`);
71
+ const unsupervised = shared.filter((key) => !worker(desktop.mcpServers[key]));
72
+ if (unsupervised.length) issues.push(`Shared Desktop bridge entries run without the supervisor and keep stale code loaded: ${unsupervised.join(", ")}`);
49
73
  const overrides = Object.entries(code.projects ?? {}).flatMap(([project, config]) => Object.entries(config.mcpServers ?? {})
50
- .filter(([, item]) => worker(item)).map(([alias]) => ({ project, alias })));
74
+ .filter(([, item]) => bridgeEntry(item, sources)).map(([alias]) => ({ project, alias })));
51
75
  if (overrides.length) issues.push("Project-specific bridge entries override or duplicate the user-level registration");
52
76
  if (!entry?.env?.CODEX_BRIDGE_PROJECT_POLICY) issues.push("No shared live project policy configured");
53
77
  return { ready: issues.length === 0, issues, shared, overrides, policyFile: entry?.env?.CODEX_BRIDGE_PROJECT_POLICY ?? null };
@@ -175,15 +175,37 @@ function parseStringArray(text, statement) {
175
175
  const raw = text.slice(statement.valueStart, statement.valueEnd);
176
176
  if (!raw.startsWith("[")) fail("env_vars is not an array");
177
177
  const values = [];
178
+ let lastEnd = null;
179
+ let trailingComma = false;
178
180
  let cursor = skipTrivia(raw, 1);
179
181
  while (raw[cursor] !== "]") {
180
182
  if (raw[cursor] !== '"' && raw[cursor] !== "'") fail("env_vars must contain only strings");
181
183
  const string = readString(raw, cursor);
182
184
  values.push(string.value);
185
+ lastEnd = statement.valueStart + string.end;
183
186
  cursor = skipTrivia(raw, string.end);
184
- if (raw[cursor] === ",") cursor = skipTrivia(raw, cursor + 1);
187
+ trailingComma = raw[cursor] === ",";
188
+ if (trailingComma) cursor = skipTrivia(raw, cursor + 1);
185
189
  }
186
- return values;
190
+ return { values, lastEnd, trailingComma, close: statement.valueStart + cursor };
191
+ }
192
+
193
+ function appendToStringArray(contents, statement, array, missing, newline) {
194
+ const lineStart = contents.lastIndexOf("\n", array.close - 1) + 1;
195
+ const closeOwnsLine = lineStart > statement.valueStart && !contents.slice(lineStart, array.close).trim();
196
+ if (!closeOwnsLine) {
197
+ if (array.lastEnd === null) return [{ start: statement.valueStart, end: statement.valueEnd, text: tomlArray(missing) }];
198
+ return [{ start: array.lastEnd, end: array.lastEnd, text: `, ${missing.map(tomlString).join(", ")}` }];
199
+ }
200
+ const leading = (index) => contents.slice(index).match(/^[ \t]*/)[0];
201
+ const elementLine = array.lastEnd === null ? null : contents.lastIndexOf("\n", array.lastEnd - 1) + 1;
202
+ const indent = elementLine !== null && elementLine > statement.valueStart ? leading(elementLine)
203
+ : `${leading(contents.lastIndexOf("\n", statement.valueStart - 1) + 1)} `;
204
+ const trailing = array.lastEnd === null || array.trailingComma;
205
+ const lines = missing.map((variable, index) => `${indent}${tomlString(variable)}${index < missing.length - 1 || trailing ? "," : ""}${newline}`).join("");
206
+ const edits = [{ start: lineStart, end: lineStart, text: lines }];
207
+ if (array.lastEnd !== null && !array.trailingComma) edits.push({ start: array.lastEnd, end: array.lastEnd, text: "," });
208
+ return edits;
187
209
  }
188
210
 
189
211
  export function retargetMcpServerToml(contents, name, { command, args, ensureEnvVars = [] }) {
@@ -224,8 +246,8 @@ export function retargetMcpServerToml(contents, name, { command, args, ensureEnv
224
246
  if (error instanceof TomlScanError) throw new Error(`The ${name} MCP entry has env_vars that are not a string list; it was not changed`);
225
247
  throw error;
226
248
  }
227
- const missing = ensureEnvVars.filter((variable) => !existing.includes(variable));
228
- if (missing.length) edits.push({ start: envVarsStatement.valueStart, end: envVarsStatement.valueEnd, text: tomlArray([...existing, ...missing]) });
249
+ const missing = [...new Set(ensureEnvVars.filter((variable) => !existing.values.includes(variable)))];
250
+ if (missing.length) edits.push(...appendToStringArray(contents, envVarsStatement, existing, missing, newline));
229
251
  } else {
230
252
  const anchor = argsStatement ?? commandStatement;
231
253
  const lineBreak = contents.slice(anchor.end - 1, anchor.end) === "\n" ? "" : newline;
@@ -3,13 +3,14 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import { scanRollout } from "./rollout-reader.mjs";
6
+ import { scanRollout, isCompressedRollout, rolloutBasePath, asRolloutChange, memoizeCompressedScan } from "./rollout-reader.mjs";
7
7
  import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
8
8
  import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
9
9
 
10
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
11
11
  const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
12
12
  const MAX_REPLY_BYTES = 1024 * 1024;
13
+ const MAX_CACHED_RECORD_BYTES = 1024 * 1024;
13
14
  const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
14
15
  const COMPLETED = new Set(["task_complete", "task_completed", "turn_complete", "turn_completed"]);
15
16
  const STARTED = new Set(["task_started", "turn_started"]);
@@ -59,6 +60,14 @@ function pathIdentity(candidate, kind) {
59
60
  }
60
61
 
61
62
  function capturePathBoundary(file, sessions) {
63
+ try {
64
+ return readPathBoundary(file, sessions);
65
+ } catch (error) {
66
+ throw asRolloutChange(error, "The Codex rollout changed while reading");
67
+ }
68
+ }
69
+
70
+ function readPathBoundary(file, sessions) {
62
71
  const resolvedFile = fs.realpathSync.native(file);
63
72
  const relative = path.relative(sessions, resolvedFile);
64
73
  if (relative.startsWith("..") || path.isAbsolute(relative) || path.relative(file, resolvedFile)) throw new Error("The Codex rollout path escapes its sessions directory");
@@ -95,10 +104,20 @@ function readStableRolloutSet(threadId, env, readSegments) {
95
104
  });
96
105
  }
97
106
 
98
- function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
107
+ function readStableOnce(found, maxBytes, options) {
99
108
  const { file, sessions } = found;
100
109
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
101
110
  const beforeBoundary = capturePathBoundary(file, sessions);
111
+ const key = crypto.createHash("sha256").update(JSON.stringify({ maxBytes, ...options })).digest("hex");
112
+ const scanned = memoizeCompressedScan(file, `native:${key}`, () => scanSegment(file, maxBytes, options), { keep: (value) => value.retainedBytes <= MAX_CACHED_RECORD_BYTES });
113
+ const afterBoundary = capturePathBoundary(file, sessions);
114
+ if (!samePathBoundary(beforeBoundary, afterBoundary) || !sameFileIdentity(scanned.identity, beforeBoundary.file)) {
115
+ throw new Error("The Codex rollout changed while reading");
116
+ }
117
+ return { ...scanned, file, sessions };
118
+ }
119
+
120
+ function scanSegment(file, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
102
121
  const records = [];
103
122
  const dispatches = [];
104
123
  const otherTurnStarts = [];
@@ -139,11 +158,7 @@ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefix
139
158
  }
140
159
  },
141
160
  });
142
- const afterBoundary = capturePathBoundary(file, sessions);
143
- if (!samePathBoundary(beforeBoundary, afterBoundary) || !sameFileIdentity(snapshot.identity, beforeBoundary.file)) {
144
- throw new Error("The Codex rollout changed while reading");
145
- }
146
- return { ...snapshot, file, sessions, records, dispatches, otherTurnStarts, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
161
+ return { ...snapshot, records, dispatches, otherTurnStarts, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
147
162
  }
148
163
 
149
164
  function canonicalDirectory(value, label) {
@@ -175,6 +190,22 @@ function uniqueTurn(snapshots) {
175
190
  return selected[0];
176
191
  }
177
192
 
193
+ function segmentMark(marks, file) {
194
+ return marks.find((mark) => rolloutBasePath(mark.file) === rolloutBasePath(file));
195
+ }
196
+
197
+ function validSegmentMarks(marks, watermark) {
198
+ return Array.isArray(marks) && marks.length && marks.length <= 64 && marks.every((mark) => object(mark) && typeof mark.file === "string") &&
199
+ new Set(marks.map((mark) => rolloutBasePath(mark.file))).size === marks.length &&
200
+ marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256);
201
+ }
202
+
203
+ function assertMarkedPrefix(snapshot, mark) {
204
+ const sameStorage = isCompressedRollout(snapshot.file) === isCompressedRollout(mark.file);
205
+ if ((sameStorage && !sameFileIdentity(snapshot.identity, mark.identity)) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
206
+ if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
207
+ }
208
+
178
209
  function delegationOutputs(executorThreadId, prompt) {
179
210
  const escaped = prompt.replace(/[&<>]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[character]);
180
211
  // Older Desktop rollouts stored raw text; current builds escape XML text content.
@@ -422,19 +453,14 @@ export function resolveCodexNativeDispatchResponse(binding, { env = process.env,
422
453
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
423
454
  if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
424
455
  const marks = watermark.segments ?? [watermark];
425
- if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
426
- !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
427
- marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
456
+ if (!validSegmentMarks(marks, watermark) || marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
428
457
  const outputs = delegationOutputs(executorThreadId, prompt);
429
458
  const snapshots = readStableRolloutSet(threadId, env, (found) => {
430
- if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
459
+ if (marks.some((mark) => !segmentMark(found, mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
431
460
  return found.map((entry) => {
432
- const mark = marks.find(({ file }) => file === entry.file);
461
+ const mark = segmentMark(marks, entry.file);
433
462
  const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
434
- if (mark) {
435
- if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
436
- if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
437
- }
463
+ if (mark) assertMarkedPrefix(snapshot, mark);
438
464
  validateIdentity(snapshot, threadId, cwd, desktopEvidence);
439
465
  return snapshot;
440
466
  });
@@ -464,23 +490,18 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
464
490
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
465
491
  if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
466
492
  const marks = watermark.segments ?? [watermark];
467
- if (!Array.isArray(marks) || !marks.length || marks.length > 64
468
- || new Set(marks.map((mark) => mark.file)).size !== marks.length
469
- || !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256)) throw new Error("The pre-send segment watermarks are invalid");
493
+ if (!validSegmentMarks(marks, watermark)) throw new Error("The pre-send segment watermarks are invalid");
470
494
  for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
471
495
  const outputs = delegationOutputs(executorThreadId, prompt);
472
496
  const snapshots = readStableRolloutSet(threadId, env, (found) => {
473
- if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
497
+ if (marks.some((mark) => !segmentMark(found, mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
474
498
  let retainedBytes = 0;
475
499
  return found.map((entry) => {
476
- const mark = marks.find(({ file }) => file === entry.file);
500
+ const mark = segmentMark(marks, entry.file);
477
501
  const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
478
502
  retainedBytes += snapshot.retainedBytes;
479
503
  if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
480
- if (mark) {
481
- if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
482
- if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
483
- }
504
+ if (mark) assertMarkedPrefix(snapshot, mark);
484
505
  if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
485
506
  validateIdentity(snapshot, threadId, cwd, desktopEvidence);
486
507
  return snapshot;
@@ -490,7 +511,7 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
490
511
  const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
491
512
  if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
492
513
  validateSession(selected, threadId, cwd, desktopEvidence);
493
- const selectedMark = marks.find(({ file }) => file === selected.file);
514
+ const selectedMark = segmentMark(marks, selected.file);
494
515
  if (existingTurn && !selectedMark) throw new Error("The active native turn has no pre-send rollout watermark");
495
516
  dispatchObserved = selected.records.some(({ record, start }) => start >= (selectedMark?.size ?? 0) &&
496
517
  record.type === "response_item" && record.payload.type === "function_call_output" &&
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { scanRollout } from "./rollout-reader.mjs";
4
+ import { scanRollout, memoizeCompressedScan } from "./rollout-reader.mjs";
5
5
  import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
6
6
 
7
7
  const METADATA_KEY = "x-codex-turn-metadata";
@@ -38,7 +38,7 @@ function reviewFlag(metadata, field) {
38
38
 
39
39
  export function findRollout(sessions, threadId) {
40
40
  const files = findRolloutSegments(sessions, threadId);
41
- if (files.length !== 1) throw new Error("Multiple rollouts match the calling Codex task");
41
+ if (files.length !== 1) throw new Error("Multiple rollouts match the Codex task");
42
42
  return files[0];
43
43
  }
44
44
 
@@ -48,7 +48,18 @@ export function readState(file, maxBytes) {
48
48
  return retryRolloutRead(() => readStateOnce(file, maxBytes));
49
49
  }
50
50
 
51
+ export function readTaskState(sessions, threadId, maxBytes) {
52
+ return retryRolloutRead(() => {
53
+ const file = findRollout(sessions, threadId);
54
+ return { ...readStateOnce(file, maxBytes), file };
55
+ });
56
+ }
57
+
51
58
  function readStateOnce(file, maxBytes) {
59
+ return memoizeCompressedScan(file, `state:${maxBytes}`, () => scanState(file, maxBytes));
60
+ }
61
+
62
+ function scanState(file, maxBytes) {
52
63
  let session, context, lifecycle;
53
64
  const snapshot = scanRollout(file, {
54
65
  maxRecordBytes: maxBytes,