@minhspark/codex-mcp-bridge 1.22.8 → 1.22.10

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.8",
3
+ "version": "1.22.10",
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.10] - 2026-10-20
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - 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.
13
+
14
+ ### Verification
15
+
16
+ - 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.
17
+ - 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.
18
+
19
+ ### Limitations
20
+
21
+ - 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.
22
+
23
+ ## [1.22.9] - 2026-10-20
24
+
25
+ ### Fixed
26
+
27
+ - Read the Claude Desktop configuration with exact bigint stats in `codex-mcp-bridge-install`, so a configuration file replaced during the installer's read is detected even when the two inodes differ only beyond Number precision. v1.22.8 converted the runtime stores but missed this installer.
28
+ - Notice a new reply in a Claude Code transcript when the file changes within the same millisecond and keeps its size. The reply poll now compares the file's device, inode, nanosecond modification and change times, and size instead of only the millisecond modification time and size.
29
+
30
+ ### Verification
31
+
32
+ - 2 new regression tests: a Desktop configuration whose inode changes only below Number precision (`2^60` and `2^60 + 1`) is rejected and left untouched, and a transcript reply is observed while its Number `mtimeMs` and size stay frozen. Both fail on v1.22.8.
33
+ - Two independent sweeps of `src/`, `scripts/` and `bin/`, one by stat API and one by stored or compared stat data, found no other Number-based identity or change detection.
34
+ - CI passed on macOS, Ubuntu and Windows with Node 22 and 24. CodeQL re-reported 3 `js/path-injection` flows on the edited stat lines; each source is operator environment configuration, matching earlier dismissals, and they were dismissed as false positives.
35
+
36
+ ### Limitations
37
+
38
+ - The 64-bit NTFS case runs for real only on Windows CI; local tests simulate it with mocked stats.
39
+
7
40
  ## [1.22.8] - 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.8",
3
+ "version": "1.22.10",
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.8",
4
+ "version": "1.22.10",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -45,23 +45,23 @@ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
45
45
  function readConfig() {
46
46
  let descriptor;
47
47
  try {
48
- const before = fs.lstatSync(cfgPath);
49
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 4 * 1024 * 1024) throw new Error("Unsafe Desktop configuration file");
48
+ const before = fs.lstatSync(cfgPath, { bigint: true });
49
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > 4n * 1024n * 1024n) throw new Error("Unsafe Desktop configuration file");
50
50
  descriptor = fs.openSync(cfgPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
51
- const opened = fs.fstatSync(descriptor);
52
- const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
51
+ const opened = fs.fstatSync(descriptor, { bigint: true });
52
+ const fields = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
53
53
  const matches = (left, right) => fields.every(field => left[field] === right[field]);
54
- if (!opened.isFile() || opened.nlink !== 1 || !matches(before, opened)) throw new Error("Desktop configuration changed during access");
55
- const data = Buffer.alloc(opened.size);
54
+ if (!opened.isFile() || opened.nlink !== 1n || !matches(before, opened)) throw new Error("Desktop configuration changed during access");
55
+ const data = Buffer.alloc(Number(opened.size));
56
56
  let offset = 0;
57
57
  while (offset < data.length) {
58
58
  const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
59
59
  if (!count) throw new Error("Desktop configuration changed during access");
60
60
  offset += count;
61
61
  }
62
- const after = fs.fstatSync(descriptor);
63
- const current = fs.lstatSync(cfgPath);
64
- if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !matches(opened, after) || !matches(after, current)) throw new Error("Desktop configuration changed during access");
62
+ const after = fs.fstatSync(descriptor, { bigint: true });
63
+ const current = fs.lstatSync(cfgPath, { bigint: true });
64
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1n || !matches(opened, after) || !matches(after, current)) throw new Error("Desktop configuration changed during access");
65
65
  return { data, version: fields.map(field => current[field]) };
66
66
  } catch (error) {
67
67
  if (error.code === "ENOENT" && descriptor === undefined) return null;
@@ -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.8";
27
+ const VERSION = "1.22.10";
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"];
@@ -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.8";
33
+ const VERSION = "1.22.10";
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;
@@ -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.8";
43
+ const VERSION = "1.22.10";
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
 
@@ -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.8";
41
+ const VERSION = "1.22.10";
42
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
43
 
44
44
  function errorResponse(code, message, sent, details) {
@@ -860,7 +860,7 @@ export class PeerEndpoint {
860
860
  const { sessionId, cwd } = pending.transcriptSession;
861
861
  const file = findTranscriptFile(sessionId, cwd);
862
862
  let stamp;
863
- try { const stat = fs.statSync(file); stamp = `${stat.mtimeMs}:${stat.size}`; }
863
+ try { const stat = fs.statSync(file, { bigint: true }); stamp = `${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`; }
864
864
  catch { continue; }
865
865
  if (stamp === pending.transcriptStamp) continue;
866
866
  pending.transcriptStamp = stamp;
@@ -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
  }