@minhspark/codex-mcp-bridge 1.26.8 → 1.26.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.
@@ -284,6 +284,7 @@ export function assertClaudeSessionProcess(session) {
284
284
  * rather than by reconstructing the slug.
285
285
  */
286
286
  function findTranscriptFile(sessionId, cwd) {
287
+ if (typeof sessionId !== "string" || !sessionId || sessionId.length > 128 || /[^a-zA-Z0-9_-]/.test(sessionId)) return null;
287
288
  const dir = projectsDir();
288
289
  const guess = path.join(dir, String(cwd ?? "").replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
289
290
  if (fs.existsSync(guess)) return guess;
@@ -297,7 +298,7 @@ function findTranscriptFile(sessionId, cwd) {
297
298
 
298
299
  export function readTranscript(sessionId, cwd, limit = 10) {
299
300
  const file = findTranscriptFile(sessionId, cwd);
300
- if (!fs.existsSync(file)) return { file, messages: [] };
301
+ if (!file || !fs.existsSync(file)) return { file, messages: [] };
301
302
  const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
302
303
  const messages = [];
303
304
  for (const line of lines) {
@@ -325,7 +326,7 @@ export function readTranscript(sessionId, cwd, limit = 10) {
325
326
 
326
327
  export function readInitialTranscriptMessage(sessionId, cwd) {
327
328
  const file = findTranscriptFile(sessionId, cwd);
328
- if (!fs.existsSync(file)) return { file, messages: [] };
329
+ if (!file || !fs.existsSync(file)) return { file, messages: [] };
329
330
  const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
330
331
  try {
331
332
  const stat = fs.fstatSync(descriptor);
@@ -388,6 +389,46 @@ function isTerminalDelivery(receipt) {
388
389
  return ["refused", "denied", "expired", "dropped"].includes(receipt?.status) || isInterruptedDelivery(receipt);
389
390
  }
390
391
 
392
+ function resumableTranscriptDelivery(id, sent, receipt) {
393
+ const uuid = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
394
+ const session = sent?.transcriptSession;
395
+ return Boolean(sent) && uuid(id) && !sent.reply && !sent.failed && !isTerminalDelivery(receipt)
396
+ && Number.isFinite(sent.sentAt) && (!receipt || receipt.status === "delivered" && receipt.fromSocket === sent.targetSocket)
397
+ && session?.entrypoint === "claude-desktop" && uuid(session.sessionId)
398
+ && typeof session.cwd === "string" && path.isAbsolute(session.cwd)
399
+ && typeof sent.targetSocket === "string" && sent.targetSocket && session.socket === sent.targetSocket
400
+ && session.desktop?.status === "matched" && typeof session.desktop.taskId === "string"
401
+ && session.desktop.taskId.startsWith("local_") && uuid(session.desktop.taskId.slice(6))
402
+ && session.desktop.cwd === session.cwd
403
+ && /^[a-f0-9]{64}$/i.test(sent.accountContext?.claude ?? "") && /^[a-f0-9]{64}$/i.test(sent.accountContext?.codex ?? "")
404
+ && session.desktop.accountFingerprint === sent.accountContext.claude
405
+ && hasTranscriptDeliveryRoot(session.sessionId, session.cwd, id);
406
+ }
407
+
408
+ function hasTranscriptDeliveryRoot(sessionId, cwd, msgId) {
409
+ const file = findTranscriptFile(sessionId, cwd);
410
+ if (!file) return false;
411
+ let lines;
412
+ try { lines = fs.readFileSync(file, "utf8").split("\n"); }
413
+ catch { return false; }
414
+ let roots = 0;
415
+ const ids = new Set();
416
+ for (const line of lines) {
417
+ if (!line.trim()) continue;
418
+ let entry;
419
+ try { entry = JSON.parse(line); } catch { return false; }
420
+ if (!entry || typeof entry !== "object" || entry.isSidechain) continue;
421
+ if (typeof entry.uuid === "string") {
422
+ if (ids.has(entry.uuid)) return false;
423
+ ids.add(entry.uuid);
424
+ }
425
+ if (!injectedMessageRoot(entry, msgId)) continue;
426
+ if (entry.entrypoint !== "claude-desktop" || entry.sessionId !== sessionId || entry.cwd !== cwd) return false;
427
+ roots++;
428
+ }
429
+ return roots === 1;
430
+ }
431
+
391
432
  export function readTranscriptReply(sessionId, cwd, msgId) {
392
433
  return readTranscriptOutcome(sessionId, cwd, msgId)?.reply ?? null;
393
434
  }
@@ -460,6 +501,7 @@ function compactContinuation(entry, root, entriesById, transcriptIds, sessionId,
460
501
  // user turn's answer to the original request or claim its side effects stopped.
461
502
  export function readTranscriptOutcome(sessionId, cwd, msgId) {
462
503
  const file = findTranscriptFile(sessionId, cwd);
504
+ if (!file) return null;
463
505
  let lines;
464
506
  try { lines = fs.readFileSync(file, "utf8").split("\n"); }
465
507
  catch { return null; }
@@ -509,6 +551,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
509
551
  }
510
552
  }
511
553
  const descendants = new Map();
554
+ const monitorTasksByEntry = new Map();
512
555
  const backgroundCalls = new Map();
513
556
  const openLeaves = new Set();
514
557
  const passiveLeaves = new Set();
@@ -520,7 +563,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
520
563
  interruptions.push({ entryId: entry.uuid, backgroundTaskIds: [...outstanding.keys()] });
521
564
  };
522
565
  for (const entry of orderedEntries) {
523
- if (entry === root) { descendants.set(entry.uuid, new Map()); openLeaves.add(entry.uuid); continue; }
566
+ if (entry === root) { descendants.set(entry.uuid, new Map()); monitorTasksByEntry.set(entry.uuid, new Set()); openLeaves.add(entry.uuid); continue; }
524
567
  let parentUuid = entry.parentUuid;
525
568
  if (!ambiguous && !interruptions.length && descendants.has(entry.logicalParentUuid)
526
569
  && openLeaves.has(entry.logicalParentUuid)) {
@@ -549,6 +592,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
549
592
  const role = entry.message?.role;
550
593
  const content = entry.message?.content;
551
594
  const outstanding = new Map(descendants.get(parentUuid));
595
+ const monitorTasks = new Set(monitorTasksByEntry.get(parentUuid));
552
596
  const toolResults = Array.isArray(content) && content.length > 0 && content.every((part) => part?.type === "tool_result");
553
597
  const imageCompanion = isImageToolCompanion(entry, entriesById);
554
598
  if (role === "user" && !toolResults && !imageCompanion && !compactSummaries.has(entry.uuid)) {
@@ -575,13 +619,20 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
575
619
  }
576
620
  if (typeof taskNotification === "string" && /^<task-notification>\s[\s\S]*<\/task-notification>$/.test(taskNotification.trim())) {
577
621
  const field = (name) => {
622
+ if (taskNotification.split(`<${name}>`).length !== 2 || taskNotification.split(`</${name}>`).length !== 2) return null;
578
623
  const matches = [...taskNotification.matchAll(new RegExp(`<${name}>([^<>]+)</${name}>`, "g"))];
579
624
  return matches.length === 1 ? matches[0][1] : null;
580
625
  };
581
626
  const taskId = field("task-id");
627
+ const hasField = name => taskNotification.split(`<${name}>`).length === 2
628
+ && taskNotification.split(`</${name}>`).length === 2
629
+ && new RegExp(`<${name}>[\\s\\S]+?</${name}>`).test(taskNotification);
630
+ const monitorProgress = monitorTasks.has(taskId) && !outstanding.has(taskId)
631
+ && hasField("summary") && hasField("event")
632
+ && !/<\/?(?:status|tool-use-id)(?:\s|>)/.test(taskNotification);
582
633
  if (!outstanding.has(taskId) || outstanding.get(taskId) !== field("tool-use-id")
583
634
  || !["completed", "failed", "killed"].includes(field("status"))) {
584
- if (role === "user") continue;
635
+ if (role === "user" && !monitorProgress) continue;
585
636
  } else outstanding.delete(taskId);
586
637
  }
587
638
  if (typeof entry.uuid !== "string" || !entry.uuid.trim()) continue;
@@ -600,6 +651,18 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
600
651
  // Parallel tool results and hook records can be leaf siblings of the real
601
652
  // continuation. They are completed observations, not separate reply turns.
602
653
  const parentEntry = entriesById.get(entry.parentUuid);
654
+ const monitorTaskId = entry.toolUseResult?.taskId;
655
+ if (role === "user" && toolResults && content.length === 1 && typeof monitorTaskId === "string" && /^[\w-]{1,128}$/.test(monitorTaskId)
656
+ && entry.sourceToolAssistantUUID === parentEntry?.uuid
657
+ && parentEntry?.message?.role === "assistant"
658
+ && Number.isSafeInteger(entry.toolUseResult.timeoutMs) && entry.toolUseResult.timeoutMs > 0
659
+ && typeof entry.toolUseResult.persistent === "boolean") {
660
+ const matches = content.filter(part => !part.is_error && typeof part.tool_use_id === "string" && part.tool_use_id
661
+ && Array.isArray(parentEntry.message.content)
662
+ && parentEntry.message.content.filter(call => call.type === "tool_use" && call.name === "Monitor" && call.id === part.tool_use_id).length === 1);
663
+ if (matches.length === 1) monitorTasks.add(monitorTaskId);
664
+ }
665
+ monitorTasksByEntry.set(entry.uuid, monitorTasks);
603
666
  const matchedResults = toolResults && !entry.toolUseResult?.backgroundTaskId
604
667
  && content.every((part) => Array.isArray(parentEntry?.message?.content)
605
668
  && parentEntry.message.content.some((call) => call.type === "tool_use" && call.id === part.tool_use_id));
@@ -654,6 +717,7 @@ export class PeerEndpoint {
654
717
  this.sentMessages = new Map();
655
718
  this.pendingMessages = new Map();
656
719
  this.responsePoll = null;
720
+ this.transcriptPollingPaused = false;
657
721
  this.connections = new Set();
658
722
  this.activityRevision = 0;
659
723
  this.reloadPaused = false;
@@ -779,6 +843,8 @@ export class PeerEndpoint {
779
843
  }
780
844
 
781
845
  this.started = true;
846
+ this.transcriptPollingPaused = false;
847
+ this.#startTranscriptPolling();
782
848
  this.log(`peer "${this.name}" listening on ${this.socketPath}`);
783
849
  }
784
850
 
@@ -903,11 +969,20 @@ export class PeerEndpoint {
903
969
  }
904
970
  }
905
971
 
972
+ #startTranscriptPolling() {
973
+ if (this.reloadPaused || this.transcriptPollingPaused || this.responsePoll
974
+ || ![...this.pendingMessages.values()].some(entry => entry.transcriptSession)) return;
975
+ this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
976
+ this.responsePoll.unref();
977
+ }
978
+
906
979
  #refreshTranscriptReplies() {
980
+ if (this.reloadPaused || this.transcriptPollingPaused) return;
907
981
  for (const [msgId, pending] of this.pendingMessages) {
908
982
  if (!pending.transcriptSession) continue;
909
983
  const { sessionId, cwd } = pending.transcriptSession;
910
984
  const file = findTranscriptFile(sessionId, cwd);
985
+ if (!file) continue;
911
986
  let stamp;
912
987
  try { const stat = fs.statSync(file, { bigint: true }); stamp = `${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`; }
913
988
  catch { continue; }
@@ -1051,6 +1126,7 @@ export class PeerEndpoint {
1051
1126
  );
1052
1127
  error.code = "PEER_REPLY_PENDING";
1053
1128
  error.sent = false;
1129
+ error.msgId = [...this.pendingMessages].find(([, entry]) => entry.targetSocket === targetSocket)?.[0];
1054
1130
  throw error;
1055
1131
  }
1056
1132
  const since = Date.now();
@@ -1059,10 +1135,7 @@ export class PeerEndpoint {
1059
1135
  let msgId = crypto.randomUUID();
1060
1136
  this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
1061
1137
  this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}), ...(scopeBindings ? { scopeBindings: Object.freeze(structuredClone(scopeBindings)) } : {}) });
1062
- if (transcriptSession && !this.responsePoll) {
1063
- this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
1064
- this.responsePoll.unref();
1065
- }
1138
+ this.#startTranscriptPolling();
1066
1139
  try {
1067
1140
  const sentId = await this.send(targetSocket, text, { priority, msgId, permissionMode, beforeSend });
1068
1141
  if (sentId !== msgId) {
@@ -1191,9 +1264,21 @@ export class PeerEndpoint {
1191
1264
  reloadReason() {
1192
1265
  this.#refreshTranscriptReplies();
1193
1266
  if (this.connections.size) return "Peer socket connections are still open";
1194
- if (this.requestQueues.size || this.pendingMessages.size || this.unconfirmedReplies.size) return "Claude messages still have pending or unconfirmed delivery";
1267
+ if (this.requestQueues.size) return "Claude messages are still being sent";
1268
+ const counts = new Map();
1269
+ for (const [id, pending] of this.pendingMessages) {
1270
+ const sent = this.sentMessages.get(id);
1271
+ if (!resumableTranscriptDelivery(id, sent, this.deliveryReceipts.get(id))
1272
+ || pending.targetSocket !== sent.targetSocket || JSON.stringify(pending.transcriptSession) !== JSON.stringify(sent.transcriptSession)) {
1273
+ return "Claude messages still have pending or unconfirmed delivery";
1274
+ }
1275
+ counts.set(sent.targetSocket, (counts.get(sent.targetSocket) ?? 0) + 1);
1276
+ }
1277
+ if (counts.size !== this.unconfirmedReplies.size || [...counts].some(([socket, count]) => this.unconfirmedReplies.get(socket) !== count)) {
1278
+ return "Claude messages still have pending or unconfirmed delivery";
1279
+ }
1195
1280
  for (const [id, sent] of this.sentMessages) {
1196
- if (!sent.reply && !sent.failed && !isTerminalDelivery(this.deliveryReceipts.get(id))) {
1281
+ if (!sent.reply && !sent.failed && !isTerminalDelivery(this.deliveryReceipts.get(id)) && !this.pendingMessages.has(id)) {
1197
1282
  return "A Claude delivery outcome remains unconfirmed";
1198
1283
  }
1199
1284
  }
@@ -1243,14 +1328,17 @@ export class PeerEndpoint {
1243
1328
  const reason = this.reloadReason();
1244
1329
  if (reason || this.started && !this.reloadPaused) throw new Error(reason ?? "Quiesce the peer endpoint before exporting state");
1245
1330
  return cloneReloadState({ name: this.name, messageSequence: this.messageSequence, inbox: this.inbox,
1246
- sentMessages: [...this.sentMessages], deliveryReceipts: [...this.deliveryReceipts] });
1331
+ sentMessages: [...this.sentMessages], deliveryReceipts: [...this.deliveryReceipts],
1332
+ pendingMessages: [...this.pendingMessages].map(([id, pending]) => [id, { targetSocket: pending.targetSocket, transcriptSession: pending.transcriptSession }]) });
1247
1333
  }
1248
1334
 
1249
1335
  restoreReloadState(input) {
1250
- if (this.started || this.connections.size || this.sentMessages.size || this.inbox.length) throw new Error("Peer reload state requires an empty dormant endpoint");
1336
+ if (this.started || this.connections.size || this.sentMessages.size || this.inbox.length || this.pendingMessages.size
1337
+ || this.unconfirmedReplies.size || this.requestQueues.size || this.responsePoll) throw new Error("Peer reload state requires an empty dormant endpoint");
1251
1338
  const state = cloneReloadState(input);
1252
1339
  if (!state || typeof state.name !== "string" || !state.name || !Number.isSafeInteger(state.messageSequence) || state.messageSequence < 0
1253
- || !Array.isArray(state.inbox) || !Array.isArray(state.sentMessages) || !Array.isArray(state.deliveryReceipts)) throw new Error("Invalid peer reload state");
1340
+ || !Array.isArray(state.inbox) || !Array.isArray(state.sentMessages) || !Array.isArray(state.deliveryReceipts)
1341
+ || state.pendingMessages !== undefined && !Array.isArray(state.pendingMessages)) throw new Error("Invalid peer reload state");
1254
1342
  const restoreMap = (entries) => {
1255
1343
  const restored = new Map();
1256
1344
  for (const pair of entries) {
@@ -1262,6 +1350,17 @@ export class PeerEndpoint {
1262
1350
  };
1263
1351
  const sentMessages = restoreMap(state.sentMessages);
1264
1352
  const deliveryReceipts = restoreMap(state.deliveryReceipts);
1353
+ const pendingMessages = restoreMap(state.pendingMessages ?? []);
1354
+ const unconfirmedReplies = new Map();
1355
+ for (const [id, pending] of pendingMessages) {
1356
+ const sent = sentMessages.get(id);
1357
+ if (!resumableTranscriptDelivery(id, sent, deliveryReceipts.get(id))
1358
+ || pending.targetSocket !== sent.targetSocket || JSON.stringify(pending.transcriptSession) !== JSON.stringify(sent.transcriptSession)) {
1359
+ throw new Error("Invalid pending Claude transcript reload record");
1360
+ }
1361
+ delete pending.transcriptStamp;
1362
+ unconfirmedReplies.set(sent.targetSocket, (unconfirmedReplies.get(sent.targetSocket) ?? 0) + 1);
1363
+ }
1265
1364
  for (const record of state.inbox) {
1266
1365
  if (!record || typeof record.text !== "string" || !Number.isSafeInteger(record.sequence) || record.sequence < 1
1267
1366
  || record.sequence > state.messageSequence || !Number.isFinite(record.receivedAt)) throw new Error("Invalid inbox reload record");
@@ -1269,7 +1368,7 @@ export class PeerEndpoint {
1269
1368
  for (const [id, sent] of sentMessages) {
1270
1369
  if (typeof sent.targetSocket !== "string" || !sent.targetSocket || !Number.isFinite(sent.sentAt)
1271
1370
  || sent.reply && (typeof sent.reply.text !== "string" || sent.reply.inReplyTo !== id)
1272
- || !sent.reply && !sent.failed && !isTerminalDelivery(deliveryReceipts.get(id))
1371
+ || !sent.reply && !sent.failed && !isTerminalDelivery(deliveryReceipts.get(id)) && !pendingMessages.has(id)
1273
1372
  || isInterruptedDelivery(deliveryReceipts.get(id)) && deliveryReceipts.get(id).inReplyTo !== id) {
1274
1373
  throw new Error("Invalid or unconfirmed Claude delivery reload record");
1275
1374
  }
@@ -1279,6 +1378,9 @@ export class PeerEndpoint {
1279
1378
  this.inbox = state.inbox;
1280
1379
  this.sentMessages = sentMessages;
1281
1380
  this.deliveryReceipts = deliveryReceipts;
1381
+ this.pendingMessages = pendingMessages;
1382
+ this.unconfirmedReplies = unconfirmedReplies;
1383
+ this.transcriptPollingPaused = true;
1282
1384
  }
1283
1385
 
1284
1386
  #removeRegistration() {
package/src/platform.mjs CHANGED
@@ -88,9 +88,10 @@ function workspaceRoots() {
88
88
  }
89
89
 
90
90
  function isRunnable(candidate) {
91
- if (!candidate || !existsSync(candidate)) return false;
92
- if (IS_WINDOWS) return true;
91
+ if (typeof candidate !== "string" || !candidate || candidate.includes("\0")) return false;
93
92
  try {
93
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) return false;
94
+ if (IS_WINDOWS) return true;
94
95
  accessSync(candidate, constants.X_OK);
95
96
  return true;
96
97
  } catch {
@@ -98,9 +99,9 @@ function isRunnable(candidate) {
98
99
  }
99
100
  }
100
101
 
101
- function windowsCodexCandidates() {
102
- const roaming = process.env.APPDATA;
103
- const local = process.env.LOCALAPPDATA;
102
+ function windowsCodexCandidates(env, runnable) {
103
+ const roaming = env.APPDATA;
104
+ const local = env.LOCALAPPDATA;
104
105
  const versionedRoot = local && path.join(local, "OpenAI", "Codex", "bin");
105
106
  let versioned = [];
106
107
  if (versionedRoot) {
@@ -108,7 +109,7 @@ function windowsCodexCandidates() {
108
109
  versioned = readdirSync(versionedRoot, { withFileTypes: true })
109
110
  .filter((entry) => entry.isDirectory())
110
111
  .map((entry) => path.join(versionedRoot, entry.name, "codex.exe"))
111
- .filter((candidate) => isRunnable(candidate))
112
+ .filter((candidate) => runnable(candidate))
112
113
  .sort((left, right) => {
113
114
  try {
114
115
  return statSync(path.dirname(right)).mtimeMs - statSync(path.dirname(left)).mtimeMs;
@@ -119,16 +120,16 @@ function windowsCodexCandidates() {
119
120
  } catch {}
120
121
  }
121
122
  return [
122
- process.env.CODEX_CLI_PATH,
123
+ env.CODEX_CLI_PATH,
123
124
  ...versioned,
124
125
  local && path.join(local, "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
125
126
  roaming && path.join(roaming, "npm", "codex.cmd"),
126
- process.env.ProgramFiles && path.join(process.env.ProgramFiles, "nodejs", "codex.cmd"),
127
+ env.ProgramFiles && path.join(env.ProgramFiles, "nodejs", "codex.cmd"),
127
128
  ];
128
129
  }
129
130
 
130
- function unixCodexCandidates() {
131
- const home = homeDir();
131
+ function unixCodexCandidates(env) {
132
+ const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
132
133
  return [
133
134
  path.join(home, ".local", "bin", "codex"),
134
135
  path.join(home, ".npm-global", "bin", "codex"),
@@ -147,17 +148,17 @@ function unixCodexCandidates() {
147
148
  * two versions write that same state, so prefer the app's binary whenever the
148
149
  * app is installed and fall back to the standalone CLI installs otherwise.
149
150
  */
150
- function macosCodexCandidates() {
151
+ function macosCodexCandidates(env) {
151
152
  return [
152
153
  hasCodexDesktopApp() ? CODEX_DESKTOP_BIN_MACOS : null,
153
- ...unixCodexCandidates(),
154
+ ...unixCodexCandidates(env),
154
155
  ];
155
156
  }
156
157
 
157
- function pathCandidates() {
158
- const separator = IS_WINDOWS ? ";" : ":";
159
- const names = IS_WINDOWS ? ["codex.exe", "codex.cmd"] : ["codex"];
160
- return (process.env.PATH ?? "")
158
+ function pathCandidates(env, platform) {
159
+ const separator = platform === "win32" ? ";" : ":";
160
+ const names = platform === "win32" ? ["codex.exe", "codex.cmd"] : ["codex"];
161
+ return (env.PATH ?? "")
161
162
  .split(separator)
162
163
  .filter(Boolean)
163
164
  .flatMap((dir) => names.map((name) => path.join(dir, name)));
@@ -217,8 +218,8 @@ export function resolveCodexBin(explicit, {
217
218
  explicit,
218
219
  env.CODEX_BIN,
219
220
  ...(discovered ?? [
220
- ...(IS_WINDOWS ? windowsCodexCandidates() : IS_MACOS ? macosCodexCandidates() : unixCodexCandidates()),
221
- ...pathCandidates(),
221
+ ...(platform === "win32" ? windowsCodexCandidates(env, runnable) : platform === "darwin" ? macosCodexCandidates(env) : unixCodexCandidates(env)),
222
+ ...pathCandidates(env, platform),
222
223
  ]),
223
224
  ].filter(Boolean);
224
225
 
@@ -55,8 +55,9 @@ export function bridgeDiagnostic(error, options = {}) {
55
55
  phase: name(previous?.phase ?? options.phase ?? error?.phase ?? error?.diagnostic?.stage, deliveryState === "not_sent" ? "preflight" : options.readOnly ? "inspection" : "delivery"),
56
56
  deliveryState,
57
57
  sent: deliveryState === "not_sent" ? false : deliveryState === "sent" ? true : null,
58
- retrySafe: deliveryState === "not_sent",
59
- nextAction: deliveryState === "not_sent" ? "Inspect the reported cause and bridge status before retrying. Do not open or create chats to load a relay."
58
+ retrySafe: deliveryState === "not_sent" && code !== "PEER_REPLY_PENDING",
59
+ nextAction: code === "PEER_REPLY_PENDING" ? "Read read_claude_delivery with the original msgId. Wait for its correlated outcome; do not resend or start another bridge to bypass pending work."
60
+ : deliveryState === "not_sent" ? "Inspect the reported cause and bridge status before retrying. Do not open or create chats to load a relay."
60
61
  : deliveryState === "not_applicable" ? "Inspect bridge status and include this diagnostic in the report."
61
62
  : "Inspect the original delivery receipt. Do not resend automatically or create a replacement chat.",
62
63
  reason: diagnosticText(previous?.reason ?? error?.preflight?.reason ?? error?.message ?? error),
package/src/setup.mjs CHANGED
@@ -8,6 +8,7 @@ import { resolveCodexBin, resolvePackagedCodexBin } from "./platform.mjs";
8
8
  import { readRegularConfigFile } from "./project-policy.mjs";
9
9
  import { stableInstallPath } from "./install-paths.mjs";
10
10
  import { resolveClaudeDesktopConfigRoot } from "./desktop-account-context.mjs";
11
+ import { CODEX_MCP_TIMEOUT_MS } from "./codex-mcp-registration.mjs";
11
12
 
12
13
  const ROOT = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
13
14
  const CODEX_PACKAGE = "@openai/codex@0.166.0";
@@ -64,7 +65,7 @@ export function setupFailureSummary(value) {
64
65
  }
65
66
 
66
67
  export function setupArguments(args) {
67
- const allowed = new Set(["--postinstall", "--check", "--dry-run", "--json", "--no-download", "--help"]);
68
+ const allowed = new Set(["--postinstall", "--check", "--dry-run", "--json", "--no-download", "--progress", "--help"]);
68
69
  if (args.some((arg) => !allowed.has(arg))) throw new Error("Unknown setup option; use codex-bridge-setup --help");
69
70
  if (args.includes("--check") && args.includes("--dry-run")) throw new Error("Use --check or --dry-run, not both");
70
71
  return Object.fromEntries([...allowed].map((arg) => [arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), args.includes(arg)]));
@@ -148,6 +149,13 @@ export function setupEnvironment(env, paths, codex) {
148
149
  CLAUDE_DESKTOP_CONFIG: paths.desktop, CODEX_EXE: codex, CODEX_BIN: codex };
149
150
  }
150
151
 
152
+ function codexInventoryFailure(error) {
153
+ const detail = error?.message?.includes("ETIMEDOUT") || error?.code === "ETIMEDOUT"
154
+ ? `Codex CLI did not list its MCP servers within ${CODEX_MCP_TIMEOUT_MS / 1000} seconds; the computer may be busy. Rerun the check when it is idle`
155
+ : error?.message ?? String(error);
156
+ return Object.assign(new Error(detail), error?.code ? { code: error.code } : {});
157
+ }
158
+
151
159
  function configurationCheck(label, check, components) {
152
160
  try { const details = check(); components.push({ name: label, status: "configured", ...details }); }
153
161
  catch (error) { components.push(failedComponent(label, error)); }
@@ -171,15 +179,16 @@ export function checkSetup({ env = process.env, platform = process.platform, roo
171
179
  let inventory;
172
180
  configurationCheck("Codex CLI", () => {
173
181
  codex = ensureSetupCodex({ env, paths, platform, download: false, run, resolve }).binary;
174
- inventory = JSON.parse(run(codex, ["mcp", "list", "--json"], setupEnvironment(env, paths, codex), 30000));
175
- if (!Array.isArray(inventory)) throw new Error("Codex returned an invalid MCP inventory");
182
+ try { inventory = JSON.parse(run(codex, ["mcp", "list", "--json"], setupEnvironment(env, paths, codex), CODEX_MCP_TIMEOUT_MS)); }
183
+ catch (error) { throw codexInventoryFailure(error); }
184
+ if (!Array.isArray(inventory)) { inventory = undefined; throw new Error("Codex returned an invalid MCP inventory"); }
176
185
  }, components);
177
186
  const native = platform === "win32" || platform === "darwin";
178
187
  for (const [name, worker] of [["claude-bridge", "claude-bridge.mjs"], ...(native ? [["codex-native-relay", "native-relay-companion.mjs"]] : []), ["antigravity-bridge", "antigravity-bridge.mjs"]]) {
179
188
  configurationCheck(`Codex / ${name}`, () => {
180
- const item = inventory?.find((server) => server.name === name);
181
- if (!item) throw new Error("MCP registration is missing");
182
- const entry = JSON.parse(run(codex, ["mcp", "get", name, "--json"], setupEnvironment(env, paths, codex), 30000));
189
+ if (!inventory) throw new Error("Not checked because Codex CLI did not report its MCP servers; the registration may still be present");
190
+ const entry = inventory.find((server) => server?.name === name);
191
+ if (!entry) throw new Error("MCP registration is missing");
183
192
  const details = assertEntry({ ...entry.transport, enabled: entry.enabled }, worker, root, name === "antigravity-bridge" ? { caller: "codex" } : {});
184
193
  if (name === "codex-native-relay" && !entry.transport.env_vars?.includes("CODEX_APP_TOOLS_PIPE_PATH")) throw new Error("Native pipe forwarding is missing");
185
194
  return details;
@@ -228,7 +237,7 @@ export function checkSetup({ env = process.env, platform = process.platform, roo
228
237
  return { configured: components.every((item) => item.status === "configured"), liveVerified: false, components };
229
238
  }
230
239
 
231
- export function runSetup({ args = [], env = process.env, platform = process.platform, root = ROOT, run = execute, resolve = resolveCodexBin } = {}) {
240
+ export function runSetup({ args = [], env = process.env, platform = process.platform, root = ROOT, run = execute, resolve = resolveCodexBin, onProgress = () => {} } = {}) {
232
241
  const options = setupArguments(args);
233
242
  if (options.help) return { help: `Usage: codex-bridge-setup [--check|--dry-run] [--json] [--no-download]\nUpdate the package and apply managed settings in an interactive terminal with: ${platform === "win32" ? "npx.cmd" : "npx"} -y @minhspark/codex-mcp-bridge@latest\nThe updater installs the package with lifecycle scripts disabled, then directly configures Claude Desktop, Claude Code, Codex, Antigravity and the independent worker relay. CODEX_BRIDGE_SKIP_SETUP=1 opts out. Saved configuration, background relay health and loaded native app connections are verified separately.` };
234
243
  if (options.postinstall && !automaticSetupEnabled(env)) return { skipped: true, reason: "Automatic configuration runs only for a global npm install; use codex-bridge-setup for manual setup." };
@@ -243,6 +252,7 @@ export function runSetup({ args = [], env = process.env, platform = process.plat
243
252
  const base = setupEnvironment(env, paths, runtime.binary);
244
253
  const components = [];
245
254
  const install = (name, script, arguments_, environment = base) => {
255
+ onProgress(name);
246
256
  try {
247
257
  const output = run(process.execPath, [path.join(root, "scripts", script), ...arguments_], environment, 180000);
248
258
  let details = {};
@@ -265,6 +275,7 @@ export function runSetup({ args = [], env = process.env, platform = process.plat
265
275
  install("Codex / claude-bridge", "install-codex-mcp.mjs", ["--setup"]);
266
276
  if (code) install("Antigravity / all components", "install-antigravity-bridge.mjs", ["--all", "--setup"]);
267
277
  install("Independent background relay", "setup-background-relay.mjs", ["--json"]);
278
+ onProgress("Verifying saved settings");
268
279
  const inspection = checkSetup({ env: base, platform, root, run, resolve: () => runtime.binary });
269
280
  return { configured: components.every((item) => item.status === "configured") && inspection.configured, liveVerified: false,
270
281
  cliInstalled: runtime.installed, components, verification: inspection.components,