@minhspark/codex-mcp-bridge 1.25.0 → 1.25.2

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.25.0",
3
+ "version": "1.25.2",
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,30 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.25.2] - 2026-10-31
8
+
9
+ ### Fixed
10
+
11
+ - Relay recovery no longer opens a task or activates a Desktop window, including request-triggered recovery. The old keeper flag cannot restore navigation. Windows background task navigation now fails explicitly instead of silently stealing focus.
12
+ - The Desktop-owned supervisor restarts crashed native relay workers with bounded exponential backoff and validates their protocol and tool catalog before activation. In-flight operations fail without replay; client disconnect still shuts down the supervisor and its workers.
13
+ - Stale inherited native pipe hints can recover through verified Desktop ancestry. Explicitly configured endpoints stay pinned, and uncertain native sends are never replayed.
14
+ - Recovery waits and subsequent retries share the original request deadline. Native relay status checks a read-only operation through the serving shared relay instead of treating socket availability as proof of working dispatch.
15
+
16
+ ### Limitations
17
+
18
+ - Reconnect the relay MCP once after upgrading to load the new supervisor. Worker hot reload alone cannot replace its running parent. Automatic recovery requires that Desktop-owned supervisor to remain alive; it cannot cold-start MCP in an idle application with no loaded session.
19
+
20
+ ## [1.25.1] - 2026-10-31
21
+
22
+ ### Fixed
23
+
24
+ - On Windows, Codex Desktop no longer jumps to the front and switches to another task on its own. The background relay keeper opened `codex://threads/<id>` whenever the native relay was missing, and Windows always activates the app for that link, so the window was pulled forward about once a minute while the relay stayed down. The keeper is now off by default on Windows; set `CODEX_BRIDGE_RELAY_KEEPER=1` to turn it back on. macOS is unchanged because it opens the link in the background.
25
+
26
+ ### Verification and limitations
27
+
28
+ - Focused regressions cover the Windows default, the explicit opt-in and the wake opt-out taking precedence.
29
+ - A request that needs the relay while it is missing still opens a Codex task on Windows, which brings the app to the front once for that request.
30
+
7
31
  ## [1.25.0] - 2026-10-30
8
32
 
9
33
  ### Added
package/README.md CHANGED
@@ -669,9 +669,9 @@ For a repeatable failure, capture the app/OS/Node versions, the version reported
669
669
 
670
670
  **Symptoms:** `RELAY_UNAVAILABLE`, `connect ENOENT`, a missing native relay socket, or `thread ... already has an active writer`.
671
671
 
672
- Native Desktop handoffs require Codex Desktop to be installed, signed in and open on Windows/macOS. The app runs `codex-native-relay` while a regular task has its MCP servers loaded. The bridge can wake a saved task on both platforms when the relay is absent; the background keeper requires a live bridge client and an eligible saved task. On Windows, opening the task can bring the app to the foreground. Explicit keeper/wake opt-outs remain respected.
672
+ Native Desktop handoffs require Codex Desktop to be installed, signed in and open on Windows/macOS. The app runs `codex-native-relay` while a regular task has its MCP servers loaded. Recovery never navigates to a task, launches a window, or activates Codex. The Desktop-owned supervisor restarts crashed native relay workers with bounded exponential backoff; pending requests fail without replay, and later requests use the recovered worker. Inherited native pipe hints are rediscovered through the original Desktop process ancestry when they become stale. Requests that never reached the relay wait up to 20 seconds, within their original deadline, for its transport to recover. `CODEX_BRIDGE_RELAY_WAKE=0` disables that wait; the retired `CODEX_BRIDGE_RELAY_KEEPER` flag cannot enable UI navigation.
673
673
 
674
- Open a regular task in the intended saved project, then check `native_relay_status` there and `codex_bridge_status` in the sending Code session. If the relay died while that task stayed loaded, reopening the same task may not restart it: reconnect its MCP or open another regular task. Do not use the internal **Native Relay** executor task as the interactive destination. Rerun setup if registration/executor configuration is missing, and verify [native relay pipe forwarding](#native-relay-pipe-forwarding). Do not persist a pipe address copied from a different app process; discovery must validate its owner.
674
+ Check `native_relay_status` in a regular project task and `codex_bridge_status` in the sending Code session. Native status performs a read-only `list_projects` probe and reports native dispatch separately from socket availability. Recovery requires the original Desktop-owned supervisor to remain alive: it cannot cold-start MCP when Desktop has loaded no session, and it stops when the MCP client closes. After five unsuccessful worker replacements, reconnect MCP in a regular task. Do not open the internal **Native Relay** executor; it is not an interactive chat and may show a missing-rollout error. Rerun setup if registration/executor configuration is missing, and verify [native relay pipe forwarding](#native-relay-pipe-forwarding). Never persist a pipe address copied from a different app process. After upgrading from a supervisor without crash recovery, reconnect the relay MCP once to load the updated supervisor; worker hot reload alone cannot replace its parent.
675
675
 
676
676
  An active-writer error usually identifies the legacy app-server route attempting to attach to a thread already owned by Desktop. Use the registered native Code-to-Desktop route for that conversation. Do not start another app-server or terminate shared work to force access. The ordinary `codex-bridge-desktop` Chat connector remains a legacy app-server connection; it is not a substitute for the verified native sender. An unused `CODEX_APP_SERVER_URL` alongside `external app-server disabled` is not by itself a native connection failure.
677
677
 
@@ -720,7 +720,7 @@ The catalog below records user-visible failures fixed in earlier releases. It is
720
720
 
721
721
  | Historical symptom | Fix version and cause | Current recovery |
722
722
  |---|---|---|
723
- | Relay vanished after app restart or was absent when a companion first started | [1.17.0](CHANGELOG.md#1170---2026-09-21): retry late pipe discovery. [1.22.14](CHANGELOG.md#12214---2026-10-21) / [1.22.16](CHANGELOG.md#12216---2026-10-22): task wake and coordinated background keeper. | Follow [relay recovery](#relay-unavailable-stale-pipe-or-an-active-writer); keep the app and a bridge client open. A dead relay in an already-loaded task may need MCP reconnect or another regular task. |
723
+ | Relay vanished after app restart or was absent when a companion first started | [1.25.2](CHANGELOG.md#1252---2026-10-31): silent worker recovery and stale-pipe rediscovery replace the historical task wake and keeper. | Follow [relay recovery](#relay-unavailable-stale-pipe-or-an-active-writer). Reconnect MCP once after upgrading the supervisor; an idle app with no loaded MCP session cannot be cold-started by silent recovery. |
724
724
  | `No handler registered for method: tools/call` or `RELAY_UNAVAILABLE` after a Codex Desktop update | [1.18.2](CHANGELOG.md#1182---2026-09-26): native caller-source compatibility. [1.22.13](CHANGELOG.md#12213---2026-10-20): discover Windows `codex-app-tools-*` pipes instead of probing only browser pipes. | Update/reload the native companion as well as the sending bridge. Check `native_relay_status`; never copy a foreign process's pipe to bypass discovery. |
725
725
  | A Windows task opened successfully, but `open_codex_thread` or relay wake reported failure | [1.22.14](CHANGELOG.md#12214---2026-10-21): replace reliance on `explorer.exe`'s misleading exit code with the native URI launcher. | Upgrade/reconnect and inspect the opened task. A navigation error is not evidence that an accepted prompt should be sent again. |
726
726
  | A macOS companion appeared installed but every native delivery was unconfirmed | [1.13.4](CHANGELOG.md#1134---2026-09-06): use the Codex-bundled runtime trusted by Desktop. | Run current setup with Codex Desktop installed and reconnect. Keep the runtime selected by the installer; an arbitrary Homebrew/nvm Node may not satisfy native host checks. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.25.0",
3
+ "version": "1.25.2",
4
4
  "description": "Connect Claude, Codex, and Antigravity through MCP, with native Desktop handoffs and bridge-owned Antigravity CLI sessions.",
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.25.0",
4
+ "version": "1.25.2",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -25,7 +25,7 @@ import { removeRelayFile } from "../src/native-relay-files.mjs";
25
25
 
26
26
  exitForVersionRequest(import.meta.url);
27
27
 
28
- const VERSION = "1.25.0";
28
+ const VERSION = "1.25.2";
29
29
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
30
30
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
31
31
  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.25.0";
33
+ const VERSION = "1.25.2";
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;
package/src/index.mjs CHANGED
@@ -37,11 +37,11 @@ import { captureProjectScope } from "./project-scope.mjs";
37
37
  import { DesktopReplyReceipts } from "./desktop-reply-receipts.mjs";
38
38
  import { registerDesktopReadTools } from "./desktop-read-tools.mjs";
39
39
  import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
40
- import { createRelayWake, relayKeeperEnabled, relayWakeStateFile, startRelayKeeper } from "./relay-wake.mjs";
40
+ import { createRelayWake } from "./relay-wake.mjs";
41
41
 
42
42
  exitForVersionRequest(import.meta.url);
43
43
 
44
- const VERSION = "1.25.0";
44
+ const VERSION = "1.25.2";
45
45
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
46
46
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
47
47
 
@@ -76,7 +76,7 @@ const desktopTasksEnabled = desktopTasksConfigured();
76
76
  const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
77
77
  const desktopOperation = new AsyncLocalStorage();
78
78
  const replyReceipts = new DesktopReplyReceipts();
79
- const relayWake = createRelayWake({ senderCwd: () => desktopOperation.getStore()?.caller?.cwd ?? null, stateFile: relayWakeStateFile(), log });
79
+ const relayWake = createRelayWake({ log });
80
80
  const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts, senderContext: () => desktopOperation.getStore()?.caller, wakeRelay: relayWake });
81
81
 
82
82
  async function assertDesktopOperation(context, { verifyProcess = false } = {}) {
@@ -1097,7 +1097,6 @@ registerTool(
1097
1097
 
1098
1098
  const transport = new StdioServerTransport();
1099
1099
  await server.connect(transport);
1100
- if (desktopTasksEnabled && relayKeeperEnabled()) startRelayKeeper({ wake: relayWake, socketPath: () => desktopTasks.relay.accountSocketPath, log });
1101
1100
  let shutdownPromise;
1102
1101
  const shutdown = () => shutdownPromise ??= (async () => {
1103
1102
  client?.close();
@@ -170,6 +170,7 @@ export async function runSupervisor(entry, options = {}) {
170
170
  let active;
171
171
  let initializeParams;
172
172
  let initializeResult;
173
+ let initializeCatalog;
173
174
  let ready = false;
174
175
  let switching = false;
175
176
  let stopping = false;
@@ -181,6 +182,17 @@ export async function runSupervisor(entry, options = {}) {
181
182
  let retryAt = 0;
182
183
  let reason = null;
183
184
  let reloads = 0;
185
+ let recoveries = 0;
186
+ let recoveryAttempts = 0;
187
+ let recoveryAt = 0;
188
+ let activeSince = Date.now();
189
+ const recoveryDelayMs = Math.min(30000, supervisorInterval(env.CODEX_BRIDGE_RECOVERY_DELAY_MS, 1000));
190
+ const recoveryLimit = 5;
191
+ const canRecover = () => entry === "native-relay-companion.mjs" && ready && initializeResult && initializeCatalog;
192
+ const scheduleRecovery = () => {
193
+ if (recoveryAttempts >= recoveryLimit) return;
194
+ recoveryAt = Date.now() + Math.min(30000, recoveryDelayMs * 2 ** recoveryAttempts);
195
+ };
184
196
  const queue = [];
185
197
  const requests = new Map();
186
198
  const reverse = new Map();
@@ -189,7 +201,7 @@ export async function runSupervisor(entry, options = {}) {
189
201
  revision: active?.release.revision ?? null, availableRevision: observed,
190
202
  routingConfiguration: active?.routingConfiguration ?? null, availableRoutingConfiguration: observedRouting,
191
203
  pending: Boolean(observed && (observed !== active?.release.revision || observedRouting !== active?.routingConfiguration)),
192
- state: failure ? "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads });
204
+ state: failure ? canRecover() && recoveryAttempts < recoveryLimit ? "recovering" : "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads, recoveries, recoveryAttempts });
193
205
 
194
206
  const handlers = {
195
207
  message(worker, message) {
@@ -210,12 +222,16 @@ export async function runSupervisor(entry, options = {}) {
210
222
  const respond = async () => {
211
223
  if (pending.method === "initialize" && !message.error) {
212
224
  initializeResult = message.result;
225
+ if (entry === "native-relay-companion.mjs") {
226
+ initializeCatalog = await worker.rpc("tools/list", {});
227
+ if (!Array.isArray(initializeCatalog.tools) || !initializeCatalog.tools.length || initializeCatalog.nextCursor) throw new Error("The native relay tool catalog is incomplete");
228
+ }
213
229
  await worker.control("activate");
214
230
  ready = true;
215
231
  }
216
232
  if (message.result && ["codex_bridge_status", "claude_bridge_status", "native_relay_status"].includes(pending.tool)) {
217
233
  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}` : ""}` }];
234
+ 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
235
  }
220
236
  output({ ...message, id: pending.id });
221
237
  };
@@ -228,7 +244,15 @@ export async function runSupervisor(entry, options = {}) {
228
244
  worker.clientPending.delete(id);
229
245
  }
230
246
  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); }
247
+ if (worker === active && !worker.retiring && !stopping) {
248
+ failure = `${error.message}; automatic replay is disabled because delivery state may be unknown`;
249
+ if (canRecover()) {
250
+ if (Date.now() - activeSince >= 60000) recoveryAttempts = 0;
251
+ scheduleRecovery();
252
+ if (recoveryAttempts >= recoveryLimit) failure += "; recovery limit reached, reconnect MCP";
253
+ }
254
+ log(failure);
255
+ }
232
256
  },
233
257
  };
234
258
  const spawn = (release, routing) => {
@@ -245,7 +269,56 @@ export async function runSupervisor(entry, options = {}) {
245
269
  return worker;
246
270
  };
247
271
  const check = async () => {
248
- if (switching || !ready || stopping || failure) return;
272
+ if (switching || !ready || stopping) return;
273
+ if (failure) {
274
+ if (!canRecover() || recoveryAttempts >= recoveryLimit || Date.now() < recoveryAt) return;
275
+ switching = true;
276
+ recoveryAttempts++;
277
+ const previous = active;
278
+ let candidate;
279
+ try {
280
+ await previous.stop();
281
+ workers.delete(previous);
282
+ if (stopping) return;
283
+ const source = currentRoot();
284
+ const release = createReleaseSnapshot(source, { expectedRevision: sourceRevision(source), lease: true });
285
+ candidate = spawn(release, routingConfiguration());
286
+ const initialized = await candidate.rpc("initialize", initializeParams);
287
+ if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
288
+ throw new Error("The replacement MCP protocol or capabilities require an explicit client reconnect");
289
+ }
290
+ candidate.write({ jsonrpc: "2.0", method: "notifications/initialized" });
291
+ const catalog = await candidate.rpc("tools/list", {});
292
+ if (JSON.stringify(catalog) !== JSON.stringify(initializeCatalog)) throw new Error("The replacement tool catalog requires an explicit client reconnect");
293
+ await candidate.control("activate");
294
+ if (stopping || candidate.closed) throw new Error("The relay replacement stopped before activation completed");
295
+ active = candidate;
296
+ candidate = null;
297
+ activeSince = Date.now();
298
+ failure = null;
299
+ reason = null;
300
+ recoveries++;
301
+ log(`recovered native relay worker after exit; recovery ${recoveries}, no requests replayed`);
302
+ schedulePrune();
303
+ } catch (error) {
304
+ failure = `Native relay recovery failed: ${error.message}; no requests were replayed`;
305
+ if (candidate) {
306
+ try {
307
+ await candidate.stop();
308
+ workers.delete(candidate);
309
+ } catch (stopError) {
310
+ recoveryAttempts = recoveryLimit;
311
+ failure += `; replacement shutdown could not be confirmed: ${stopError.message}`;
312
+ }
313
+ }
314
+ scheduleRecovery();
315
+ if (recoveryAttempts >= recoveryLimit) failure += "; recovery limit reached, reconnect MCP";
316
+ log(failure);
317
+ } finally {
318
+ switching = false;
319
+ }
320
+ return;
321
+ }
249
322
  let source;
250
323
  let revision;
251
324
  let routing;
@@ -279,8 +352,12 @@ export async function runSupervisor(entry, options = {}) {
279
352
  await candidate.control("restore", exported.state);
280
353
  await candidate.control("activate");
281
354
  if (stopping) throw new Error("The MCP client disconnected during activation");
355
+ if (candidate.closed) throw new Error("The replacement worker exited during activation");
282
356
  active = candidate;
283
357
  candidate = null;
358
+ initializeCatalog = catalog;
359
+ activeSince = Date.now();
360
+ failure = null;
284
361
  reloads++;
285
362
  reason = null;
286
363
  if (JSON.stringify(catalog) !== JSON.stringify(oldCatalog)) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
@@ -321,7 +398,7 @@ export async function runSupervisor(entry, options = {}) {
321
398
  }
322
399
  return;
323
400
  }
324
- if (failure) { if (hasId(message)) output(errorMessage(message.id, failure)); return; }
401
+ if (failure) { if (hasId(message)) output(errorMessage(message.id, `${failure}${canRecover() && recoveryAttempts < recoveryLimit ? "; native relay recovery is pending, this request was not sent" : ""}`)); return; }
325
402
  if (switching || !active) {
326
403
  const bytes = queue.reduce((size, queued) => size + Buffer.byteLength(JSON.stringify(queued)), 0);
327
404
  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; }
@@ -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.0";
46
+ const VERSION = "1.25.2";
46
47
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
47
48
 
48
49
  function errorResponse(code, message, sent, details) {
@@ -865,6 +866,20 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
865
866
  };
866
867
  }
867
868
 
869
+ export async function probeRelayDispatch({ env = process.env, timeoutMs = 3000, accounts = readBridgeAccounts, relay = new NativeDesktopRelay({ env, socketPath: desktopTaskSocketPath(env), accountSocketPath: accountRelaySocketPath(env) }) } = {}) {
870
+ try {
871
+ const accountContext = accountIdentity(accounts());
872
+ const response = await relay.requestDesktop("list_projects", {}, {
873
+ timeoutMs,
874
+ ...(accountContext ? { accountContext, beforeSend: () => assertAccountIdentity(accountContext) } : {}),
875
+ });
876
+ if (!Array.isArray(response?.result?.projects)) throw new Error("Native Desktop did not return a project list");
877
+ return { ready: true, checkedAt: new Date().toISOString(), scope: "shared-relay-native-dispatch" };
878
+ } catch (error) {
879
+ return { ready: false, checkedAt: new Date().toISOString(), scope: "shared-relay-native-dispatch", code: error.code ?? "NATIVE_HEALTH_CHECK_FAILED", reason: error.message };
880
+ }
881
+ }
882
+
868
883
  export function createRelayServers({ nativeTools, env = process.env, signalExit = true, log: logFn = () => {} }) {
869
884
  const hardened = hardenedBridgeEnabled(env);
870
885
  if (hardened && !desktopTasksConfigured(env)) throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_DESKTOP_TASKS=1; legacy relay listeners are disabled");
@@ -938,8 +953,9 @@ if (invokedDirectly) {
938
953
  } catch (err) {
939
954
  executor = err.message;
940
955
  }
956
+ const nativeDispatch = listening ? await probeRelayDispatch() : { ready: false, reason: "Relay transport is not listening" };
941
957
  return {
942
- structuredContent: { runtime: runtime.status() },
958
+ structuredContent: { runtime: runtime.status(), nativeDispatch },
943
959
  content: [
944
960
  {
945
961
  type: "text",
@@ -953,6 +969,7 @@ if (invokedDirectly) {
953
969
  `remote Codex: ${remoteRelay.socketPath} (${await remoteRelay.isListening() ? "listening" : "not listening"})`,
954
970
  `executor: ${executor}`,
955
971
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
972
+ `native dispatch: ${nativeDispatch.ready ? "verified (read-only list_projects)" : `unavailable (${nativeDispatch.reason})`}`,
956
973
  `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
957
974
  ].join("\n"),
958
975
  },
@@ -560,9 +560,9 @@ export async function resolveNativeToolsPipePath({
560
560
  }
561
561
 
562
562
  export class NativeToolsClient {
563
- constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }), assertAccount = assertAccountIdentity } = {}) {
563
+ constructor({ env = process.env, socketPath, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env: { ...env, CODEX_APP_TOOLS_PIPE_PATH: undefined } }), assertAccount = assertAccountIdentity } = {}) {
564
564
  this.env = env;
565
- this.socketPath = socketPath;
565
+ this.socketPath = socketPath ?? env.CODEX_APP_TOOLS_PIPE_PATH;
566
566
  this.explicitSocketPath = socketPath || null;
567
567
  this.hasDiscoveredSocket = false;
568
568
  this.timeoutMs = timeoutMs;
@@ -581,7 +581,7 @@ export class NativeToolsClient {
581
581
  if (this.socket && !this.socket.destroyed) return;
582
582
  const generation = this.connectionGeneration;
583
583
  this.connecting = (async () => {
584
- const socketPath = this.socketPath || await this.resolveSocketPath();
584
+ const socketPath = this.socketPath || await this.resolveSocketPath() || this.env.CODEX_APP_TOOLS_PIPE_PATH;
585
585
  if (generation !== this.connectionGeneration) {
586
586
  throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
587
587
  }
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,22 +1,6 @@
1
- import fs from "node:fs";
2
1
  import net from "node:net";
3
- import path from "node:path";
4
2
 
5
- import { codexHome, resolveRelayThreadId } from "./native-relay.mjs";
6
- import { isCodexDesktopRunning, openThreadInCodexApp } from "./platform.mjs";
7
- import { captureProjectScope } from "./project-scope.mjs";
8
- import { readRolloutFirstRecord } from "./rollout-reader.mjs";
9
-
10
- const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
- const ROLLOUT = /^rollout-.+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i;
12
3
  export const RELAY_WAKE_WAIT_MS = 20000;
13
- export const RELAY_WAKE_COOLDOWN_MS = 60000;
14
- export const RELAY_KEEPER_INTERVAL_MS = 30000;
15
- export const RELAY_KEEPER_WAIT_MS = 60000;
16
- export const RELAY_KEEPER_MAX_BACKOFF_MS = 30 * 60000;
17
- export const DESKTOP_RECHECK_MS = 60000;
18
- const MAX_DAY_DIRECTORIES = 14;
19
- const MAX_CANDIDATES = 40;
20
4
 
21
5
  export function relayWakeEnabled(env = process.env, platform = process.platform) {
22
6
  const value = (env.CODEX_BRIDGE_RELAY_WAKE ?? "").toLowerCase();
@@ -24,69 +8,14 @@ export function relayWakeEnabled(env = process.env, platform = process.platform)
24
8
  return platform === "darwin" || platform === "win32";
25
9
  }
26
10
 
27
- function dayDirectories(sessions) {
28
- const directories = [];
29
- const children = (directory, pattern) => {
30
- try { return fs.readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory() && pattern.test(entry.name)).map((entry) => entry.name).sort().reverse(); }
31
- catch { return []; }
32
- };
33
- for (const year of children(sessions, /^\d{4}$/)) {
34
- for (const month of children(path.join(sessions, year), /^\d{2}$/)) {
35
- for (const day of children(path.join(sessions, year, month), /^\d{2}$/)) {
36
- directories.push(path.join(sessions, year, month, day));
37
- if (directories.length >= MAX_DAY_DIRECTORIES) return directories;
38
- }
39
- }
40
- }
41
- return directories;
42
- }
43
-
44
- function sameProject(senderCwd, taskCwd) {
45
- try { captureProjectScope(senderCwd, taskCwd); return true; }
46
- catch { return false; }
47
- }
48
-
49
- export function relayKeeperEnabled(env = process.env, platform = process.platform) {
50
- const value = (env.CODEX_BRIDGE_RELAY_KEEPER ?? "").toLowerCase();
51
- if (["0", "off", "false"].includes(value)) return false;
52
- return relayWakeEnabled(env, platform);
53
- }
54
-
55
- export function relayWakeStateFile(env = process.env) {
56
- return path.join(codexHome(env), "bridge-relay-wake.json");
57
- }
58
-
59
- export async function recentDesktopTaskFor(cwd, { env = process.env, sessions = path.join(codexHome(env), "sessions"), exclude = [], anyProject = false } = {}) {
60
- if (!anyProject && (typeof cwd !== "string" || !cwd)) return null;
61
- const excluded = new Set(exclude.filter(Boolean));
62
- const candidates = [];
63
- for (const directory of dayDirectories(sessions)) {
64
- let entries;
65
- try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
66
- for (const entry of entries) {
67
- const match = entry.isFile() ? ROLLOUT.exec(entry.name) : null;
68
- if (!match || excluded.has(match[1])) continue;
69
- const file = path.join(directory, entry.name);
70
- try { candidates.push({ file, threadId: match[1], mtimeMs: fs.lstatSync(file).mtimeMs }); } catch {}
71
- }
72
- if (candidates.length >= MAX_CANDIDATES) break;
73
- }
74
- candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
75
- for (const candidate of candidates.slice(0, MAX_CANDIDATES)) {
76
- let record;
77
- try { record = await readRolloutFirstRecord(candidate.file, { maxBytes: 256 * 1024 }); } catch { continue; }
78
- const meta = record?.type === "session_meta" ? record.payload : null;
79
- if (meta?.id !== candidate.threadId || meta.originator !== "Codex Desktop" || typeof meta.cwd !== "string") continue;
80
- if (anyProject || sameProject(cwd, meta.cwd)) return candidate.threadId;
81
- }
82
- return null;
83
- }
84
-
85
11
  export function endpointAnswers(socketPath, { timeoutMs = 1000 } = {}) {
86
12
  if (typeof socketPath !== "string" || !socketPath) return Promise.resolve(false);
87
13
  return new Promise((resolve) => {
88
14
  const probe = net.connect({ path: socketPath });
15
+ let settled = false;
89
16
  const done = (answer) => {
17
+ if (settled) return;
18
+ settled = true;
90
19
  globalThis.clearTimeout(timer);
91
20
  probe.destroy();
92
21
  resolve(answer);
@@ -94,163 +23,33 @@ export function endpointAnswers(socketPath, { timeoutMs = 1000 } = {}) {
94
23
  const timer = globalThis.setTimeout(() => done(false), timeoutMs);
95
24
  probe.once("connect", () => done(true));
96
25
  probe.once("error", () => done(false));
26
+ probe.once("close", () => done(false));
97
27
  });
98
28
  }
99
29
 
100
- function readWakeState(file) {
101
- if (!file) return {};
102
- try {
103
- const state = JSON.parse(fs.readFileSync(file, "utf8"));
104
- return state && typeof state === "object" && !Array.isArray(state) ? state : {};
105
- } catch { return {}; }
106
- }
107
-
108
- function writeWakeState(file, state) {
109
- if (!file) return;
110
- const temporary = `${file}.${process.pid}.tmp`;
111
- try {
112
- fs.writeFileSync(temporary, JSON.stringify(state));
113
- fs.renameSync(temporary, file);
114
- } catch { try { fs.rmSync(temporary, { force: true }); } catch {} }
115
- }
116
-
117
- function acquireWakeLock(file, now, staleMs) {
118
- if (!file) return () => {};
119
- const lock = `${file}.lock`;
120
- const claim = () => {
121
- const descriptor = fs.openSync(lock, "wx");
122
- try { fs.writeSync(descriptor, JSON.stringify({ pid: process.pid, at: now() })); } finally { fs.closeSync(descriptor); }
123
- return () => { try { fs.rmSync(lock, { force: true }); } catch {} };
124
- };
125
- try { return claim(); }
126
- catch (error) { if (error.code !== "EEXIST") return null; }
127
- let heldSince;
128
- try { heldSince = JSON.parse(fs.readFileSync(lock, "utf8")).at; } catch { heldSince = 0; }
129
- if (Number.isFinite(heldSince) && now() - heldSince < staleMs) return null;
130
- try { fs.rmSync(lock, { force: true }); return claim(); } catch { return null; }
131
- }
132
-
133
- export function keeperBackoffMs(failures, { baseMs = RELAY_WAKE_COOLDOWN_MS, maxMs = RELAY_KEEPER_MAX_BACKOFF_MS } = {}) {
134
- const count = Number.isInteger(failures) && failures > 0 ? Math.min(failures, 16) : 0;
135
- return count === 0 ? 0 : Math.min(baseMs * 2 ** (count - 1), maxMs);
136
- }
137
-
138
30
  export function createRelayWake({
139
31
  env = process.env,
140
32
  enabled = relayWakeEnabled(env),
141
- desktopRunning = () => isCodexDesktopRunning(),
142
- senderCwd = () => null,
143
- findTask = (cwd, exclude) => recentDesktopTaskFor(cwd, { env, exclude }),
144
- findAnyTask = (exclude) => recentDesktopTaskFor(null, { env, exclude, anyProject: true }),
145
- executorThreadId = () => { try { return resolveRelayThreadId(env).threadId; } catch { return null; } },
146
- open = (threadId) => openThreadInCodexApp(threadId, { activate: false }),
147
33
  answers = endpointAnswers,
148
34
  waitMs = RELAY_WAKE_WAIT_MS,
149
- backgroundWaitMs = RELAY_KEEPER_WAIT_MS,
150
- cooldownMs = RELAY_WAKE_COOLDOWN_MS,
151
- desktopRecheckMs = DESKTOP_RECHECK_MS,
152
- stateFile = null,
153
35
  pollMs = 250,
154
36
  now = Date.now,
155
37
  sleep = (ms) => new Promise((resolve) => globalThis.setTimeout(resolve, ms)),
156
38
  log = () => {},
157
39
  } = {}) {
158
- let inFlight = null;
159
- let failedAt = -Infinity;
160
- const waitFor = async (socketPath, until) => {
40
+ return async ({ socketPath, deadline }) => {
41
+ if (!enabled || typeof socketPath !== "string" || !socketPath) return null;
42
+ const until = Math.min(now() + waitMs, deadline ?? Infinity);
161
43
  while (now() < until) {
162
- if (await answers(socketPath)) return true;
163
- await sleep(pollMs);
164
- }
165
- return false;
166
- };
167
- const attempt = async ({ socketPath, threadId, deadline, background = false }) => {
168
- if (!enabled) return null;
169
- const state = readWakeState(stateFile);
170
- if (background) {
171
- if (state.desktopRunning === false && now() - (state.desktopCheckedAt ?? -Infinity) < desktopRecheckMs) return null;
172
- if (now() - (state.attemptAt ?? -Infinity) < Math.max(keeperBackoffMs(state.failures), cooldownMs)) return null;
173
- } else if (now() - failedAt < cooldownMs) return null;
174
- const running = await desktopRunning();
175
- if (stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), desktopCheckedAt: now(), desktopRunning: Boolean(running) });
176
- if (!running) return null;
177
- const wait = background ? backgroundWaitMs : waitMs;
178
- const release = acquireWakeLock(stateFile, now, wait + 15000);
179
- if (!release) {
180
- if (background) return null;
181
- return await waitFor(socketPath, Math.min(now() + wait, deadline ?? Infinity)) ? { threadId: null } : null;
182
- }
183
- try {
184
- const executor = executorThreadId();
185
- const target = typeof threadId === "string" && THREAD_ID.test(threadId) && threadId !== executor
186
- ? threadId : (await findTask(senderCwd(), [executor])) ?? (await findAnyTask([executor]));
187
- const startedAt = now();
188
- const failures = Number.isInteger(state.failures) ? state.failures : 0;
189
- if (!target) {
190
- if (background && stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), attemptAt: startedAt, failures: failures + 1 });
191
- return null;
44
+ if (await answers(socketPath, { timeoutMs: Math.min(1000, until - now()) })) {
45
+ if (now() >= until) return null;
46
+ log("relay transport available; the original operation will verify native dispatch");
47
+ return { threadId: null };
192
48
  }
193
- const fail = (message) => {
194
- failedAt = now();
195
- if (stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), attemptAt: startedAt, failures: failures + 1 });
196
- log(message);
197
- return null;
198
- };
199
- try { await open(target); }
200
- catch (error) { return fail(`could not open Codex task ${target} to start the native relay: ${error.message}`); }
201
- if (await waitFor(socketPath, Math.min(now() + wait, deadline ?? Infinity))) {
202
- if (stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), attemptAt: startedAt, failures: 0 });
203
- log(`started the Codex Desktop native relay by opening task ${target} in the background`);
204
- return { threadId: target };
205
- }
206
- return fail(`opened Codex task ${target}, but the native relay did not start within ${wait} ms`);
207
- } finally {
208
- release();
49
+ const remaining = until - now();
50
+ if (remaining > 0) await sleep(Math.min(pollMs, remaining));
209
51
  }
210
- };
211
- return (request) => {
212
- if (!inFlight) return inFlight = attempt(request).finally(() => { inFlight = null; });
213
- if (request.background || !request.socketPath) return inFlight;
214
- const until = Math.min(now() + waitMs, request.deadline ?? Infinity);
215
- return Promise.race([inFlight, waitFor(request.socketPath, until).then((ready) => ready ? { threadId: null } : null)]);
216
- };
217
- }
218
-
219
- export function startRelayKeeper({
220
- wake,
221
- socketPath,
222
- answers = endpointAnswers,
223
- intervalMs = RELAY_KEEPER_INTERVAL_MS,
224
- initialDelayMs = 3000,
225
- timers = globalThis,
226
- log = () => {},
227
- }) {
228
- let busy = false;
229
- let stopped = false;
230
- const tick = async () => {
231
- if (busy || stopped) return null;
232
- busy = true;
233
- try {
234
- const target = socketPath();
235
- if (await answers(target)) return null;
236
- return await wake({ socketPath: target, background: true });
237
- } catch (error) {
238
- log(`relay keeper check failed: ${error.message}`);
239
- return null;
240
- } finally {
241
- busy = false;
242
- }
243
- };
244
- const first = timers.setTimeout(tick, initialDelayMs);
245
- const repeat = timers.setInterval(tick, intervalMs);
246
- first?.unref?.();
247
- repeat?.unref?.();
248
- return {
249
- tick,
250
- stop() {
251
- stopped = true;
252
- timers.clearTimeout(first);
253
- timers.clearInterval(repeat);
254
- },
52
+ log("native relay recovery timed out; no task or window was opened");
53
+ return null;
255
54
  };
256
55
  }
@@ -74,7 +74,7 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
74
74
  return matches[0];
75
75
  }
76
76
 
77
- export const RELAY_DOWN_GUIDANCE = "The Codex Desktop native relay is not listening: 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.";
77
+ export const RELAY_DOWN_GUIDANCE = "The Codex Desktop native relay is not listening after waiting for silent recovery. Its Desktop-owned supervisor restarts crashed relay workers without replaying requests. If Desktop has not loaded the relay MCP, reconnect it in a regular project task; automatic recovery never opens a task or window. Do not open the internal Native Relay executor task.";
78
78
 
79
79
  function relayUnreachable(error) {
80
80
  return error?.code === "RELAY_UNREACHABLE" && error.sent === false;
@@ -126,23 +126,25 @@ export class DesktopTaskDelivery {
126
126
  if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
127
127
  const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
128
128
  if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
129
- const remaining = deadline === undefined ? undefined : deadline - this.now();
130
- if (remaining !== undefined && remaining <= 0) throw Object.assign(new Error("The bridge's response deadline has elapsed; this operation was not sent"), { code: "DESKTOP_RESPONSE_DEADLINE" });
131
129
  const accountContext = this.accountContext?.();
132
- const send = () => this.relay.requestDesktop(operation, args, {
133
- ...(remaining === undefined ? {} : { timeoutMs: remaining }),
134
- ...(this.beforeRequest || projectScope || scopeBinding ? { beforeSend: async () => {
135
- await this.beforeRequest?.({ operation, args, phase: "write" });
136
- if (projectScope) {
137
- recheckProjectScope(projectScope);
138
- recheckProjectScope(projectScope, await this.mutationProjectScope(operation, args, { deadline }));
139
- }
140
- await this.beforeRequest?.({ operation, args, phase: "dispatch" });
141
- if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
142
- } } : {}),
143
- ...(accountContext ? { accountContext } : {}),
144
- ...(creationReceiptId ? { creationReceiptId } : {}),
145
- });
130
+ const send = () => {
131
+ const remaining = deadline === undefined ? undefined : deadline - this.now();
132
+ if (remaining !== undefined && remaining <= 0) throw Object.assign(new Error("The bridge's response deadline has elapsed; this operation was not sent"), { code: "DESKTOP_RESPONSE_DEADLINE" });
133
+ return this.relay.requestDesktop(operation, args, {
134
+ ...(remaining === undefined ? {} : { timeoutMs: remaining }),
135
+ ...(this.beforeRequest || projectScope || scopeBinding ? { beforeSend: async () => {
136
+ await this.beforeRequest?.({ operation, args, phase: "write" });
137
+ if (projectScope) {
138
+ recheckProjectScope(projectScope);
139
+ recheckProjectScope(projectScope, await this.mutationProjectScope(operation, args, { deadline }));
140
+ }
141
+ await this.beforeRequest?.({ operation, args, phase: "dispatch" });
142
+ if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
143
+ } } : {}),
144
+ ...(accountContext ? { accountContext } : {}),
145
+ ...(creationReceiptId ? { creationReceiptId } : {}),
146
+ });
147
+ };
146
148
  let response;
147
149
  try {
148
150
  response = await send();