@minhspark/codex-mcp-bridge 1.22.9 → 1.22.11

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.9",
3
+ "version": "1.22.11",
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,39 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.11] - 2026-10-20
8
+
9
+ ### Fixed
10
+
11
+ - Stop `claude-bridge` and native relay companion workers from outliving their MCP client. The supervisor ends a worker's stdin and IPC channel and waits before killing it, but MCP clients such as the SDK stdio transport kill the supervisor about 4 seconds after closing it. `index.mjs` already exited on stdin end; the other two workers did not, so they kept running with parent PID 1 and kept their relay and peer sockets. Each `npm test` run left two such processes, and the same could happen in Codex Desktop or Claude Desktop whenever an MCP client closed.
12
+ - Both workers now exit on their own when stdin ends or the supervisor channel disconnects. They first stop taking new work and wait up to 35 seconds for work already accepted: the companion refuses new relay connections with `RELAY_STOPPING` (`sent: false`) and finishes accepted connections, active requests and pending Desktop dispatches; `claude-bridge` unregisters its peer endpoint and finishes open peer connections, in-flight sends and queued reply forwards. A late activation from the supervisor cannot reopen the relay during this drain, and losing the supervisor's output pipes no longer crashes the worker mid-drain. The supervisor waits for that drain before forcing a stop when its client closes; retiring a quiesced worker after a reload keeps its 6-second limit.
13
+
14
+ ### Verification
15
+
16
+ - Every MCP supervisor test now records the supervisor and worker PIDs it sees and fails if any outlives its client, killing leftovers so a failure cannot accumulate processes. On v1.22.10, 4 tests fail: the real `claude-bridge` and companion upgrade tests leave their workers behind, and a new test that kills the supervisor finds both workers still running.
17
+ - New tests cover the drain: an accepted relay delivery finishes before exit, a late connection is refused with `RELAY_STOPPING`, a late activation is rejected, a worker whose parent is killed during a drain still exits 0 after its work finishes (it crashes on v1.22.10's output handling), and the peer endpoint stops accepting while keeping open connections.
18
+ - Two full `npm test` runs on macOS left no process from a `mcp-supervisor-test-` directory; the run on v1.22.10 left two.
19
+
20
+ ### Limitations
21
+
22
+ - On Windows the supervisor's workers belong to its job object and end with it, so they never outlived their client there, and a drain cannot continue after the client kills the supervisor. The supervisor-kill test passes on Windows for that reason rather than through the drain.
23
+
24
+ ## [1.22.10] - 2026-10-20
25
+
26
+ ### Fixed
27
+
28
+ - Re-read the whole rollout set when Codex appends between per-segment reads. The pre-send watermark, dispatch, turn and creation readers and Codex sender verification now repeat find, read and set check together, up to three times, instead of failing with `A rollout segment changed while reading`. Only read races qualify: identity, history, account, workspace and dispatch mismatches still fail at once.
29
+ - Keep observing an accepted Desktop send or creation while Codex is still writing its rollout. When a read keeps reporting that the rollout changed while reading, the wait polls again until its deadline and then returns the pending receipt with that reason, instead of reporting the delivery as failed. Seen live on 2026-10-20: an accepted send reported `failed` while the reply was later recovered from the same receipt. A rollout whose last record stays incomplete, for example after Codex stopped mid-write, is still reported at once with its turn status and reason.
30
+
31
+ ### Verification
32
+
33
+ - 8 new regression tests reproduce the append during the set check for the watermark, the turn and dispatch reply readers, sender verification and both wait paths; all fail on v1.22.8. Two more pin that a permanently incomplete rollout is reported without waiting, and a unit test pins which messages are retried.
34
+ - Two rounds of independent review shaped the wait behavior. A deferred released turn stays the observed turn even if a newer turn starts. The deferral reason is cleared after a clean read, and a timeout reports it as an unavailable observation. Only the set level retries, so an actively written rollout is scanned at most three times per observation. Deferral is limited to `changed while reading`, because `incomplete final record` can be permanent. Each of these has a regression test that fails without its fix.
35
+
36
+ ### Limitations
37
+
38
+ - The original race was seen once in a live Desktop send and cannot be triggered on demand; the regression tests reproduce it deterministically by appending to the rollout during the set check. A Codex Desktop or MCP restart still ends in-memory peer receipts, as before.
39
+
7
40
  ## [1.22.9] - 2026-10-20
8
41
 
9
42
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.9",
3
+ "version": "1.22.11",
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.9",
4
+ "version": "1.22.11",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -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.9";
27
+ const VERSION = "1.22.11";
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"];
@@ -18,7 +18,7 @@ import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
18
18
  import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
19
19
  import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
20
20
  import { ReplyForwarder } from "./reply-forwarder.mjs";
21
- import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
21
+ import { assertRoutingReload, clientReloadReason, createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
22
22
  import { readClaudeAccountContext } from "./desktop-account-context.mjs";
23
23
  import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
24
24
  import { recoverClaudeDesktopSession, resolveClaudeDesktopSession, sameClaudeDesktopRecipient } from "./claude-session-router.mjs";
@@ -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.9";
33
+ const VERSION = "1.22.11";
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;
@@ -811,5 +811,13 @@ try {
811
811
 
812
812
  const transport = new StdioServerTransport();
813
813
  await server.connect(transport);
814
+ exitWhenClientCloses({
815
+ prepare: () => peer.closeIntake(),
816
+ busy: () => peer.connections.size ? "Peer socket connections are still open"
817
+ : peer.requestQueues.size ? "Claude messages are still being sent"
818
+ : replyForwarder.forwardingReason(),
819
+ stop: async () => { replyForwarder.close(); peer.stop(); codex.close(); await peer.closed; },
820
+ log,
821
+ });
814
822
  reload.listen();
815
823
  log(`ready on ${PLATFORM_LABEL} as peer "${peer.name}" (${peer.started ? peer.socketPath : "peer endpoint down"})`);
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { scanRollout } from "./rollout-reader.mjs";
7
7
  import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
8
- import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
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;
@@ -84,12 +84,15 @@ function samePathBoundary(expected, current) {
84
84
  }
85
85
 
86
86
  function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
87
- for (let attempt = 0; ; attempt++) {
88
- try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }); }
89
- catch (error) {
90
- if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
91
- }
92
- }
87
+ return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset });
88
+ }
89
+
90
+ function readStableRolloutSet(threadId, env, readSegments) {
91
+ return retryRolloutRead(() => {
92
+ const snapshots = readSegments(findRollouts(threadId, env));
93
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
94
+ return snapshots;
95
+ });
93
96
  }
94
97
 
95
98
  function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
@@ -346,8 +349,7 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch, o
346
349
  export function captureCodexRolloutWatermark({ threadId, expectedCwd, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
347
350
  try {
348
351
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
349
- const snapshots = findRollouts(threadId, env).map((found) => ({ ...found, ...readStable(found, maxRolloutBytes) }));
350
- assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
352
+ const snapshots = readStableRolloutSet(threadId, env, (found) => found.map((entry) => ({ ...entry, ...readStable(entry, maxRolloutBytes) })));
351
353
  for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
352
354
  const matches = snapshots.length === 1 ? snapshots : snapshots.filter((snapshot) =>
353
355
  desktopEvidence?.latestTurnId && snapshot.latestContext?.turn_id === desktopEvidence.latestTurnId);
@@ -369,15 +371,16 @@ export function readCodexNativeCreationResponse({ threadId, turnId, expectedCwd,
369
371
  if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native creation response identity is invalid");
370
372
  if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact creation prompt is unavailable");
371
373
  const cwd = canonicalDirectory(expectedCwd, "The created native task workspace");
372
- let retainedBytes = 0;
373
- const snapshots = findRollouts(threadId, env).map((found) => {
374
- const snapshot = readStable(found, maxRolloutBytes, { threadId, turnId });
375
- retainedBytes += snapshot.retainedBytes;
376
- if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
377
- validateIdentity(snapshot, threadId, cwd, desktopEvidence);
378
- return snapshot;
374
+ const snapshots = readStableRolloutSet(threadId, env, (found) => {
375
+ let retainedBytes = 0;
376
+ return found.map((entry) => {
377
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId });
378
+ retainedBytes += snapshot.retainedBytes;
379
+ if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
380
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
381
+ return snapshot;
382
+ });
379
383
  });
380
- assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
381
384
  const selected = uniqueTurn(snapshots);
382
385
  validateSession(selected, threadId, cwd, desktopEvidence);
383
386
  return inspectTurnRecords(selected.records, threadId, turnId, cwd, {
@@ -392,14 +395,15 @@ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopE
392
395
  try {
393
396
  if (!UUID.test(threadId) || !UUID.test(turnId)) throw new Error("The native turn identity is invalid");
394
397
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
395
- let retainedBytes = 0;
396
- const snapshots = findRollouts(threadId, env).map((found) => {
397
- const snapshot = readStable(found, maxRolloutBytes, { threadId, turnId });
398
- retainedBytes += snapshot.retainedBytes;
399
- if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
400
- return snapshot;
398
+ const snapshots = readStableRolloutSet(threadId, env, (found) => {
399
+ let retainedBytes = 0;
400
+ return found.map((entry) => {
401
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId });
402
+ retainedBytes += snapshot.retainedBytes;
403
+ if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
404
+ return snapshot;
405
+ });
401
406
  });
402
- assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
403
407
  for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
404
408
  const selected = uniqueTurn(snapshots);
405
409
  validateSession(selected, threadId, cwd, desktopEvidence);
@@ -421,20 +425,20 @@ export function resolveCodexNativeDispatchResponse(binding, { env = process.env,
421
425
  if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
422
426
  !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
423
427
  marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
424
- const found = findRollouts(threadId, env);
425
- if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
426
428
  const outputs = delegationOutputs(executorThreadId, prompt);
427
- const snapshots = found.map((entry) => {
428
- const mark = marks.find(({ file }) => file === entry.file);
429
- const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
430
- if (mark) {
431
- if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
432
- if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
433
- }
434
- validateIdentity(snapshot, threadId, cwd, desktopEvidence);
435
- return snapshot;
429
+ 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");
431
+ return found.map((entry) => {
432
+ const mark = marks.find(({ file }) => file === entry.file);
433
+ 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
+ }
438
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
439
+ return snapshot;
440
+ });
436
441
  });
437
- assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
438
442
  const matches = snapshots.flatMap((snapshot) => snapshot.dispatches.map((dispatch) => ({ snapshot, dispatch })));
439
443
  if (!matches.length) return { status: "pending", threadId, turnId: null, text: "", assistantItems: [], replySha256: null };
440
444
  if (matches.length !== 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
@@ -464,24 +468,24 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
464
468
  || new Set(marks.map((mark) => mark.file)).size !== marks.length
465
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");
466
470
  for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
467
- const found = findRollouts(threadId, env);
468
- if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
469
471
  const outputs = delegationOutputs(executorThreadId, prompt);
470
- let retainedBytes = 0;
471
- const snapshots = found.map((entry) => {
472
- const mark = marks.find(({ file }) => file === entry.file);
473
- const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
474
- retainedBytes += snapshot.retainedBytes;
475
- if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
476
- if (mark) {
477
- if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
478
- if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
479
- }
480
- if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
481
- validateIdentity(snapshot, threadId, cwd, desktopEvidence);
482
- return snapshot;
472
+ 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");
474
+ let retainedBytes = 0;
475
+ return found.map((entry) => {
476
+ const mark = marks.find(({ file }) => file === entry.file);
477
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
478
+ retainedBytes += snapshot.retainedBytes;
479
+ 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
+ }
484
+ if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
485
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
486
+ return snapshot;
487
+ });
483
488
  });
484
- assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
485
489
  const selected = uniqueTurn(snapshots);
486
490
  const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
487
491
  if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { scanRollout } from "./rollout-reader.mjs";
5
- import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
5
+ import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
6
6
 
7
7
  const METADATA_KEY = "x-codex-turn-metadata";
8
8
  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}$/;
@@ -43,14 +43,9 @@ export function findRollout(sessions, threadId) {
43
43
  }
44
44
 
45
45
  export function readState(file, maxBytes) {
46
- for (let attempt = 0; ; attempt++) {
47
- try { return readStateOnce(file, maxBytes); }
48
- catch (error) {
49
- // A live Desktop task can append its tool event during verification.
50
- // Retry the entire scan, never reuse partially observed permissions.
51
- if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
52
- }
53
- }
46
+ // A live Desktop task can append its tool event during verification.
47
+ // Retry the entire scan, never reuse partially observed permissions.
48
+ return retryRolloutRead(() => readStateOnce(file, maxBytes));
54
49
  }
55
50
 
56
51
  function readStateOnce(file, maxBytes) {
@@ -182,15 +177,18 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
182
177
  const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
183
178
  if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
184
179
  const sessions = path.join(configuredHome, "sessions");
185
- const matches = [], snapshots = [];
186
- for (const file of findRolloutSegments(sessions, identity.threadId)) {
187
- const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
188
- if (state.session?.id !== identity.threadId) throw new Error("A rollout segment contradicts the calling task identity");
189
- snapshots.push({ file, version: state.version });
190
- if (state.context?.turn_id === identity.turnId || state.lifecycle?.turn_id === identity.turnId) matches.push({ file, ...state });
191
- if (matches.length > 1) throw new Error("Multiple rollout segments match the calling turn");
192
- }
193
- assertRolloutSetStable(sessions, identity.threadId, snapshots);
180
+ const matches = retryRolloutRead(() => {
181
+ const found = [], snapshots = [];
182
+ for (const file of findRolloutSegments(sessions, identity.threadId)) {
183
+ const state = readStateOnce(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
184
+ if (state.session?.id !== identity.threadId) throw new Error("A rollout segment contradicts the calling task identity");
185
+ snapshots.push({ file, version: state.version });
186
+ if (state.context?.turn_id === identity.turnId || state.lifecycle?.turn_id === identity.turnId) found.push({ file, ...state });
187
+ if (found.length > 1) throw new Error("Multiple rollout segments match the calling turn");
188
+ }
189
+ assertRolloutSetStable(sessions, identity.threadId, snapshots);
190
+ return found;
191
+ });
194
192
  if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollout segments match the calling turn" : "The calling turn is no longer the latest active Codex turn");
195
193
  const { file, session, context, lifecycle } = matches[0];
196
194
  if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
package/src/index.mjs CHANGED
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
40
40
 
41
41
  exitForVersionRequest(import.meta.url);
42
42
 
43
- const VERSION = "1.22.9";
43
+ const VERSION = "1.22.11";
44
44
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
45
45
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
46
46
 
@@ -6,11 +6,14 @@ import { fileURLToPath } from "node:url";
6
6
  import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
7
7
  import { desktopTasksConfigured } from "./native-relay.mjs";
8
8
  import { isMainModule, stableInstallPath } from "./install-paths.mjs";
9
+ import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mjs";
9
10
 
10
11
  const WORKER_NODE = stableInstallPath(process.execPath);
11
12
  const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
12
13
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
13
14
  const MAX_QUEUE = 256;
15
+ const RETIRE_GRACE_MS = 6000;
16
+ const CLIENT_CLOSE_GRACE_MS = CLIENT_CLOSE_DRAIN_MS + CLIENT_CLOSE_STOP_MS + 1000;
14
17
  const hasId = (message) => Object.hasOwn(message, "id");
15
18
  const idKey = (id) => JSON.stringify(id);
16
19
  const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
@@ -111,7 +114,7 @@ class Worker {
111
114
  this.handlers.exit(this, error);
112
115
  }
113
116
 
114
- async stop() {
117
+ async stop(graceMs = RETIRE_GRACE_MS) {
115
118
  this.retiring = true;
116
119
  if (this.stopping) return this.stopping;
117
120
  this.stopping = (async () => {
@@ -123,7 +126,7 @@ class Worker {
123
126
  };
124
127
  this.process.stdin.end();
125
128
  if (this.process.connected) this.process.disconnect();
126
- if (await wait(6000)) return;
129
+ if (await wait(graceMs)) return;
127
130
  if (this.process.exitCode === null && this.process.signalCode === null) {
128
131
  this.process.kill("SIGKILL");
129
132
  }
@@ -324,7 +327,7 @@ export async function runSupervisor(entry, options = {}) {
324
327
  if (stopping) return;
325
328
  stopping = true;
326
329
  clearInterval(timer);
327
- await Promise.allSettled([...workers].map((worker) => worker.stop()));
330
+ await Promise.allSettled([...workers].map((worker) => worker.stop(CLIENT_CLOSE_GRACE_MS)));
328
331
  process.stdin.pause();
329
332
  };
330
333
  const preparationStarted = performance.now();
@@ -28,7 +28,7 @@ import {
28
28
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
29
29
  import { exitForVersionRequest } from "./cli-version.mjs";
30
30
  import { assertAccountIdentity } from "./bridge-account-context.mjs";
31
- import { createReloadControl } from "./reload-control.mjs";
31
+ import { createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
32
32
  import { createRuntimeState } from "./runtime-state.mjs";
33
33
  import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
34
34
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
38
38
 
39
39
  exitForVersionRequest(import.meta.url);
40
40
 
41
- const VERSION = "1.22.9";
41
+ const VERSION = "1.22.11";
42
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
43
 
44
44
  function errorResponse(code, message, sent, details) {
@@ -46,6 +46,9 @@ function errorResponse(code, message, sent, details) {
46
46
  return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...size, ...(sent === false ? { sent: false } : {}) } };
47
47
  }
48
48
 
49
+ const RELAY_RELOADING_REFUSAL = Object.freeze({ code: "RELAY_RELOADING", message: "The native relay is reloading; no message was sent" });
50
+ const RELAY_STOPPING_REFUSAL = Object.freeze({ code: "RELAY_STOPPING", message: "The native relay is shutting down because its MCP client closed; no message was sent" });
51
+
49
52
  function boundRequest(payload) {
50
53
  return payload?.v === ACCOUNT_RELAY_PROTOCOL_VERSION;
51
54
  }
@@ -411,6 +414,7 @@ export class RelaySocketServer {
411
414
  this.connections = new Set();
412
415
  this.handlers = new Set();
413
416
  this.accepting = true;
417
+ this.refusal = RELAY_RELOADING_REFUSAL;
414
418
  this.closed = Promise.resolve();
415
419
  this.processHandlers = new Map();
416
420
  this.aclReady = !IS_WINDOWS || !strict;
@@ -558,7 +562,7 @@ export class RelaySocketServer {
558
562
  }
559
563
  if (!this.accepting) {
560
564
  socket.on("error", () => {});
561
- socket.end(`${JSON.stringify(errorResponse("RELAY_RELOADING", "The native relay is reloading; no message was sent", false))}\n`);
565
+ socket.end(`${JSON.stringify(errorResponse(this.refusal.code, this.refusal.message, false))}\n`);
562
566
  return;
563
567
  }
564
568
  let buffer = Buffer.alloc(0);
@@ -712,10 +716,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
712
716
  const servers = [...new Set(relays)];
713
717
  let startups = [];
714
718
  let ownedSockets = [];
715
- const inspect = () => {
719
+ let stopping = false;
720
+ const deliveryReason = () => {
716
721
  if (servers.some((relay) => relay.handlers.size)) return "Native relay requests are still active";
717
722
  if (servers.some((relay) => relay.connections.size)) return "Native relay clients are still connected";
718
723
  if (nativeTools.pending.size) return "Native Desktop dispatches are still pending";
724
+ return null;
725
+ };
726
+ const inspect = () => {
727
+ const reason = deliveryReason();
728
+ if (reason) return reason;
719
729
  if (nativeTools.connecting || startups.some((startup) => startup.busy)) return "Native relay startup is still pending";
720
730
  return null;
721
731
  };
@@ -729,6 +739,8 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
729
739
  nativeTools.close();
730
740
  };
731
741
  const activate = async () => {
742
+ if (stopping) throw new Error("The native relay is shutting down because its MCP client closed");
743
+ for (const relay of servers) relay.refusal = RELAY_RELOADING_REFUSAL;
732
744
  if (startups.length) {
733
745
  for (const relay of servers) relay.accepting = true;
734
746
  return;
@@ -745,8 +757,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
745
757
  };
746
758
  return {
747
759
  inspect,
760
+ deliveryReason,
748
761
  activate,
749
762
  stop,
763
+ refuseNewDeliveries() {
764
+ stopping = true;
765
+ for (const relay of servers) {
766
+ relay.refusal = RELAY_STOPPING_REFUSAL;
767
+ relay.accepting = false;
768
+ }
769
+ },
750
770
  async quiesce() {
751
771
  for (const relay of servers) relay.accepting = false;
752
772
  const reason = inspect();
@@ -861,6 +881,7 @@ if (invokedDirectly) {
861
881
  if (!reload.staged) void lifecycle.activate().catch((error) => log(`native relay startup failed: ${error.message}`));
862
882
  mcp.server.onclose = () => { void lifecycle.stop(); };
863
883
  await mcp.connect(new StdioServerTransport());
884
+ exitWhenClientCloses({ prepare: () => lifecycle.refuseNewDeliveries(), busy: lifecycle.deliveryReason, stop: lifecycle.stop, log });
864
885
  reload.listen();
865
886
  log(`ready on ${PLATFORM_LABEL} (${strictDesktop ? accountRelay.socketPath : relay.started ? relay.socketPath : "socket down"})`);
866
887
  }
@@ -616,6 +616,7 @@ export class PeerEndpoint {
616
616
  this.connections = new Set();
617
617
  this.activityRevision = 0;
618
618
  this.reloadPaused = false;
619
+ this.intakeClosed = false;
619
620
  this.processHandlersRegistered = false;
620
621
  this.strict = hardenedBridgeEnabled(env);
621
622
  this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
@@ -655,6 +656,7 @@ export class PeerEndpoint {
655
656
 
656
657
  start() {
657
658
  if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
659
+ if (this.intakeClosed) throw new Error("The peer endpoint is shutting down because its MCP client closed");
658
660
  if (this.started) return;
659
661
  if (this.startPromise) return this.startPromise;
660
662
  const starting = this.#startOnce();
@@ -1150,6 +1152,12 @@ export class PeerEndpoint {
1150
1152
  if (revision !== this.activityRevision || this.reloadReason()) throw new Error("Peer activity raced with reload; the endpoint remains quiesced");
1151
1153
  }
1152
1154
 
1155
+ closeIntake() {
1156
+ this.intakeClosed = true;
1157
+ this.#removeRegistration();
1158
+ if (this.server?.listening) this.server.close();
1159
+ }
1160
+
1153
1161
  async resume() {
1154
1162
  this.reloadPaused = false;
1155
1163
  await this.start();
@@ -100,3 +100,38 @@ export function createReloadControl({ entry, inspect = () => null, quiesce = ()
100
100
  },
101
101
  };
102
102
  }
103
+
104
+ export const CLIENT_CLOSE_DRAIN_MS = 35000;
105
+ export const CLIENT_CLOSE_STOP_MS = 5000;
106
+
107
+ export function exitWhenClientCloses({ prepare = () => {}, busy = () => null, stop = () => {}, log = () => {}, drainMs = CLIENT_CLOSE_DRAIN_MS, stopMs = CLIENT_CLOSE_STOP_MS, pollMs = 50, stdin = process.stdin, channel = process, outputs = [process.stdout, process.stderr], exit = (code) => process.exit(code) } = {}) {
108
+ let closing = null;
109
+ const bounded = (promise, ms) => {
110
+ let timer;
111
+ return Promise.race([Promise.resolve(promise).then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })])
112
+ .finally(() => clearTimeout(timer));
113
+ };
114
+ const close = (why) => closing ??= (async () => {
115
+ log(`MCP client closed (${why}); draining before exit`);
116
+ let code = 0;
117
+ try {
118
+ await prepare();
119
+ const deadline = Date.now() + drainMs;
120
+ let reason = busy();
121
+ while (reason && Date.now() < deadline) {
122
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
123
+ reason = busy();
124
+ }
125
+ if (reason) log(`exiting after ${drainMs} ms with work still open: ${reason}`);
126
+ if (!await bounded(stop(), stopMs)) log(`listener shutdown exceeded ${stopMs} ms`);
127
+ } catch (error) {
128
+ code = 1;
129
+ log(`shutdown failed: ${error?.message ?? String(error)}`);
130
+ }
131
+ exit(code);
132
+ })();
133
+ for (const output of outputs) output?.on("error", (error) => { void close(`output failed: ${error?.code ?? error?.message}`); });
134
+ stdin?.once("end", () => { void close("stdin ended"); });
135
+ channel?.once("disconnect", () => { void close("supervisor channel disconnected"); });
136
+ return { close, get closing() { return closing !== null; } };
137
+ }
@@ -106,8 +106,13 @@ export class ReplyForwarder {
106
106
  return { ...counts, attempts, maxPerSession: this.maxPerSession, closed: this.closed };
107
107
  }
108
108
 
109
+ forwardingReason() {
110
+ return this.active || this.queue.length || this.timer !== null ? "Reply forwarding is still active or queued" : null;
111
+ }
112
+
109
113
  reloadReason() {
110
- if (this.active || this.queue.length || this.timer !== null) return "Reply forwarding is still active or queued";
114
+ const forwarding = this.forwardingReason();
115
+ if (forwarding) return forwarding;
111
116
  if ([...this.records.values()].some(({ receipt }) => ["queued", "sending", "unknown"].includes(receipt.status))) {
112
117
  return "A reply forwarding outcome remains unconfirmed";
113
118
  }
@@ -55,6 +55,33 @@ export function isLegacyCliSession(session) {
55
55
  return session?.originator === "codex-tui" && session.source === "cli";
56
56
  }
57
57
 
58
+ const TRANSIENT_ROLLOUT_READ = /changed while (reading|opening)|incomplete final record/;
59
+ const ROLLOUT_CHANGED_DURING_READ = /changed while (reading|opening)/;
60
+ const ROLLOUT_READ_ATTEMPTS = 3;
61
+
62
+ function reasonText(reason) {
63
+ return typeof reason === "string" ? reason : reason?.message;
64
+ }
65
+
66
+ export function isTransientRolloutRead(reason) {
67
+ const message = reasonText(reason);
68
+ return typeof message === "string" && TRANSIENT_ROLLOUT_READ.test(message);
69
+ }
70
+
71
+ export function isRolloutChangedDuringRead(reason) {
72
+ const message = reasonText(reason);
73
+ return typeof message === "string" && ROLLOUT_CHANGED_DURING_READ.test(message);
74
+ }
75
+
76
+ export function retryRolloutRead(read, attempts = ROLLOUT_READ_ATTEMPTS) {
77
+ for (let attempt = 1; ; attempt++) {
78
+ try { return read(); }
79
+ catch (error) {
80
+ if (attempt >= attempts || !isTransientRolloutRead(error)) throw error;
81
+ }
82
+ }
83
+ }
84
+
58
85
  export function assertRolloutSetStable(sessions, threadId, snapshots) {
59
86
  const files = findRolloutSegments(sessions, threadId);
60
87
  if (files.length !== snapshots.length || files.some((file, i) => file !== snapshots[i].file)) throw new Error("The rollout segment set changed while reading");
@@ -7,6 +7,7 @@ import { creationRequestHash } from "./native-creation-receipts.mjs";
7
7
  import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
8
8
  import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse, resolveCodexNativeDispatchResponse } from "./codex-native-response.mjs";
9
9
  import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
10
+ import { isRolloutChangedDuringRead } from "./rollout-segments.mjs";
10
11
  import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
11
12
 
12
13
  /**
@@ -550,6 +551,8 @@ export class DesktopTaskDelivery {
550
551
  let cursor;
551
552
  let turnId = null;
552
553
  let readSnapshots = false;
554
+ let deferredReason = null;
555
+ let deferredTurn = null;
553
556
  const readSnapshot = async () => {
554
557
  const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
555
558
  const thread = result?.thread;
@@ -560,19 +563,24 @@ export class DesktopTaskDelivery {
560
563
  if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
561
564
  return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
562
565
  };
563
- const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
566
+ const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", ...(deferredReason ? { observationStatus: "unavailable", observationReason: deferredReason } : {}), assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
564
567
  const deadlineError = (error) => {
565
568
  const cause = error.cause ?? error;
566
569
  return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
567
570
  cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
568
571
  };
569
- const unassigned = Symbol("workspace-unassigned");
572
+ const observeLater = Symbol("observe-later");
570
573
  const observe = async (selectedTurnId, resolveDispatch = false) => {
571
574
  try {
572
- return await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
575
+ const observed = await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
576
+ deferredReason = observed?.status === "unavailable" && isRolloutChangedDuringRead(observed.reason) ? observed.reason : null;
577
+ return deferredReason ? observeLater : observed;
573
578
  } catch (error) {
574
579
  if (deadlineError(error)) return null;
575
- if (error.code === WORKSPACE_UNASSIGNED) return unassigned;
580
+ if (error.code === WORKSPACE_UNASSIGNED) {
581
+ deferredReason = null;
582
+ return observeLater;
583
+ }
576
584
  throw error;
577
585
  }
578
586
  };
@@ -611,7 +619,7 @@ export class DesktopTaskDelivery {
611
619
  if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
612
620
  const observed = await observe(null, true);
613
621
  if (!observed) return expired();
614
- if (observed === unassigned) {
622
+ if (observed === observeLater) {
615
623
  await pause();
616
624
  continue;
617
625
  }
@@ -634,17 +642,20 @@ export class DesktopTaskDelivery {
634
642
  continue;
635
643
  }
636
644
  if (needsAttention) return attention();
637
- const existingTurn = turn?.id === previousTurnId;
638
- if (turn?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
639
- if (!existingTurn) turnId = turn.id;
640
- const status = turn.status;
645
+ const released = deferredTurn ?? turn;
646
+ const existingTurn = released?.id === previousTurnId;
647
+ if (released?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
648
+ if (!existingTurn) turnId = released.id;
649
+ const status = released.status;
641
650
  if (RELEASE_STATUSES.has(status)) {
642
- const observed = await observe(turn.id);
651
+ const observed = await observe(released.id);
643
652
  if (!observed) return expired();
644
- if (observed === unassigned) {
653
+ if (observed === observeLater) {
654
+ deferredTurn = deferredReason ? released : null;
645
655
  await pause();
646
656
  continue;
647
657
  }
658
+ deferredTurn = null;
648
659
  const responseStatus = observed.status;
649
660
  if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
650
661
  turnId = null;
@@ -652,7 +663,7 @@ export class DesktopTaskDelivery {
652
663
  await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
653
664
  continue;
654
665
  }
655
- turnId = turn.id;
666
+ turnId = released.id;
656
667
  return {
657
668
  threadId, turnId, status,
658
669
  text: responseStatus === "completed" ? observed.text : "",
@@ -663,7 +674,7 @@ export class DesktopTaskDelivery {
663
674
  replySha256: observed.replySha256 ?? null,
664
675
  responseSource: observed.source ?? null,
665
676
  ...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
666
- activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt,
677
+ activity: [], errors: released.error ? [released.error] : [], durationMs: released.durationMs ?? this.now() - startedAt,
667
678
  };
668
679
  }
669
680
  }