@minhspark/codex-mcp-bridge 1.25.1 → 1.25.3

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.
@@ -7,6 +7,7 @@ import { createReleaseSnapshot, pruneReleaseSnapshots, snapshotRoot, sourceRevis
7
7
  import { desktopTasksConfigured } from "./native-relay.mjs";
8
8
  import { isMainModule, stableInstallPath } from "./install-paths.mjs";
9
9
  import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mjs";
10
+ import { withBridgeDiagnostic } from "./recipient-preflight.mjs";
10
11
 
11
12
  const WORKER_NODE = stableInstallPath(process.execPath);
12
13
  const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
@@ -16,7 +17,10 @@ const RETIRE_GRACE_MS = 6000;
16
17
  const CLIENT_CLOSE_GRACE_MS = CLIENT_CLOSE_DRAIN_MS + CLIENT_CLOSE_STOP_MS + 1000;
17
18
  const hasId = (message) => Object.hasOwn(message, "id");
18
19
  const idKey = (id) => JSON.stringify(id);
19
- const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
20
+ const errorMessage = (id, message, options = {}) => {
21
+ const result = withBridgeDiagnostic({ content: [] }, new Error(message), { component: "mcp-supervisor", code: "MCP_WORKER_UNAVAILABLE", ...options });
22
+ return { jsonrpc: "2.0", id, error: { code: -32603, message: `${message}\n${result.content[0].text}`, data: { diagnostic: result.structuredContent.diagnostic } } };
23
+ };
20
24
 
21
25
  export function supervisorInterval(value, fallback) {
22
26
  const parsed = Number(value);
@@ -170,6 +174,7 @@ export async function runSupervisor(entry, options = {}) {
170
174
  let active;
171
175
  let initializeParams;
172
176
  let initializeResult;
177
+ let initializeCatalog;
173
178
  let ready = false;
174
179
  let switching = false;
175
180
  let stopping = false;
@@ -181,6 +186,17 @@ export async function runSupervisor(entry, options = {}) {
181
186
  let retryAt = 0;
182
187
  let reason = null;
183
188
  let reloads = 0;
189
+ let recoveries = 0;
190
+ let recoveryAttempts = 0;
191
+ let recoveryAt = 0;
192
+ let activeSince = Date.now();
193
+ const recoveryDelayMs = Math.min(30000, supervisorInterval(env.CODEX_BRIDGE_RECOVERY_DELAY_MS, 1000));
194
+ const recoveryLimit = 5;
195
+ const canRecover = () => entry === "native-relay-companion.mjs" && ready && initializeResult && initializeCatalog;
196
+ const scheduleRecovery = () => {
197
+ if (recoveryAttempts >= recoveryLimit) return;
198
+ recoveryAt = Date.now() + Math.min(30000, recoveryDelayMs * 2 ** recoveryAttempts);
199
+ };
184
200
  const queue = [];
185
201
  const requests = new Map();
186
202
  const reverse = new Map();
@@ -189,7 +205,7 @@ export async function runSupervisor(entry, options = {}) {
189
205
  revision: active?.release.revision ?? null, availableRevision: observed,
190
206
  routingConfiguration: active?.routingConfiguration ?? null, availableRoutingConfiguration: observedRouting,
191
207
  pending: Boolean(observed && (observed !== active?.release.revision || observedRouting !== active?.routingConfiguration)),
192
- state: failure ? "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads });
208
+ state: failure ? canRecover() && recoveryAttempts < recoveryLimit ? "recovering" : "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads, recoveries, recoveryAttempts });
193
209
 
194
210
  const handlers = {
195
211
  message(worker, message) {
@@ -210,25 +226,37 @@ export async function runSupervisor(entry, options = {}) {
210
226
  const respond = async () => {
211
227
  if (pending.method === "initialize" && !message.error) {
212
228
  initializeResult = message.result;
229
+ if (entry === "native-relay-companion.mjs") {
230
+ initializeCatalog = await worker.rpc("tools/list", {});
231
+ if (!Array.isArray(initializeCatalog.tools) || !initializeCatalog.tools.length || initializeCatalog.nextCursor) throw new Error("The native relay tool catalog is incomplete");
232
+ }
213
233
  await worker.control("activate");
214
234
  ready = true;
215
235
  }
216
236
  if (message.result && ["codex_bridge_status", "claude_bridge_status", "native_relay_status"].includes(pending.tool)) {
217
237
  message.result.structuredContent = { ...message.result.structuredContent, autoReload: diagnostics() };
218
- message.result.content = [...(message.result.content ?? []), { type: "text", text: `automatic reload: ${diagnostics().state}\nsupervisor pid: ${process.pid}\nworker pid: ${worker.process.pid}\nreload count: ${reloads}${reason ? `\nreload detail: ${reason}` : ""}` }];
238
+ message.result.content = [...(message.result.content ?? []), { type: "text", text: `automatic reload: ${diagnostics().state}\nsupervisor pid: ${process.pid}\nworker pid: ${worker.process.pid}\nreload count: ${reloads}\nrecovery count: ${recoveries}${reason ? `\nreload detail: ${reason}` : ""}` }];
219
239
  }
220
240
  output({ ...message, id: pending.id });
221
241
  };
222
- void respond().catch((error) => { failure = error.message; output(errorMessage(pending.id, error.message)); });
242
+ void respond().catch((error) => { failure = error.message; output(errorMessage(pending.id, error.message, { code: "MCP_RESPONSE_FAILED", operation: pending.tool ?? pending.method, deliveryState: "unknown" })); });
223
243
  },
224
244
  exit(worker, error) {
225
245
  for (const [id, pending] of worker.clientPending) {
226
246
  requests.delete(idKey(pending.id));
227
- output(errorMessage(pending.id, `${error.message}. The operation may already have been dispatched; it was not retried. Retain the original task and receipts.`));
247
+ output(errorMessage(pending.id, `${error.message}. The operation may already have been dispatched; it was not retried. Retain the original task and receipts.`, { code: "MCP_WORKER_EXITED", operation: pending.tool ?? pending.method, deliveryState: "unknown" }));
228
248
  worker.clientPending.delete(id);
229
249
  }
230
250
  for (const [id, pending] of reverse) if (pending.worker === worker) reverse.delete(id);
231
- if (worker === active && !worker.retiring && !stopping) { failure = `${error.message}; automatic replay is disabled because delivery state may be unknown`; log(failure); }
251
+ if (worker === active && !worker.retiring && !stopping) {
252
+ failure = `${error.message}; automatic replay is disabled because delivery state may be unknown`;
253
+ if (canRecover()) {
254
+ if (Date.now() - activeSince >= 60000) recoveryAttempts = 0;
255
+ scheduleRecovery();
256
+ if (recoveryAttempts >= recoveryLimit) failure += "; recovery limit reached, reconnect MCP";
257
+ }
258
+ log(failure);
259
+ }
232
260
  },
233
261
  };
234
262
  const spawn = (release, routing) => {
@@ -245,7 +273,56 @@ export async function runSupervisor(entry, options = {}) {
245
273
  return worker;
246
274
  };
247
275
  const check = async () => {
248
- if (switching || !ready || stopping || failure) return;
276
+ if (switching || !ready || stopping) return;
277
+ if (failure) {
278
+ if (!canRecover() || recoveryAttempts >= recoveryLimit || Date.now() < recoveryAt) return;
279
+ switching = true;
280
+ recoveryAttempts++;
281
+ const previous = active;
282
+ let candidate;
283
+ try {
284
+ await previous.stop();
285
+ workers.delete(previous);
286
+ if (stopping) return;
287
+ const source = currentRoot();
288
+ const release = createReleaseSnapshot(source, { expectedRevision: sourceRevision(source), lease: true });
289
+ candidate = spawn(release, routingConfiguration());
290
+ const initialized = await candidate.rpc("initialize", initializeParams);
291
+ if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
292
+ throw new Error("The replacement MCP protocol or capabilities require an explicit client reconnect");
293
+ }
294
+ candidate.write({ jsonrpc: "2.0", method: "notifications/initialized" });
295
+ const catalog = await candidate.rpc("tools/list", {});
296
+ if (JSON.stringify(catalog) !== JSON.stringify(initializeCatalog)) throw new Error("The replacement tool catalog requires an explicit client reconnect");
297
+ await candidate.control("activate");
298
+ if (stopping || candidate.closed) throw new Error("The relay replacement stopped before activation completed");
299
+ active = candidate;
300
+ candidate = null;
301
+ activeSince = Date.now();
302
+ failure = null;
303
+ reason = null;
304
+ recoveries++;
305
+ log(`recovered native relay worker after exit; recovery ${recoveries}, no requests replayed`);
306
+ schedulePrune();
307
+ } catch (error) {
308
+ failure = `Native relay recovery failed: ${error.message}; no requests were replayed`;
309
+ if (candidate) {
310
+ try {
311
+ await candidate.stop();
312
+ workers.delete(candidate);
313
+ } catch (stopError) {
314
+ recoveryAttempts = recoveryLimit;
315
+ failure += `; replacement shutdown could not be confirmed: ${stopError.message}`;
316
+ }
317
+ }
318
+ scheduleRecovery();
319
+ if (recoveryAttempts >= recoveryLimit) failure += "; recovery limit reached, reconnect MCP";
320
+ log(failure);
321
+ } finally {
322
+ switching = false;
323
+ }
324
+ return;
325
+ }
249
326
  let source;
250
327
  let revision;
251
328
  let routing;
@@ -279,8 +356,12 @@ export async function runSupervisor(entry, options = {}) {
279
356
  await candidate.control("restore", exported.state);
280
357
  await candidate.control("activate");
281
358
  if (stopping) throw new Error("The MCP client disconnected during activation");
359
+ if (candidate.closed) throw new Error("The replacement worker exited during activation");
282
360
  active = candidate;
283
361
  candidate = null;
362
+ initializeCatalog = catalog;
363
+ activeSince = Date.now();
364
+ failure = null;
284
365
  reloads++;
285
366
  reason = null;
286
367
  if (JSON.stringify(catalog) !== JSON.stringify(oldCatalog)) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
@@ -321,16 +402,16 @@ export async function runSupervisor(entry, options = {}) {
321
402
  }
322
403
  return;
323
404
  }
324
- if (failure) { if (hasId(message)) output(errorMessage(message.id, failure)); return; }
405
+ if (failure) { if (hasId(message)) output(errorMessage(message.id, `${failure}${canRecover() && recoveryAttempts < recoveryLimit ? "; native relay recovery is pending, this request was not sent" : ""}`, { operation: message.params?.name ?? message.method, deliveryState: "not_sent" })); return; }
325
406
  if (switching || !active) {
326
407
  const bytes = queue.reduce((size, queued) => size + Buffer.byteLength(JSON.stringify(queued)), 0);
327
- if (queue.length >= MAX_QUEUE || bytes + Buffer.byteLength(JSON.stringify(message)) > MAX_FRAME_BYTES) { if (hasId(message)) output(errorMessage(message.id, "Reload request queue is full; no operation was dispatched")); return; }
408
+ if (queue.length >= MAX_QUEUE || bytes + Buffer.byteLength(JSON.stringify(message)) > MAX_FRAME_BYTES) { if (hasId(message)) output(errorMessage(message.id, "Reload request queue is full; no operation was dispatched", { code: "MCP_QUEUE_FULL", operation: message.params?.name ?? message.method, deliveryState: "not_sent" })); return; }
328
409
  queue.push(message);
329
410
  return;
330
411
  }
331
412
  if (message.method === "initialize") initializeParams = message.params;
332
413
  if (hasId(message)) {
333
- if (requests.has(idKey(message.id))) { output(errorMessage(message.id, "Duplicate active JSON-RPC request ID")); return; }
414
+ if (requests.has(idKey(message.id))) { output(errorMessage(message.id, "Duplicate active JSON-RPC request ID", { code: "MCP_DUPLICATE_REQUEST", operation: message.params?.name ?? message.method, deliveryState: "unknown" })); return; }
334
415
  const id = `client:${++sequence}`;
335
416
  active.clientPending.set(id, { id: message.id, method: message.method, tool: message.params?.name });
336
417
  requests.set(idKey(message.id), { worker: active, workerId: id });
@@ -14,6 +14,7 @@ import {
14
14
  responseTooLargeError,
15
15
  NATIVE_DISPATCH_METHOD,
16
16
  NativeToolsClient,
17
+ NativeDesktopRelay,
17
18
  NativeRelayError,
18
19
  RELAY_PROTOCOL_VERSION,
19
20
  ACCOUNT_RELAY_PROTOCOL_VERSION,
@@ -28,7 +29,7 @@ import {
28
29
  } from "./native-relay.mjs";
29
30
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
30
31
  import { exitForVersionRequest } from "./cli-version.mjs";
31
- import { assertAccountIdentity } from "./bridge-account-context.mjs";
32
+ import { accountIdentity, assertAccountIdentity, readBridgeAccounts } from "./bridge-account-context.mjs";
32
33
  import { createReloadControl, drainPendingAccepts, exitWhenClientCloses } from "./reload-control.mjs";
33
34
  import { createRuntimeState } from "./runtime-state.mjs";
34
35
  import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
@@ -42,7 +43,7 @@ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } fro
42
43
 
43
44
  exitForVersionRequest(import.meta.url);
44
45
 
45
- const VERSION = "1.25.1";
46
+ const VERSION = "1.25.3";
46
47
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
47
48
 
48
49
  function errorResponse(code, message, sent, details) {
@@ -344,6 +345,9 @@ async function handleDesktopRequest(payload, { dispatchDesktop, resolveExecutor,
344
345
  if (payload.operation === "send_message_to_thread" && payload.arguments.threadId === executorThreadId) {
345
346
  return errorResponse("RELAY_BAD_REQUEST", "The relay executor cannot receive its own relayed message");
346
347
  }
348
+ if (payload.operation === "navigate_to_codex_page" && payload.arguments.threadId === executorThreadId) {
349
+ return errorResponse("RELAY_BAD_REQUEST", "The internal relay executor has no user conversation to open");
350
+ }
347
351
  // Reserve before dispatch. A disconnected caller must not discard a late
348
352
  // result, and even identical duplicate writes must not dispatch twice.
349
353
  const creation = payload.creationReceiptId ? creationReceipts.reserve(payload.creationReceiptId, payload.arguments, accountContext, executorThreadId) : null;
@@ -865,6 +869,20 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
865
869
  };
866
870
  }
867
871
 
872
+ export async function probeRelayDispatch({ env = process.env, timeoutMs = 3000, accounts = readBridgeAccounts, relay = new NativeDesktopRelay({ env, socketPath: desktopTaskSocketPath(env), accountSocketPath: accountRelaySocketPath(env) }) } = {}) {
873
+ try {
874
+ const accountContext = accountIdentity(accounts());
875
+ const response = await relay.requestDesktop("list_projects", {}, {
876
+ timeoutMs,
877
+ ...(accountContext ? { accountContext, beforeSend: () => assertAccountIdentity(accountContext) } : {}),
878
+ });
879
+ if (!Array.isArray(response?.result?.projects)) throw new Error("Native Desktop did not return a project list");
880
+ return { ready: true, checkedAt: new Date().toISOString(), scope: "shared-relay-native-dispatch" };
881
+ } catch (error) {
882
+ return { ready: false, checkedAt: new Date().toISOString(), scope: "shared-relay-native-dispatch", code: error.code ?? "NATIVE_HEALTH_CHECK_FAILED", reason: error.message };
883
+ }
884
+ }
885
+
868
886
  export function createRelayServers({ nativeTools, env = process.env, signalExit = true, log: logFn = () => {} }) {
869
887
  const hardened = hardenedBridgeEnabled(env);
870
888
  if (hardened && !desktopTasksConfigured(env)) throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_DESKTOP_TASKS=1; legacy relay listeners are disabled");
@@ -938,8 +956,9 @@ if (invokedDirectly) {
938
956
  } catch (err) {
939
957
  executor = err.message;
940
958
  }
959
+ const nativeDispatch = listening ? await probeRelayDispatch() : { ready: false, reason: "Relay transport is not listening" };
941
960
  return {
942
- structuredContent: { runtime: runtime.status() },
961
+ structuredContent: { runtime: runtime.status(), nativeDispatch },
943
962
  content: [
944
963
  {
945
964
  type: "text",
@@ -953,6 +972,7 @@ if (invokedDirectly) {
953
972
  `remote Codex: ${remoteRelay.socketPath} (${await remoteRelay.isListening() ? "listening" : "not listening"})`,
954
973
  `executor: ${executor}`,
955
974
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
975
+ `native dispatch: ${nativeDispatch.ready ? "verified (read-only list_projects)" : `unavailable (${nativeDispatch.reason})`}`,
956
976
  `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
957
977
  ].join("\n"),
958
978
  },
@@ -254,6 +254,9 @@ export function validateDesktopOperation(operation, args) {
254
254
 
255
255
  export function nativeDesktopOperationParams({ executorThreadId, operation, arguments: args }) {
256
256
  validateDesktopOperation(operation, args);
257
+ if (operation === "navigate_to_codex_page" && args.threadId === executorThreadId) {
258
+ throw new NativeRelayError("The internal relay executor has no user conversation to open", "RELAY_BAD_REQUEST");
259
+ }
257
260
  return {
258
261
  arguments: args,
259
262
  callerSource: "codex",
@@ -560,9 +563,9 @@ export async function resolveNativeToolsPipePath({
560
563
  }
561
564
 
562
565
  export class NativeToolsClient {
563
- constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }), assertAccount = assertAccountIdentity } = {}) {
566
+ constructor({ env = process.env, socketPath, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env: { ...env, CODEX_APP_TOOLS_PIPE_PATH: undefined } }), assertAccount = assertAccountIdentity } = {}) {
564
567
  this.env = env;
565
- this.socketPath = socketPath;
568
+ this.socketPath = socketPath ?? env.CODEX_APP_TOOLS_PIPE_PATH;
566
569
  this.explicitSocketPath = socketPath || null;
567
570
  this.hasDiscoveredSocket = false;
568
571
  this.timeoutMs = timeoutMs;
@@ -581,7 +584,7 @@ export class NativeToolsClient {
581
584
  if (this.socket && !this.socket.destroyed) return;
582
585
  const generation = this.connectionGeneration;
583
586
  this.connecting = (async () => {
584
- const socketPath = this.socketPath || await this.resolveSocketPath();
587
+ const socketPath = this.socketPath || await this.resolveSocketPath() || this.env.CODEX_APP_TOOLS_PIPE_PATH;
585
588
  if (generation !== this.connectionGeneration) {
586
589
  throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
587
590
  }
@@ -774,7 +777,8 @@ export function nativeRelayStatus(env = process.env) {
774
777
  enabled: false,
775
778
  mode,
776
779
  socketPath,
777
- reason: `no companion socket at ${socketPath}. Install the native relay if it is missing. Codex Desktop runs codex-native-relay only while a task has its MCP servers loaded: open any regular project task (not the internal Native Relay executor task) and send it a turn or reconnect its MCP servers, then retry.`,
780
+ code: "RELAY_UNREACHABLE",
781
+ reason: `The native relay endpoint is unavailable at ${socketPath}. Its Desktop-owned host may be unloaded or disconnected; socket absence alone cannot distinguish these causes. Silent worker recovery requires a running native supervisor. Do not open or create chats to load the relay; include the bridge diagnostic in a report.`,
778
782
  };
779
783
  }
780
784
  return { enabled: true, mode, socketPath, reason: null };
@@ -235,7 +235,7 @@ export function findClaudeSession(target, { desktopOnly = false, expectedCwd } =
235
235
  if (matches.length !== 1) throw new Error(`Desktop-only mode requires an unambiguous sessionId or pid; "${needle}" matches multiple sessions. No message was sent.`);
236
236
  const session = matches[0];
237
237
  if (session.entrypoint !== "claude-desktop") {
238
- throw new Error(`Desktop-only mode refuses Claude session ${session.sessionId ?? session.pid} with entrypoint ${session.entrypoint ?? "unknown"}. Open or reconnect an existing Code session in Claude Desktop for the intended project. Do not launch a replacement CLI session. No message was sent.`);
238
+ throw Object.assign(new Error(`Desktop-only mode refuses Claude session ${session.sessionId ?? session.pid} with entrypoint ${session.entrypoint ?? "unknown"}. Inspect native session discovery; do not open chats to load a relay or launch a replacement CLI session. No message was sent.`), { code: "CLAUDE_DESKTOP_TASK_UNVERIFIED", sent: false });
239
239
  }
240
240
  if (expectedCwd !== undefined) assertClaudeSessionCwd(session, expectedCwd);
241
241
  return session;
@@ -268,13 +268,13 @@ export function assertClaudeSessionCwd(session, expectedCwd) {
268
268
  }
269
269
 
270
270
  export function assertClaudeSessionProcess(session) {
271
- const changed = "The live Claude process identity is missing or changed. No message was sent; reopen the existing Desktop task and inspect its session again.";
272
- if (!session?.alive || typeof session.processStart !== "string" || !session.processStart) throw new Error(changed);
271
+ const changed = "The live Claude process identity is missing or changed. No message was sent; inspect list_claude_sessions without opening an app or chat.";
272
+ if (!session?.alive || typeof session.processStart !== "string" || !session.processStart) throw Object.assign(new Error(changed), { code: "CLAUDE_PROCESS_IDENTITY_CHANGED", sent: false });
273
273
  const current = readProcessStart(session.pid);
274
274
  if (current === null) {
275
- throw new Error("The live Claude process identity could not be read, so this send was refused rather than sent unverified. That failure is not itself evidence the session changed; inspect the existing Desktop task before retrying.");
275
+ throw Object.assign(new Error("The live Claude process identity could not be read, so this send was refused rather than sent unverified. That failure is not itself evidence the session changed; inspect native session discovery before retrying."), { code: "CLAUDE_PROCESS_IDENTITY_UNAVAILABLE", sent: false });
276
276
  }
277
- if (current !== session.processStart) throw new Error(changed);
277
+ if (current !== session.processStart) throw Object.assign(new Error(changed), { code: "CLAUDE_PROCESS_IDENTITY_CHANGED", sent: false });
278
278
  }
279
279
 
280
280
  /**
@@ -1050,6 +1050,7 @@ export class PeerEndpoint {
1050
1050
  + "Inspect read_claude_delivery and wait for Claude's outstanding replies. Changing waitSec or starting another bridge must not be used to bypass a pending message.",
1051
1051
  );
1052
1052
  error.code = "PEER_REPLY_PENDING";
1053
+ error.sent = false;
1053
1054
  throw error;
1054
1055
  }
1055
1056
  const since = Date.now();
@@ -1076,8 +1077,9 @@ export class PeerEndpoint {
1076
1077
  }
1077
1078
  } catch (err) {
1078
1079
  const sent = this.sentMessages.get(msgId);
1079
- if (sent) sent.error = err.message;
1080
+ if (sent) { sent.error = err.message; sent.errorCode = err.code ?? "CLAUDE_PEER_SEND_FAILED"; }
1080
1081
  if (!err.deliveryUncertain) {
1082
+ err.sent = false;
1081
1083
  this.#removePendingReply(targetSocket, msgId);
1082
1084
  if (sent) sent.failed = true;
1083
1085
  }
@@ -1160,6 +1162,7 @@ export class PeerEndpoint {
1160
1162
  msgId,
1161
1163
  status: sent.reply ? "reply_received" : sent.failed ? "send_failed" : delivery?.status ?? "sent_unconfirmed",
1162
1164
  reason: delivery?.reason ?? sent.error ?? null,
1165
+ ...(sent.errorCode || delivery?.reasonCode ? { reasonCode: sent.errorCode ?? delivery.reasonCode } : {}),
1163
1166
  sentAt: sent.sentAt,
1164
1167
  sessionId: session?.sessionId ?? null,
1165
1168
  cwd: session?.cwd ?? null,
package/src/platform.mjs CHANGED
@@ -545,6 +545,7 @@ export async function openThreadInCodexApp(threadId, { activate = true, platform
545
545
  if (typeof threadId !== "string" || !threadId) throw new Error("A Codex thread id is required");
546
546
  const url = codexThreadUrl(encodeURIComponent(threadId));
547
547
  if (platform === "win32") {
548
+ if (!activate) throw Object.assign(new Error("Windows cannot open a Codex task without activating its window; background navigation is disabled"), { code: "BACKGROUND_NAVIGATION_UNAVAILABLE" });
548
549
  const [shell, args] = windowsOpenUrlCommand(url, env);
549
550
  await run(shell, args, { windowsHide: true, timeout: 10000 });
550
551
  return url;
@@ -1,5 +1,86 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
5
+ const diagnosticCode = (value) => typeof value === "string" && /^[A-Z][A-Z0-9_]{0,79}$/.test(value) ? value : null;
6
+ const diagnosticId = (value) => typeof value === "string" && /^(?:[a-f0-9]{64}|(?:local_)?[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12})$/i.test(value) ? value : null;
7
+
8
+ export function diagnosticText(value) {
9
+ return String(value ?? "Unknown bridge failure")
10
+ .replace(/\{[\s\S]*\}|\[\s*(?:\{|"|true|false|null|\d)[\s\S]*\]/g, "[structured details omitted]")
11
+ .replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[credential]")
12
+ .replace(/((?:access[_-]?token|refresh[_-]?token|api[_-]?key|token|secret|password|authorization)\s*["']?\s*[:=]\s*)["']?[^\s,"';}]+["']?/gi, "$1[redacted]")
13
+ .replace(/https?:\/\/[^\s<>"']+/gi, "[url]")
14
+ .replace(/(?:[a-z]:[\\/]|\\\\)[^\r\n"'<>|,;]*/gi, "[path]")
15
+ .replace(/(^|[\s"'(])\/(?:Users|home|tmp|var|private|Volumes|mnt)\/[^\r\n"'<>|,;]*/g, "$1[path]")
16
+ .replace(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi, "[email]")
17
+ .replace(/\b(?:sk-[a-z0-9_-]+|gh[pousr]_[a-z0-9_]+)\b/gi, "[credential]")
18
+ .replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 1400);
19
+ }
20
+
21
+ export function bridgeDiagnostic(error, options = {}) {
22
+ const previous = error?.diagnostic?.schemaVersion === 1 ? error.diagnostic : null;
23
+ const chain = [];
24
+ const seen = new Set();
25
+ for (let cause = error; cause && !seen.has(cause) && chain.length < 8; cause = cause.cause) {
26
+ seen.add(cause);
27
+ chain.push(cause);
28
+ }
29
+ const codes = [...new Set(chain.flatMap((cause) => [cause?.preflight?.code, cause?.code, cause?.reasonCode, cause?.diagnostic?.code]).map(diagnosticCode).filter(Boolean))];
30
+ const code = diagnosticCode(previous?.code) ?? diagnosticCode(options.code) ?? codes[0] ?? "BRIDGE_OPERATION_FAILED";
31
+ const state = options.deliveryState ?? previous?.deliveryState ?? error?.deliveryState;
32
+ const deliveryState = state === "not_applicable" || options.readOnly && !previous ? "not_applicable"
33
+ : ["not_sent", "not-sent"].includes(state) ? "not_sent"
34
+ : state === "sent" ? "sent"
35
+ : state === "unknown" || error?.deliveryUncertain ? "unknown"
36
+ : chain.some((cause) => cause?.sent === false || cause?.preflight?.sent === false) ? "not_sent" : "unknown";
37
+ const name = (value, fallback) => typeof value === "string" && /^[a-zA-Z][a-zA-Z0-9_./-]{0,99}$/.test(value) ? value : fallback;
38
+ const ids = {};
39
+ for (const key of ["requestId", "msgId", "deliveryId", "threadId", "taskId", "conversationId"]) {
40
+ const id = diagnosticId(previous?.ids?.[key] ?? options[key] ?? error?.[key]);
41
+ if (id) ids[key] = id;
42
+ }
43
+ const timestamp = previous?.timestamp;
44
+ return {
45
+ schemaVersion: 1,
46
+ reportId: diagnosticId(previous?.reportId) ?? randomUUID(),
47
+ timestamp: typeof timestamp === "string" && /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(timestamp) ? timestamp : new Date().toISOString(),
48
+ component: name(previous?.component ?? options.component, "bridge"),
49
+ operation: name(previous?.operation === "unknown" ? options.operation : previous?.operation ?? options.operation, "unknown"),
50
+ version: typeof previous?.version === "string" && /^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(previous.version) ? previous.version : version,
51
+ platform: process.platform,
52
+ nodeVersion: process.versions.node,
53
+ code,
54
+ causeCodes: [...new Set([...(Array.isArray(previous?.causeCodes) ? previous.causeCodes : []).map(diagnosticCode).filter(Boolean), ...codes])].filter((value) => value !== code).slice(0, 8),
55
+ phase: name(previous?.phase ?? options.phase ?? error?.phase ?? error?.diagnostic?.stage, deliveryState === "not_sent" ? "preflight" : options.readOnly ? "inspection" : "delivery"),
56
+ deliveryState,
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."
60
+ : deliveryState === "not_applicable" ? "Inspect bridge status and include this diagnostic in the report."
61
+ : "Inspect the original delivery receipt. Do not resend automatically or create a replacement chat.",
62
+ reason: diagnosticText(previous?.reason ?? error?.preflight?.reason ?? error?.message ?? error),
63
+ ids,
64
+ };
65
+ }
66
+
67
+ export function withBridgeDiagnostic(result, error, options = {}) {
68
+ const diagnostic = bridgeDiagnostic({ ...error, cause: error?.cause, message: error?.message ?? String(error ?? "Bridge operation failed"), diagnostic: result?.structuredContent?.diagnostic ?? result?.diagnostic ?? error?.diagnostic }, options);
69
+ const report = ["Bridge diagnostic (include this block in a bug report)", `${diagnostic.code} | ${diagnostic.component} / ${diagnostic.operation}`,
70
+ `Bridge ${diagnostic.version} | ${diagnostic.platform} | Node ${diagnostic.nodeVersion}`,
71
+ `Phase: ${diagnostic.phase} | Delivery: ${diagnostic.deliveryState} | Sent: ${diagnostic.sent ?? "unconfirmed"}`,
72
+ `Reason: ${diagnostic.reason}`, ...(diagnostic.causeCodes.length ? [`Cause codes: ${diagnostic.causeCodes.join(", ")}`] : []),
73
+ ...Object.entries(diagnostic.ids).map(([key, value]) => `${key}: ${value}`),
74
+ `Report: ${diagnostic.reportId} | ${diagnostic.timestamp}`, diagnostic.nextAction].join("\n");
75
+ return { ...result, content: [...(result?.content ?? []).filter((item) => item.type !== "text" || !item.text.startsWith("Bridge diagnostic (")), { type: "text", text: report }],
76
+ structuredContent: { ...result?.structuredContent, diagnostic } };
77
+ }
78
+
1
79
  export function preflightFailure(code, reason) {
2
80
  const error = new Error(`${reason} No message was sent.`);
81
+ error.code = code;
82
+ error.deliveryState = "not_sent";
83
+ error.sent = false;
3
84
  error.preflight = { status: "blocked", code, reason, sent: false };
4
85
  return error;
5
86
  }