@minhspark/codex-mcp-bridge 1.12.1 → 1.12.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.
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.1";
26
+ const VERSION = "1.12.3";
25
27
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
26
28
 
27
29
  /**
@@ -38,7 +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"]);
41
- const RELEASE_TURN_STATUSES = new Set([...TERMINAL_TURN_STATUSES, "disconnected"]);
43
+ const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
42
44
  const security = new BridgeSecurityPolicy();
43
45
 
44
46
  const client = new CodexAppServerClient({
@@ -115,6 +117,15 @@ async function createCodexThread({ cwd, model, name, prompt }) {
115
117
  });
116
118
  const thread = res?.thread ?? {};
117
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
+ }
118
129
  const threadName = name || prompt ? threadNameFor({ cwd: thread.cwd ?? workspace.path, prompt, name }) : null;
119
130
  if (threadName) {
120
131
  await client.call("thread/name/set", { threadId: thread.id, name: threadName });
@@ -138,26 +149,18 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
138
149
 
139
150
  if (releaseAfterTurn && releasable) {
140
151
  try {
141
- const released = await client.stopServer();
142
- if (released.stopped) {
143
- if (released.stillListening) {
144
- canOpenAfterRelease = false;
145
- notes.push(
146
- `stop requested for app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}, but it is still listening`,
147
- );
148
- notes.push("WARNING: the app-server is still listening, so the desktop thread was not opened to avoid another lock");
149
- } else {
150
- notes.push(
151
- `released app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}; Codex Desktop can write this thread`,
152
- );
153
- }
152
+ const released = await client.releaseThread(threadId);
153
+ if (released.released) {
154
+ notes.push(`released thread ${threadId}; other app-server threads remain active`);
154
155
  } else {
155
156
  canOpenAfterRelease = false;
156
- 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}`);
157
160
  }
158
161
  } catch (err) {
159
162
  canOpenAfterRelease = false;
160
- notes.push(`could not release app-server: ${err.message}`);
163
+ notes.push(`could not release thread: ${err.message}`);
161
164
  }
162
165
  }
163
166
 
@@ -267,7 +270,7 @@ server.registerTool(
267
270
  releaseAfterTurn: z
268
271
  .boolean()
269
272
  .optional()
270
- .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"),
271
274
  },
272
275
  annotations: {
273
276
  readOnlyHint: false,
@@ -358,7 +361,7 @@ server.registerTool(
358
361
  releaseAfterTurn: z
359
362
  .boolean()
360
363
  .optional()
361
- .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"),
362
365
  },
363
366
  annotations: {
364
367
  readOnlyHint: false,
@@ -368,64 +371,66 @@ server.registerTool(
368
371
  },
369
372
  },
370
373
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
371
- const notes = [];
372
- const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
373
- const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
374
- try {
375
- const authorizedThread = await assertThreadAccess(threadId);
376
- let resolvedCwd = null;
377
- if (cwd) {
378
- const workspace = resolveWorkspacePath(cwd);
379
- security.assertCwd(workspace.path);
380
- resolvedCwd = workspace.path;
381
- if (workspace.note) notes.push(workspace.note);
382
- } else if (authorizedThread?.cwd) {
383
- const workspace = resolveWorkspacePath(authorizedThread.cwd);
384
- resolvedCwd = workspace.path;
385
- if (workspace.note) notes.push(workspace.note);
386
- }
387
- const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
388
- const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
389
- security.assertCwd(attachedThread?.cwd);
390
- if (name) {
391
- await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
392
- notes.push(`session name: ${name.trim().slice(0, 200)}`);
393
- }
394
- /**
395
- * Opening the thread in the app comes after both gates. It ran first
396
- * once, which meant a thread this bridge was about to refuse still got
397
- * raised on screen - a refusal that leaked which threads exist.
398
- */
399
- if (shouldOpen && !shouldRelease) {
400
- try {
401
- notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
402
- } catch (err) {
403
- 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)}`);
404
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);
405
432
  }
406
- const result = await runTurn(client, {
407
- threadId,
408
- input: [{ type: "text", text: prompt }],
409
- timeoutMs: (timeoutSec ?? 240) * 1000,
410
- turnOverrides: {
411
- ...(resolvedCwd ? { cwd: resolvedCwd } : {}),
412
- ...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
413
- ...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
414
- },
415
- });
416
- const body = formatTurn(result);
417
- const failed = result.status === "failed" || result.status === "disconnected";
418
- notes.push(...(await finishDesktopHandoff({
419
- threadId,
420
- result,
421
- openInApp: shouldOpen,
422
- releaseAfterTurn: shouldRelease,
423
- })));
424
- const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
425
- return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
426
- } catch (err) {
427
- return failure(err);
428
- }
433
+ });
429
434
  },
430
435
  );
431
436
 
@@ -459,16 +464,40 @@ server.registerTool(
459
464
  }
460
465
  if (searchTerm) params.searchTerm = searchTerm;
461
466
  const method = loadedOnly ? "thread/loaded/list" : "thread/list";
462
- const res = await client.call(method, loadedOnly ? { limit: limit ?? 15 } : params);
463
- const rows = security.filterThreads(
464
- (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) => {
465
487
  try {
466
- 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];
467
492
  } catch {
468
493
  return [];
469
494
  }
470
- }),
471
- );
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);
472
501
  if (!rows.length) {
473
502
  return textResult(
474
503
  security.summary().allowedRoots.length
@@ -648,6 +677,9 @@ server.registerTool(
648
677
  async () => {
649
678
  try {
650
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
+ }
651
683
  return textResult(
652
684
  result.stopped
653
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
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.1";
20
+ const VERSION = "1.12.3";
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));
@@ -113,6 +108,8 @@ export class RelaySocketServer {
113
108
  this.log = logFn;
114
109
  this.server = null;
115
110
  this.started = false;
111
+ this.connections = new Set();
112
+ this.processHandlers = new Map();
116
113
  }
117
114
 
118
115
  async start() {
@@ -139,12 +136,16 @@ export class RelaySocketServer {
139
136
  this.started = true;
140
137
 
141
138
  for (const signal of ["SIGINT", "SIGTERM"]) {
142
- process.on(signal, () => {
139
+ const handler = () => {
143
140
  this.stop();
144
141
  process.exit(0);
145
- });
142
+ };
143
+ this.processHandlers.set(signal, handler);
144
+ process.on(signal, handler);
146
145
  }
147
- process.on("exit", () => this.stop());
146
+ const onExit = () => this.stop();
147
+ this.processHandlers.set("exit", onExit);
148
+ process.on("exit", onExit);
148
149
 
149
150
  this.log(`relay socket listening on ${this.socketPath}`);
150
151
  return this.socketPath;
@@ -200,23 +201,30 @@ export class RelaySocketServer {
200
201
  });
201
202
  }
202
203
 
204
+ async isListening() {
205
+ return this.started || this.#socketIsLive();
206
+ }
207
+
203
208
  #handleConnection(socket) {
204
- let buffer = "";
209
+ let buffer = Buffer.alloc(0);
210
+ let handled = false;
211
+ this.connections.add(socket);
212
+ socket.on("close", () => this.connections.delete(socket));
213
+ socket.setTimeout(30000, () => socket.destroy());
205
214
  socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
206
215
  socket.on("data", (chunk) => {
207
- buffer += chunk.toString("utf8");
208
- if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
216
+ if (handled) return;
217
+ buffer = Buffer.concat([buffer, chunk]);
218
+ if (buffer.length > MAX_FRAME_BYTES) {
219
+ handled = true;
209
220
  this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
210
- socket.destroy();
211
221
  return;
212
222
  }
213
- let index;
214
- while ((index = buffer.indexOf("\n")) >= 0) {
215
- const line = buffer.slice(0, index).trim();
216
- buffer = buffer.slice(index + 1);
217
- if (!line) continue;
218
- void this.#handleLine(socket, line);
219
- }
223
+ const index = buffer.indexOf(10);
224
+ if (index < 0) return;
225
+ handled = true;
226
+ void this.#handleLine(socket, buffer.subarray(0, index).toString("utf8"));
227
+ buffer = Buffer.alloc(0);
220
228
  });
221
229
  }
222
230
 
@@ -239,10 +247,14 @@ export class RelaySocketServer {
239
247
 
240
248
  #reply(socket, response) {
241
249
  if (socket.destroyed) return;
242
- socket.write(`${JSON.stringify(response)}\n`);
250
+ socket.end(`${JSON.stringify(response)}\n`);
243
251
  }
244
252
 
245
253
  stop() {
254
+ for (const [event, handler] of this.processHandlers) process.off(event, handler);
255
+ this.processHandlers.clear();
256
+ for (const socket of this.connections) socket.destroy();
257
+ this.connections.clear();
246
258
  try {
247
259
  this.server?.close();
248
260
  } catch {}
@@ -253,6 +265,58 @@ export class RelaySocketServer {
253
265
  }
254
266
  }
255
267
 
268
+ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () => {}, retryDelayMs = 250, maxRetryDelayMs = 30000 }) {
269
+ let stopped = false;
270
+ let timer = null;
271
+ let delayMs = retryDelayMs;
272
+ let resolveReady;
273
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
274
+ const attempt = async () => {
275
+ if (stopped) return;
276
+ try {
277
+ await nativeTools.connect();
278
+ if (stopped) {
279
+ nativeTools.close();
280
+ return;
281
+ }
282
+ await relay.start();
283
+ if (stopped) {
284
+ relay.stop();
285
+ nativeTools.close();
286
+ return;
287
+ }
288
+ resolveReady(true);
289
+ } catch (err) {
290
+ if (stopped) return;
291
+ nativeTools.close();
292
+ if (!nativeTools.socketPath) {
293
+ logFn(`native relay unavailable (${err.message})`);
294
+ resolveReady(false);
295
+ return;
296
+ }
297
+ logFn(`native relay unavailable (${err.message}); retrying in ${delayMs}ms`);
298
+ timer = globalThis.setTimeout(() => {
299
+ timer = null;
300
+ void attempt();
301
+ }, delayMs);
302
+ timer.unref();
303
+ delayMs = Math.min(delayMs * 2, maxRetryDelayMs);
304
+ }
305
+ };
306
+ void attempt();
307
+ return {
308
+ ready,
309
+ stop() {
310
+ if (stopped) return;
311
+ stopped = true;
312
+ globalThis.clearTimeout(timer);
313
+ nativeTools.close();
314
+ relay.stop();
315
+ resolveReady(false);
316
+ },
317
+ };
318
+ }
319
+
256
320
  /**
257
321
  * `import.meta.main` is Node 24 and up, and this project supports Node 22, so
258
322
  * the entry point is detected by comparing the resolved argv path instead.
@@ -271,20 +335,8 @@ if (invokedDirectly) {
271
335
  },
272
336
  );
273
337
 
274
- /**
275
- * The dispatch goes back over the very connection Codex Desktop opened to
276
- * launch this process, which is what keeps the app the single writer. Sent as
277
- * a plain JSON-RPC request rather than through a typed helper because the
278
- * method is an internal of the app, not part of the MCP specification.
279
- */
280
- const dispatch = ({ executorThreadId, targetThreadId, message }) =>
281
- mcp.server.request(
282
- {
283
- method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
284
- params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
285
- },
286
- z.any(),
287
- );
338
+ const nativeTools = new NativeToolsClient();
339
+ const dispatch = (args) => nativeTools.dispatch(args);
288
340
 
289
341
  const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
290
342
 
@@ -302,6 +354,7 @@ if (invokedDirectly) {
302
354
  },
303
355
  },
304
356
  async () => {
357
+ const listening = await relay.isListening();
305
358
  let executor = "(unconfigured)";
306
359
  try {
307
360
  const resolved = resolveRelayThreadId();
@@ -316,9 +369,10 @@ if (invokedDirectly) {
316
369
  text: [
317
370
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
318
371
  `companion: codex-native-relay ${VERSION}`,
319
- `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
372
+ `relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
320
373
  `executor: ${executor}`,
321
374
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
375
+ `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
322
376
  ].join("\n"),
323
377
  },
324
378
  ],
@@ -326,18 +380,8 @@ if (invokedDirectly) {
326
380
  },
327
381
  );
328
382
 
329
- /**
330
- * Never let the socket take the MCP server down. Codex Desktop waits on the
331
- * `initialize` handshake, so a process that dies before answering reads as a
332
- * hang rather than an error - the same failure mode `claude-bridge` already
333
- * guards its peer endpoint against.
334
- */
335
- try {
336
- await relay.start();
337
- } catch (err) {
338
- log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
339
- }
340
-
383
+ const startup = startRelayWhenAvailable({ nativeTools, relay, log });
384
+ mcp.server.onclose = () => startup.stop();
341
385
  await mcp.connect(new StdioServerTransport());
342
386
  log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
343
387
  }