@minhspark/codex-mcp-bridge 1.12.0 → 1.12.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.
package/src/index.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
+ import path from "node:path";
6
+ import { realpathSync } from "node:fs";
5
7
 
6
8
  import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
7
9
  import {
@@ -21,7 +23,7 @@ import {
21
23
  import { runTurn } from "./turn.mjs";
22
24
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
23
25
 
24
- const VERSION = "1.12.0";
26
+ const VERSION = "1.12.2";
25
27
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
26
28
 
27
29
  /**
@@ -38,6 +40,7 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
38
40
  ? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
39
41
  : IS_WINDOWS;
40
42
  const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
43
+ const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
41
44
  const security = new BridgeSecurityPolicy();
42
45
 
43
46
  const client = new CodexAppServerClient({
@@ -114,6 +117,15 @@ async function createCodexThread({ cwd, model, name, prompt }) {
114
117
  });
115
118
  const thread = res?.thread ?? {};
116
119
  if (!thread.id) throw new Error("Codex app-server created no thread id");
120
+ try {
121
+ security.assertCwd(thread.cwd);
122
+ if (!path.isAbsolute(thread.cwd) || path.relative(realpathSync(workspace.path), realpathSync(thread.cwd))) {
123
+ throw new Error("Codex app-server created the thread in a different workspace than requested");
124
+ }
125
+ } catch (err) {
126
+ await client.releaseThread(thread.id).catch(() => {});
127
+ throw err;
128
+ }
117
129
  const threadName = name || prompt ? threadNameFor({ cwd: thread.cwd ?? workspace.path, prompt, name }) : null;
118
130
  if (threadName) {
119
131
  await client.call("thread/name/set", { threadId: thread.id, name: threadName });
@@ -133,29 +145,22 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
133
145
  const notes = [];
134
146
  let canOpenAfterRelease = true;
135
147
  const terminal = TERMINAL_TURN_STATUSES.has(result.status);
148
+ const releasable = RELEASE_TURN_STATUSES.has(result.status);
136
149
 
137
- if (releaseAfterTurn && terminal) {
150
+ if (releaseAfterTurn && releasable) {
138
151
  try {
139
- const released = await client.stopServer();
140
- if (released.stopped) {
141
- if (released.stillListening) {
142
- canOpenAfterRelease = false;
143
- notes.push(
144
- `stop requested for app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}, but it is still listening`,
145
- );
146
- notes.push("WARNING: the app-server is still listening, so the desktop thread was not opened to avoid another lock");
147
- } else {
148
- notes.push(
149
- `released app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}; Codex Desktop can write this thread`,
150
- );
151
- }
152
+ const released = await client.releaseThread(threadId);
153
+ if (released.released) {
154
+ notes.push(`released thread ${threadId}; other app-server threads remain active`);
152
155
  } else {
153
156
  canOpenAfterRelease = false;
154
- notes.push(`could not release app-server: ${released.reason}`);
157
+ notes.push(released.unsubscribed
158
+ ? `unsubscribed from thread ${threadId}; desktop opening is deferred until the server unloads it`
159
+ : `could not release thread: ${released.reason ?? released.status}`);
155
160
  }
156
161
  } catch (err) {
157
162
  canOpenAfterRelease = false;
158
- notes.push(`could not release app-server: ${err.message}`);
163
+ notes.push(`could not release thread: ${err.message}`);
159
164
  }
160
165
  }
161
166
 
@@ -265,7 +270,7 @@ server.registerTool(
265
270
  releaseAfterTurn: z
266
271
  .boolean()
267
272
  .optional()
268
- .describe("Stop the bridge app-server after a terminal turn so Codex Desktop owns the writer lock"),
273
+ .describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
269
274
  },
270
275
  annotations: {
271
276
  readOnlyHint: false,
@@ -356,7 +361,7 @@ server.registerTool(
356
361
  releaseAfterTurn: z
357
362
  .boolean()
358
363
  .optional()
359
- .describe("Stop the bridge app-server after a terminal turn so Codex Desktop owns the writer lock"),
364
+ .describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
360
365
  },
361
366
  annotations: {
362
367
  readOnlyHint: false,
@@ -366,64 +371,66 @@ server.registerTool(
366
371
  },
367
372
  },
368
373
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
369
- const notes = [];
370
- const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
371
- const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
372
- try {
373
- const authorizedThread = await assertThreadAccess(threadId);
374
- let resolvedCwd = null;
375
- if (cwd) {
376
- const workspace = resolveWorkspacePath(cwd);
377
- security.assertCwd(workspace.path);
378
- resolvedCwd = workspace.path;
379
- if (workspace.note) notes.push(workspace.note);
380
- } else if (authorizedThread?.cwd) {
381
- const workspace = resolveWorkspacePath(authorizedThread.cwd);
382
- resolvedCwd = workspace.path;
383
- if (workspace.note) notes.push(workspace.note);
384
- }
385
- const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
386
- const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
387
- security.assertCwd(attachedThread?.cwd);
388
- if (name) {
389
- await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
390
- notes.push(`session name: ${name.trim().slice(0, 200)}`);
391
- }
392
- /**
393
- * Opening the thread in the app comes after both gates. It ran first
394
- * once, which meant a thread this bridge was about to refuse still got
395
- * raised on screen - a refusal that leaked which threads exist.
396
- */
397
- if (shouldOpen && !shouldRelease) {
398
- try {
399
- notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
400
- } catch (err) {
401
- notes.push(`could not open the thread in the Codex app: ${err.message}`);
374
+ return client.withThread(threadId, async () => {
375
+ const notes = [];
376
+ const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
377
+ const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
378
+ try {
379
+ const authorizedThread = await assertThreadAccess(threadId);
380
+ let resolvedCwd = null;
381
+ if (cwd) {
382
+ const workspace = resolveWorkspacePath(cwd);
383
+ security.assertCwd(workspace.path);
384
+ resolvedCwd = workspace.path;
385
+ if (workspace.note) notes.push(workspace.note);
386
+ } else if (authorizedThread?.cwd) {
387
+ const workspace = resolveWorkspacePath(authorizedThread.cwd);
388
+ resolvedCwd = workspace.path;
389
+ if (workspace.note) notes.push(workspace.note);
390
+ }
391
+ const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
392
+ const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
393
+ security.assertCwd(attachedThread?.cwd);
394
+ if (name) {
395
+ await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
396
+ notes.push(`session name: ${name.trim().slice(0, 200)}`);
402
397
  }
398
+ /**
399
+ * Opening the thread in the app comes after both gates. It ran first
400
+ * once, which meant a thread this bridge was about to refuse still got
401
+ * raised on screen - a refusal that leaked which threads exist.
402
+ */
403
+ if (shouldOpen && !shouldRelease) {
404
+ try {
405
+ notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
406
+ } catch (err) {
407
+ notes.push(`could not open the thread in the Codex app: ${err.message}`);
408
+ }
409
+ }
410
+ const result = await runTurn(client, {
411
+ threadId,
412
+ input: [{ type: "text", text: prompt }],
413
+ timeoutMs: (timeoutSec ?? 240) * 1000,
414
+ turnOverrides: {
415
+ ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
416
+ ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
417
+ ...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
418
+ },
419
+ });
420
+ const body = formatTurn(result);
421
+ const failed = result.status === "failed" || result.status === "disconnected";
422
+ notes.push(...(await finishDesktopHandoff({
423
+ threadId,
424
+ result,
425
+ openInApp: shouldOpen,
426
+ releaseAfterTurn: shouldRelease,
427
+ })));
428
+ const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
429
+ return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
430
+ } catch (err) {
431
+ return failure(err);
403
432
  }
404
- const result = await runTurn(client, {
405
- threadId,
406
- input: [{ type: "text", text: prompt }],
407
- timeoutMs: (timeoutSec ?? 240) * 1000,
408
- turnOverrides: {
409
- ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
410
- ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
411
- ...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
412
- },
413
- });
414
- const body = formatTurn(result);
415
- const failed = result.status === "failed" || result.status === "disconnected";
416
- notes.push(...(await finishDesktopHandoff({
417
- threadId,
418
- result,
419
- openInApp: shouldOpen,
420
- releaseAfterTurn: shouldRelease,
421
- })));
422
- const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
423
- return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
424
- } catch (err) {
425
- return failure(err);
426
- }
433
+ });
427
434
  },
428
435
  );
429
436
 
@@ -457,16 +464,40 @@ server.registerTool(
457
464
  }
458
465
  if (searchTerm) params.searchTerm = searchTerm;
459
466
  const method = loadedOnly ? "thread/loaded/list" : "thread/list";
460
- const res = await client.call(method, loadedOnly ? { limit: limit ?? 15 } : params);
461
- const rows = security.filterThreads(
462
- (res?.data ?? res?.threads ?? []).flatMap((thread) => {
467
+ const rows = [];
468
+ const seenIds = new Set();
469
+ const seenCursors = new Set();
470
+ let cursor;
471
+ do {
472
+ const res = await client.call(method, loadedOnly ? { limit: params.limit, ...(cursor ? { cursor } : {}) } : params);
473
+ let threads = res?.data ?? res?.threads ?? [];
474
+ if (loadedOnly) {
475
+ threads = await Promise.all(threads.map(async (threadId) => {
476
+ if (seenIds.has(threadId)) return null;
477
+ seenIds.add(threadId);
478
+ try {
479
+ const read = await client.call("thread/read", { threadId });
480
+ return read?.thread ?? null;
481
+ } catch {
482
+ return null;
483
+ }
484
+ }));
485
+ }
486
+ rows.push(...security.filterThreads(threads.flatMap((thread) => {
463
487
  try {
464
- return [normalizeThreadCwd(thread, { strict: true })];
488
+ const normalized = normalizeThreadCwd(thread, { strict: true });
489
+ if (loadedOnly && params.cwd && (!normalized?.cwd || path.relative(params.cwd.paths[0], normalized.cwd))) return [];
490
+ if (loadedOnly && searchTerm && !String(normalized?.name ?? normalized?.preview ?? "").toLowerCase().includes(searchTerm.toLowerCase())) return [];
491
+ return [normalized];
465
492
  } catch {
466
493
  return [];
467
494
  }
468
- }),
469
- );
495
+ })));
496
+ cursor = res?.nextCursor;
497
+ if (!loadedOnly || !cursor || seenCursors.has(cursor)) break;
498
+ seenCursors.add(cursor);
499
+ } while (rows.length < params.limit);
500
+ rows.splice(params.limit);
470
501
  if (!rows.length) {
471
502
  return textResult(
472
503
  security.summary().allowedRoots.length
@@ -646,6 +677,9 @@ server.registerTool(
646
677
  async () => {
647
678
  try {
648
679
  const result = await client.stopServer();
680
+ if (result.stillListening) {
681
+ return textResult("The app-server is still listening after the stop request; its thread writer locks are not confirmed released.", true);
682
+ }
649
683
  return textResult(
650
684
  result.stopped
651
685
  ? `Stopped the shared app-server (pid ${result.pids.join(", ")}). Its thread writer locks are released, so the Codex desktop app now owns ~/.codex and every thread it was holding.`
@@ -6,32 +6,18 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
- import { z } from "zod";
10
9
 
11
10
  import {
12
11
  MAX_FRAME_BYTES,
13
12
  NATIVE_DISPATCH_METHOD,
13
+ NativeToolsClient,
14
14
  RELAY_PROTOCOL_VERSION,
15
- nativeDispatchParams,
16
15
  relaySocketPath,
17
16
  resolveRelayThreadId,
18
17
  } from "./native-relay.mjs";
19
- import { PLATFORM_LABEL } from "./platform.mjs";
18
+ import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
20
19
 
21
- /**
22
- * The companion half of the Codex Desktop native relay.
23
- *
24
- * Codex Desktop launches this as one of its own MCP servers, so the connection
25
- * it answers on belongs to the app's real app-server - the one already holding
26
- * the writer lock of every thread the human has open. Asking that app-server to
27
- * deliver a message is therefore not a second writer, and the thread stays open
28
- * and owned by Codex Desktop throughout.
29
- *
30
- * Everything else is deliberately small: a private socket, one accepted shape
31
- * (`{ targetThreadId, message }`), one dispatch, one acknowledgement.
32
- */
33
-
34
- const VERSION = "1.12.0";
20
+ const VERSION = "1.12.2";
35
21
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
36
22
 
37
23
  function errorResponse(code, message) {
@@ -56,6 +42,11 @@ export async function handleRelayRequest(
56
42
  payload,
57
43
  { dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
58
44
  ) {
45
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
46
+ Object.keys(payload).some((key) => !["v", "targetThreadId", "message"].includes(key)) ||
47
+ (payload.v !== undefined && payload.v !== RELAY_PROTOCOL_VERSION)) {
48
+ return errorResponse("RELAY_BAD_REQUEST", "expected a relay request with targetThreadId and message");
49
+ }
59
50
  const targetThreadId = typeof payload?.targetThreadId === "string" ? payload.targetThreadId.trim() : "";
60
51
  const message = typeof payload?.message === "string" ? payload.message : "";
61
52
 
@@ -84,6 +75,10 @@ export async function handleRelayRequest(
84
75
 
85
76
  try {
86
77
  const result = await dispatch({ executorThreadId, targetThreadId, message });
78
+ if (result?.success !== true || result?.isError === true) {
79
+ const detail = typeof result?.error === "string" ? result.error : result?.error?.message;
80
+ return errorResponse("NATIVE_DISPATCH_FAILED", detail ?? "Codex Desktop did not confirm successful native dispatch");
81
+ }
87
82
  return { ok: true, v: RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
88
83
  } catch (err) {
89
84
  return errorResponse(errorCode(err), err?.message ?? String(err));
@@ -91,20 +86,19 @@ export async function handleRelayRequest(
91
86
  }
92
87
 
93
88
  /**
94
- * Listens on a private unix socket and answers one NDJSON line per request.
95
- *
96
- * The socket is mode 0600 inside the Codex home directory, which is the same
97
- * boundary the Claude peer protocol already relies on: owning the user account
98
- * is what grants access, and nothing weaker does. Named separately here because
99
- * this socket can put text into a Codex thread, so the file mode is the
100
- * security control rather than a detail of the transport.
89
+ * Listens on a private local socket or Windows named pipe and answers one NDJSON line per request.
90
+ *
91
+ * POSIX sockets are mode 0600 inside the Codex home directory. Windows uses the
92
+ * Claude-compatible local named-pipe namespace instead of a filesystem mode.
101
93
  */
102
94
  export class RelaySocketServer {
103
95
  constructor({
104
96
  socketPath,
105
97
  dispatch,
106
98
  resolveExecutor = resolveRelayThreadId,
107
- restrictSocket = (target) => fs.chmodSync(target, 0o600),
99
+ restrictSocket = (target) => {
100
+ if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
101
+ },
108
102
  log: logFn = () => {},
109
103
  } = {}) {
110
104
  this.socketPath = socketPath;
@@ -114,11 +108,13 @@ export class RelaySocketServer {
114
108
  this.log = logFn;
115
109
  this.server = null;
116
110
  this.started = false;
111
+ this.connections = new Set();
112
+ this.processHandlers = new Map();
117
113
  }
118
114
 
119
115
  async start() {
120
116
  if (this.started) return this.socketPath;
121
- fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
117
+ if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
122
118
 
123
119
  this.server = net.createServer((socket) => this.#handleConnection(socket));
124
120
  await this.#listen({ replaceStale: true });
@@ -140,12 +136,16 @@ export class RelaySocketServer {
140
136
  this.started = true;
141
137
 
142
138
  for (const signal of ["SIGINT", "SIGTERM"]) {
143
- process.on(signal, () => {
139
+ const handler = () => {
144
140
  this.stop();
145
141
  process.exit(0);
146
- });
142
+ };
143
+ this.processHandlers.set(signal, handler);
144
+ process.on(signal, handler);
147
145
  }
148
- process.on("exit", () => this.stop());
146
+ const onExit = () => this.stop();
147
+ this.processHandlers.set("exit", onExit);
148
+ process.on("exit", onExit);
149
149
 
150
150
  this.log(`relay socket listening on ${this.socketPath}`);
151
151
  return this.socketPath;
@@ -171,6 +171,13 @@ export class RelaySocketServer {
171
171
  });
172
172
  } catch (err) {
173
173
  if (err.code !== "EADDRINUSE" || !replaceStale) throw err;
174
+ if (IS_WINDOWS) {
175
+ if (await this.#socketIsLive()) {
176
+ throw new Error(`another native relay companion already owns ${this.socketPath}`);
177
+ }
178
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
179
+ return this.#listen({ replaceStale: false });
180
+ }
174
181
  if (await this.#socketIsLive()) {
175
182
  throw new Error(`another native relay companion already owns ${this.socketPath}`);
176
183
  }
@@ -195,22 +202,25 @@ export class RelaySocketServer {
195
202
  }
196
203
 
197
204
  #handleConnection(socket) {
198
- let buffer = "";
205
+ let buffer = Buffer.alloc(0);
206
+ let handled = false;
207
+ this.connections.add(socket);
208
+ socket.on("close", () => this.connections.delete(socket));
209
+ socket.setTimeout(30000, () => socket.destroy());
199
210
  socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
200
211
  socket.on("data", (chunk) => {
201
- buffer += chunk.toString("utf8");
202
- if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
212
+ if (handled) return;
213
+ buffer = Buffer.concat([buffer, chunk]);
214
+ if (buffer.length > MAX_FRAME_BYTES) {
215
+ handled = true;
203
216
  this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
204
- socket.destroy();
205
217
  return;
206
218
  }
207
- let index;
208
- while ((index = buffer.indexOf("\n")) >= 0) {
209
- const line = buffer.slice(0, index).trim();
210
- buffer = buffer.slice(index + 1);
211
- if (!line) continue;
212
- void this.#handleLine(socket, line);
213
- }
219
+ const index = buffer.indexOf(10);
220
+ if (index < 0) return;
221
+ handled = true;
222
+ void this.#handleLine(socket, buffer.subarray(0, index).toString("utf8"));
223
+ buffer = Buffer.alloc(0);
214
224
  });
215
225
  }
216
226
 
@@ -233,20 +243,76 @@ export class RelaySocketServer {
233
243
 
234
244
  #reply(socket, response) {
235
245
  if (socket.destroyed) return;
236
- socket.write(`${JSON.stringify(response)}\n`);
246
+ socket.end(`${JSON.stringify(response)}\n`);
237
247
  }
238
248
 
239
249
  stop() {
250
+ for (const [event, handler] of this.processHandlers) process.off(event, handler);
251
+ this.processHandlers.clear();
252
+ for (const socket of this.connections) socket.destroy();
253
+ this.connections.clear();
240
254
  try {
241
255
  this.server?.close();
242
256
  } catch {}
243
257
  try {
244
- if (this.started) fs.rmSync(this.socketPath, { force: true });
258
+ if (this.started && !IS_WINDOWS) fs.rmSync(this.socketPath, { force: true });
245
259
  } catch {}
246
260
  this.started = false;
247
261
  }
248
262
  }
249
263
 
264
+ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () => {}, retryDelayMs = 250, maxRetryDelayMs = 30000 }) {
265
+ let stopped = false;
266
+ let timer = null;
267
+ let delayMs = retryDelayMs;
268
+ let resolveReady;
269
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
270
+ const attempt = async () => {
271
+ if (stopped) return;
272
+ try {
273
+ await nativeTools.connect();
274
+ if (stopped) {
275
+ nativeTools.close();
276
+ return;
277
+ }
278
+ await relay.start();
279
+ if (stopped) {
280
+ relay.stop();
281
+ nativeTools.close();
282
+ return;
283
+ }
284
+ resolveReady(true);
285
+ } catch (err) {
286
+ if (stopped) return;
287
+ nativeTools.close();
288
+ if (!nativeTools.socketPath) {
289
+ logFn(`native relay unavailable (${err.message})`);
290
+ resolveReady(false);
291
+ return;
292
+ }
293
+ logFn(`native relay unavailable (${err.message}); retrying in ${delayMs}ms`);
294
+ timer = globalThis.setTimeout(() => {
295
+ timer = null;
296
+ void attempt();
297
+ }, delayMs);
298
+ timer.unref();
299
+ delayMs = Math.min(delayMs * 2, maxRetryDelayMs);
300
+ }
301
+ };
302
+ void attempt();
303
+ return {
304
+ ready,
305
+ stop() {
306
+ if (stopped) return;
307
+ stopped = true;
308
+ globalThis.clearTimeout(timer);
309
+ nativeTools.close();
310
+ relay.stop();
311
+ resolveReady(false);
312
+ },
313
+ };
314
+ }
315
+
250
316
  /**
251
317
  * `import.meta.main` is Node 24 and up, and this project supports Node 22, so
252
318
  * the entry point is detected by comparing the resolved argv path instead.
@@ -265,20 +331,8 @@ if (invokedDirectly) {
265
331
  },
266
332
  );
267
333
 
268
- /**
269
- * The dispatch goes back over the very connection Codex Desktop opened to
270
- * launch this process, which is what keeps the app the single writer. Sent as
271
- * a plain JSON-RPC request rather than through a typed helper because the
272
- * method is an internal of the app, not part of the MCP specification.
273
- */
274
- const dispatch = ({ executorThreadId, targetThreadId, message }) =>
275
- mcp.server.request(
276
- {
277
- method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
278
- params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
279
- },
280
- z.any(),
281
- );
334
+ const nativeTools = new NativeToolsClient();
335
+ const dispatch = (args) => nativeTools.dispatch(args);
282
336
 
283
337
  const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
284
338
 
@@ -313,6 +367,7 @@ if (invokedDirectly) {
313
367
  `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
314
368
  `executor: ${executor}`,
315
369
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
370
+ `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
316
371
  ].join("\n"),
317
372
  },
318
373
  ],
@@ -320,18 +375,8 @@ if (invokedDirectly) {
320
375
  },
321
376
  );
322
377
 
323
- /**
324
- * Never let the socket take the MCP server down. Codex Desktop waits on the
325
- * `initialize` handshake, so a process that dies before answering reads as a
326
- * hang rather than an error - the same failure mode `claude-bridge` already
327
- * guards its peer endpoint against.
328
- */
329
- try {
330
- await relay.start();
331
- } catch (err) {
332
- log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
333
- }
334
-
378
+ const startup = startRelayWhenAvailable({ nativeTools, relay, log });
379
+ mcp.server.onclose = () => startup.stop();
335
380
  await mcp.connect(new StdioServerTransport());
336
381
  log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
337
382
  }