@minhspark/codex-mcp-bridge 1.13.3 → 1.13.5

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.
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const METADATA_KEY = "x-codex-turn-metadata";
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7
+ const LIFECYCLE = new Set(["task_started", "task_complete", "task_completed", "turn_started", "turn_complete", "turn_completed", "turn_aborted", "task_aborted"]);
8
+ const STARTED = new Set(["task_started", "turn_started"]);
9
+ const MAX_ROLLOUT_BYTES = 64 * 1024 * 1024;
10
+ const MAX_ENTRIES = 100000;
11
+ const MAX_DIRECTORIES = 4096;
12
+
13
+ function object(value) {
14
+ return value !== null && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+
17
+ function exactObject(value, keys) {
18
+ return object(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
19
+ }
20
+
21
+ function unavailable(reason, identity = {}) {
22
+ return { status: "unavailable", threadId: null, turnId: null, mode: null, cwd: null, source: null, ...identity, reason };
23
+ }
24
+
25
+ function reviewFlag(metadata, field) {
26
+ if (!Object.hasOwn(metadata, field) || metadata[field] === undefined) return "missing";
27
+ if (metadata[field] === true) return "enabled";
28
+ if (metadata[field] === false) return "disabled";
29
+ return "invalid";
30
+ }
31
+
32
+ function findRollout(sessions, threadId) {
33
+ if (!fs.lstatSync(sessions).isDirectory()) throw new Error("The Codex sessions path is not a regular directory");
34
+ const queue = [{ directory: sessions, depth: 0 }];
35
+ const matches = [];
36
+ let entries = 0;
37
+ let directories = 0;
38
+ while (queue.length) {
39
+ const { directory, depth } = queue.pop();
40
+ if (++directories > MAX_DIRECTORIES) throw new Error("The bounded Codex sessions scan exceeded its directory limit");
41
+ const children = fs.readdirSync(directory, { withFileTypes: true });
42
+ entries += children.length;
43
+ if (entries > MAX_ENTRIES) throw new Error("The bounded Codex sessions scan exceeded its entry limit");
44
+ for (const child of children) {
45
+ const candidate = path.join(directory, child.name);
46
+ if (depth < 3 && (depth === 0 ? /^\d{4}$/ : /^\d{2}$/).test(child.name)) {
47
+ if (child.isSymbolicLink()) throw new Error("The Codex sessions scan encountered a linked date directory");
48
+ if (child.isDirectory()) queue.push({ directory: candidate, depth: depth + 1 });
49
+ }
50
+ if (depth === 3 && child.name.startsWith("rollout-") && child.name.endsWith(`-${threadId}.jsonl`)) {
51
+ if (!child.isFile() || child.isSymbolicLink()) throw new Error("The sender rollout is not a regular file");
52
+ matches.push(candidate);
53
+ }
54
+ }
55
+ }
56
+ if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollouts match the calling Codex task" : "No rollout matches the calling Codex task");
57
+ return matches[0];
58
+ }
59
+
60
+ function readState(file, maxBytes) {
61
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
62
+ try {
63
+ const before = fs.fstatSync(descriptor);
64
+ if (!before.isFile() || before.size === 0 || before.size > maxBytes) throw new Error("The sender rollout is empty or exceeds the bounded read limit");
65
+ const data = Buffer.alloc(before.size);
66
+ let offset = 0;
67
+ while (offset < data.length) {
68
+ const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
69
+ if (!count) throw new Error("The sender rollout changed while reading");
70
+ offset += count;
71
+ }
72
+ const after = fs.fstatSync(descriptor);
73
+ const current = fs.lstatSync(file);
74
+ if (!current.isFile() || current.isSymbolicLink() || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== current.ino || before.dev !== current.dev) throw new Error("The sender rollout changed while reading");
75
+ const text = data.toString("utf8");
76
+ if (!text.endsWith("\n")) throw new Error("The sender rollout has an incomplete final record");
77
+ let session;
78
+ let context;
79
+ let lifecycle;
80
+ for (const line of text.split("\n")) {
81
+ if (!line) continue;
82
+ const record = JSON.parse(line);
83
+ if (!object(record) || !object(record.payload)) throw new Error("The sender rollout contains an invalid record");
84
+ if (record.type === "session_meta") {
85
+ if (session) throw new Error("The sender rollout repeats its session identity");
86
+ session = record.payload;
87
+ } else if (record.type === "turn_context") {
88
+ context = record.payload;
89
+ } else if (record.type === "event_msg" && LIFECYCLE.has(record.payload.type)) {
90
+ lifecycle = record.payload;
91
+ }
92
+ }
93
+ return { session, context, lifecycle };
94
+ } finally {
95
+ fs.closeSync(descriptor);
96
+ }
97
+ }
98
+
99
+ function permissionClass(context, metadata) {
100
+ for (const field of ["auto_review_enabled", "node_repl_auto_review_required"]) {
101
+ if (!Object.hasOwn(metadata, field) || typeof metadata[field] !== "boolean") throw new Error(`The caller's ${field} review evidence is ${reviewFlag(metadata, field)}; the host must supply an explicit boolean`);
102
+ }
103
+ if (context.approvals_reviewer !== "user") throw new Error("The caller's effective approval reviewer is unverified");
104
+ if (!exactObject(context.permission_profile, ["type"]) || context.permission_profile.type !== "disabled") throw new Error("The caller's permission profile is restricted or unsupported; no permission class was inferred");
105
+ if (!exactObject(context.sandbox_policy, ["type"]) || context.sandbox_policy.type !== "danger-full-access") throw new Error("The caller's effective sandbox policy does not match its disabled permission profile");
106
+ if (!["never", "on-request", "on-failure", "untrusted"].includes(context.approval_policy)) throw new Error("The caller's effective approval policy is unsupported");
107
+ return context.approval_policy === "never" && !metadata.auto_review_enabled && !metadata.node_repl_auto_review_required ? "bypass" : "prompting";
108
+ }
109
+
110
+ export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
111
+ const metadata = object(meta) ? meta[METADATA_KEY] : undefined;
112
+ if (!object(metadata) || typeof metadata.thread_id !== "string" || typeof metadata.turn_id !== "string" || !UUID.test(metadata.thread_id) || !UUID.test(metadata.turn_id)) return unavailable("This MCP call has no valid host-supplied Codex task and turn identity");
113
+ const identity = { threadId: metadata.thread_id, turnId: metadata.turn_id,
114
+ review: { autoReview: reviewFlag(metadata, "auto_review_enabled"), nodeReplReview: reviewFlag(metadata, "node_repl_auto_review_required") } };
115
+ try {
116
+ if (!Number.isSafeInteger(maxRolloutBytes) || maxRolloutBytes < 1) throw new Error("The sender rollout read limit is invalid");
117
+ if (metadata.thread_source !== "user") throw new Error("This MCP call is not from a user-owned Codex task");
118
+ const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
119
+ if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
120
+ const sessions = path.join(configuredHome, "sessions");
121
+ const file = findRollout(sessions, identity.threadId);
122
+ const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
123
+ const { session, context, lifecycle } = state;
124
+ if (session?.id !== identity.threadId || session.originator !== "Codex Desktop" || session.source !== "vscode") throw new Error("The caller rollout does not confirm a root Codex Desktop task");
125
+ if (context?.turn_id !== identity.turnId || lifecycle?.turn_id !== identity.turnId || !STARTED.has(lifecycle?.type)) throw new Error("The calling turn is no longer the latest active Codex turn");
126
+ if (typeof context.cwd !== "string" || !path.isAbsolute(context.cwd) || typeof session.cwd !== "string" || !path.isAbsolute(session.cwd)) throw new Error("The caller's workspace is missing or invalid");
127
+ const cwd = fs.realpathSync.native(context.cwd);
128
+ if (!fs.statSync(cwd).isDirectory() || path.relative(fs.realpathSync.native(session.cwd), cwd)) throw new Error("The caller's workspace changed from its Desktop session identity");
129
+ const mode = permissionClass(context, metadata);
130
+ return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy: context.approval_policy, reason: "Host-supplied calling task and active turn match the Desktop rollout's effective permission settings" };
131
+ } catch (error) {
132
+ return unavailable(error?.code ? `Caller evidence could not be read (${error.code}); no sender permission class was inferred` : error.message, identity);
133
+ }
134
+ }
package/src/index.mjs CHANGED
@@ -25,10 +25,11 @@ import { BridgeSecurityPolicy } from "./security-policy.mjs";
25
25
  import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.mjs";
26
26
  import { desktopTasksConfigured } from "./native-relay.mjs";
27
27
  import { exitForVersionRequest } from "./cli-version.mjs";
28
+ import { createRuntimeState } from "./runtime-state.mjs";
28
29
 
29
30
  exitForVersionRequest(import.meta.url);
30
31
 
31
- const VERSION = "1.13.3";
32
+ const VERSION = "1.13.5";
32
33
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
33
34
 
34
35
  /**
@@ -48,6 +49,7 @@ const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
48
49
  const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
49
50
  const security = new BridgeSecurityPolicy();
50
51
  const desktopTasksEnabled = desktopTasksConfigured();
52
+ const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
51
53
  const desktopTasks = new DesktopTaskDelivery({ security });
52
54
 
53
55
  const client = desktopTasksEnabled ? null : new CodexAppServerClient({
@@ -279,7 +281,8 @@ const server = new McpServer(
279
281
  { name: "codex-bridge", version: VERSION },
280
282
  {
281
283
  instructions:
282
- "Bridge Claude work into Codex. Prefer delegate_to_codex: it creates a named Codex thread at the " +
284
+ "Bridge Claude work into Codex. Prefer an existing task: inspect its exact threadId and project, then use send_to_codex_thread. " +
285
+ "Only use delegate_to_codex or start_codex_thread when the user explicitly requests a new task at the " +
283
286
  "requested cwd. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
284
287
  "Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
285
288
  "Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
@@ -287,7 +290,26 @@ const server = new McpServer(
287
290
  },
288
291
  );
289
292
 
290
- server.registerTool(
293
+ function registerTool(name, definition, handler) {
294
+ server.registerTool(name, definition, async (...args) => {
295
+ try {
296
+ if (!definition.annotations?.readOnlyHint) runtime.assertCurrent();
297
+ if (name === "codex_bridge_status") {
298
+ const state = runtime.status();
299
+ if (!state.current) return { ...failure(new Error(`${state.reason}; reconnect this MCP server in the existing task.`)), structuredContent: { runtime: state } };
300
+ const result = await handler(...args);
301
+ result.content.push({ type: "text", text: `runtime pid: ${state.pid}\nloaded source: ${state.revision}\nruntime state: current` });
302
+ result.structuredContent = { ...result.structuredContent, runtime: state };
303
+ return result;
304
+ }
305
+ return await handler(...args);
306
+ } catch (err) {
307
+ return failure(err);
308
+ }
309
+ });
310
+ }
311
+
312
+ registerTool(
291
313
  "delegate_to_codex",
292
314
  {
293
315
  title: "Delegate work to a new Codex session",
@@ -378,7 +400,7 @@ server.registerTool(
378
400
  },
379
401
  );
380
402
 
381
- server.registerTool(
403
+ registerTool(
382
404
  "send_to_codex_thread",
383
405
  {
384
406
  title: "Send a prompt to a Codex thread",
@@ -503,7 +525,7 @@ server.registerTool(
503
525
  },
504
526
  );
505
527
 
506
- server.registerTool(
528
+ registerTool(
507
529
  "list_codex_threads",
508
530
  {
509
531
  title: "List Codex threads",
@@ -589,7 +611,7 @@ server.registerTool(
589
611
  },
590
612
  );
591
613
 
592
- server.registerTool(
614
+ registerTool(
593
615
  "start_codex_thread",
594
616
  {
595
617
  title: "Start a new Codex thread",
@@ -631,7 +653,7 @@ server.registerTool(
631
653
  },
632
654
  );
633
655
 
634
- server.registerTool(
656
+ registerTool(
635
657
  "read_codex_thread",
636
658
  {
637
659
  title: "Read a Codex thread",
@@ -678,7 +700,7 @@ server.registerTool(
678
700
  },
679
701
  );
680
702
 
681
- server.registerTool(
703
+ registerTool(
682
704
  "interrupt_codex_turn",
683
705
  {
684
706
  title: "Interrupt a Codex turn",
@@ -712,7 +734,7 @@ server.registerTool(
712
734
  },
713
735
  );
714
736
 
715
- server.registerTool(
737
+ registerTool(
716
738
  "open_codex_thread",
717
739
  {
718
740
  title: "Open a Codex thread in the desktop app",
@@ -755,7 +777,7 @@ server.registerTool(
755
777
  },
756
778
  );
757
779
 
758
- server.registerTool(
780
+ registerTool(
759
781
  "stop_codex_app_server",
760
782
  {
761
783
  title: "Stop the shared Codex app-server",
@@ -789,7 +811,7 @@ server.registerTool(
789
811
  },
790
812
  );
791
813
 
792
- server.registerTool(
814
+ registerTool(
793
815
  "codex_bridge_status",
794
816
  {
795
817
  title: "Check the Codex bridge environment",
@@ -23,7 +23,7 @@ import { exitForVersionRequest } from "./cli-version.mjs";
23
23
 
24
24
  exitForVersionRequest(import.meta.url);
25
25
 
26
- const VERSION = "1.13.3";
26
+ const VERSION = "1.13.5";
27
27
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
28
28
 
29
29
  function errorResponse(code, message) {
@@ -117,7 +117,7 @@ function readProcessStart(pid) {
117
117
  try {
118
118
  if (IS_WINDOWS) {
119
119
  const shell = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
120
- return execFileSync(shell, ["-NoProfile", "-Command", `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: 3000, windowsHide: true }).toString().trim();
120
+ return execFileSync(shell, ["-NoProfile", "-NonInteractive", "-Command", `[System.Diagnostics.Process]::GetProcessById(${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: 3000, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
121
121
  }
122
122
  return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 3000 }).toString().trim();
123
123
  } catch {
@@ -176,10 +176,12 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
176
176
  pid: entry.pid,
177
177
  name: entry.name ?? null,
178
178
  sessionId: entry.sessionId ?? null,
179
+ bridgeSessionId: entry.bridgeSessionId ?? null,
179
180
  cwd: entry.cwd ?? null,
180
181
  kind: entry.kind ?? null,
181
182
  entrypoint: entry.entrypoint ?? null,
182
183
  startedAt: entry.startedAt ?? null,
184
+ processStart: (IS_WINDOWS ? entry.procStartFt : entry.procStart) ?? null,
183
185
  socket: entry.messagingSocketPath,
184
186
  alive,
185
187
  });
@@ -187,9 +189,21 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
187
189
  return rows.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
188
190
  }
189
191
 
190
- export function findClaudeSession(target) {
192
+ export function findClaudeSession(target, { desktopOnly = false, expectedCwd } = {}) {
191
193
  const sessions = listClaudeSessions();
192
194
  const needle = String(target).trim();
195
+ if (desktopOnly) {
196
+ const byId = sessions.filter((session) => String(session.pid) === needle || session.sessionId === needle);
197
+ const matches = byId.length ? byId : sessions.filter((session) => session.name === needle);
198
+ if (!needle || matches.length === 0) return null;
199
+ if (matches.length !== 1) throw new Error(`Desktop-only mode requires an unambiguous sessionId or pid; "${needle}" matches multiple sessions. No message was sent.`);
200
+ const session = matches[0];
201
+ if (session.entrypoint !== "claude-desktop") {
202
+ 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.`);
203
+ }
204
+ if (expectedCwd !== undefined) assertClaudeSessionCwd(session, expectedCwd);
205
+ return session;
206
+ }
193
207
  return (
194
208
  sessions.find((s) => String(s.pid) === needle) ??
195
209
  sessions.find((s) => s.sessionId === needle) ??
@@ -199,6 +213,31 @@ export function findClaudeSession(target) {
199
213
  );
200
214
  }
201
215
 
216
+ export function assertClaudeSessionCwd(session, expectedCwd) {
217
+ if (typeof expectedCwd !== "string" || !path.isAbsolute(expectedCwd) || !session.cwd || !path.isAbsolute(session.cwd)) {
218
+ throw new Error("Desktop delivery requires an explicit absolute expectedCwd and an absolute session cwd. No message was sent.");
219
+ }
220
+ let expected;
221
+ let actual;
222
+ try {
223
+ expected = fs.realpathSync.native(expectedCwd);
224
+ actual = fs.realpathSync.native(session.cwd);
225
+ } catch {
226
+ throw new Error("The intended project directory or the Claude session cwd no longer exists. No message was sent.");
227
+ }
228
+ const normalize = (value) => IS_WINDOWS ? value.toLowerCase() : value;
229
+ if (normalize(expected) !== normalize(actual)) {
230
+ throw new Error(`Claude session cwd ${actual} does not match expectedCwd ${expected}. No message was sent.`);
231
+ }
232
+ }
233
+
234
+ export function assertClaudeSessionProcess(session) {
235
+ if (!session.alive || typeof session.processStart !== "string" || !session.processStart ||
236
+ readProcessStart(session.pid) !== session.processStart) {
237
+ throw new Error("The live Claude process identity is missing or changed. No message was sent; reopen the existing Desktop task and inspect its session again.");
238
+ }
239
+ }
240
+
202
241
  /**
203
242
  * Claude Code stores a transcript at ~/.claude/projects/<slug>/<sessionId>.jsonl
204
243
  * where the slug rewrites more than just path separators (/mnt/dev_disk ->
@@ -292,6 +331,7 @@ export class PeerEndpoint {
292
331
  this.peerToken = null;
293
332
  this.permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
294
333
  this.deliveryReceipts = new Map();
334
+ this.sentMessages = new Map();
295
335
  this.pendingMessages = new Map();
296
336
  this.responsePoll = null;
297
337
  }
@@ -432,7 +472,18 @@ export class PeerEndpoint {
432
472
  #receiveMessage(message) {
433
473
  const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
434
474
  this.inbox.push(record);
435
- this.#removePendingReply(record.fromSocket, record.inReplyTo);
475
+ const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
476
+ const pending = this.pendingMessages.get(key);
477
+ const correlated = pending && (!pending.transcriptSession || (record.source === "transcript" && record.inReplyTo === key));
478
+ if (key && correlated && pending.targetSocket === record.fromSocket) {
479
+ const sent = this.sentMessages.get(key);
480
+ if (sent) {
481
+ record.inReplyTo = key;
482
+ record.replyThreadId = sent.replyThreadId ?? null;
483
+ sent.reply = record;
484
+ }
485
+ this.#removePendingReply(record.fromSocket, key);
486
+ }
436
487
  this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
437
488
  for (const listener of [...this.listeners]) {
438
489
  try { listener(record); }
@@ -480,12 +531,13 @@ export class PeerEndpoint {
480
531
  return () => this.listeners.delete(listener);
481
532
  }
482
533
 
483
- async send(targetSocket, text, { priority = "next", msgId } = {}) {
484
- const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode: this.permissionMode });
534
+ async send(targetSocket, text, { priority = "next", msgId, permissionMode = this.permissionMode, beforeSend } = {}) {
535
+ const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode });
485
536
  if (msgId) frame.uuid = frame.msg_id = msgId;
486
537
  const token = readPeerToken(targetSocket);
487
538
  const line = (token ? JSON.stringify({ type: "auth", token }) + "\n" : "") + JSON.stringify(frame) + "\n";
488
539
  for (let attempt = 1; attempt <= PEER_SEND_ATTEMPTS; attempt += 1) {
540
+ await beforeSend?.();
489
541
  try {
490
542
  await new Promise((resolve, reject) => {
491
543
  const client = net.connect({ path: targetSocket });
@@ -503,22 +555,30 @@ export class PeerEndpoint {
503
555
  settled = true;
504
556
  globalThis.clearTimeout(timer);
505
557
  if (error) {
558
+ if (writeStarted) error.deliveryUncertain = true;
506
559
  reject({ error, retryable: !connected && !writeStarted });
507
560
  } else {
508
561
  resolve();
509
562
  }
510
563
  };
511
- client.once("connect", () => {
564
+ client.once("connect", async () => {
512
565
  connected = true;
513
- writeStarted = true;
514
- client.write(line, (error) => {
515
- if (error) {
516
- finish(error);
517
- return;
518
- }
519
- client.end();
520
- finish();
521
- });
566
+ try {
567
+ await beforeSend?.();
568
+ if (settled) return;
569
+ writeStarted = true;
570
+ client.write(line, (error) => {
571
+ if (error) {
572
+ finish(error);
573
+ return;
574
+ }
575
+ client.end();
576
+ finish();
577
+ });
578
+ } catch (error) {
579
+ finish(error);
580
+ client.destroy();
581
+ }
522
582
  });
523
583
  client.once("error", finish);
524
584
  });
@@ -534,14 +594,15 @@ export class PeerEndpoint {
534
594
  return frame.msg_id;
535
595
  }
536
596
 
537
- async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession } = {}) {
597
+ async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview } = {}) {
538
598
  const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
539
599
  const pending = previous.catch(() => {}).then(async () => {
600
+ await beforeSend?.();
540
601
  const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
541
- if (timeoutMs > 0 && unconfirmed > 0) {
602
+ if (unconfirmed > 0) {
542
603
  const error = new Error(
543
604
  `${unconfirmed} earlier message(s) to ${targetSocket} still await a reply; this message was not sent. `
544
- + "Wait for Claude's outstanding replies and check read_claude_inbox, or set waitSec to 0 to send without matching a reply.",
605
+ + "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.",
545
606
  );
546
607
  error.code = "PEER_REPLY_PENDING";
547
608
  throw error;
@@ -551,22 +612,33 @@ export class PeerEndpoint {
551
612
  this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
552
613
  let msgId = crypto.randomUUID();
553
614
  this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
615
+ this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, ...(senderReview ? { senderReview: { ...senderReview } } : {}) });
616
+ if (transcriptSession && !this.responsePoll) {
617
+ this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
618
+ this.responsePoll.unref();
619
+ }
554
620
  try {
555
- const sentId = await this.send(targetSocket, text, { priority, msgId });
621
+ const sentId = await this.send(targetSocket, text, { priority, msgId, permissionMode, beforeSend });
556
622
  if (sentId !== msgId) {
557
623
  const pendingMessage = this.pendingMessages.get(msgId);
624
+ const earlyReply = this.sentMessages.get(msgId)?.reply;
625
+ if (earlyReply?.inReplyTo === msgId) earlyReply.inReplyTo = sentId;
558
626
  this.pendingMessages.delete(msgId);
627
+ this.sentMessages.set(sentId, this.sentMessages.get(msgId));
628
+ this.sentMessages.delete(msgId);
559
629
  msgId = sentId;
560
630
  if (pendingMessage) this.pendingMessages.set(msgId, pendingMessage);
561
631
  }
562
632
  } catch (err) {
563
- this.#removePendingReply(targetSocket, msgId);
633
+ const sent = this.sentMessages.get(msgId);
634
+ if (sent) sent.error = err.message;
635
+ if (!err.deliveryUncertain) {
636
+ this.#removePendingReply(targetSocket, msgId);
637
+ if (sent) sent.failed = true;
638
+ }
639
+ err.msgId = msgId;
564
640
  throw err;
565
641
  }
566
- if (transcriptSession && !this.responsePoll && this.pendingMessages.has(msgId)) {
567
- this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
568
- this.responsePoll.unref();
569
- }
570
642
  const reply = timeoutMs > 0
571
643
  ? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence, msgId })
572
644
  : null;
@@ -584,7 +656,8 @@ export class PeerEndpoint {
584
656
 
585
657
  #removePendingReply(fromSocket, msgId) {
586
658
  const key = msgId ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === fromSocket)?.[0];
587
- if (key) this.pendingMessages.delete(key);
659
+ if (!key || this.pendingMessages.get(key)?.targetSocket !== fromSocket) return;
660
+ this.pendingMessages.delete(key);
588
661
  const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
589
662
  if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
590
663
  else this.unconfirmedReplies.delete(fromSocket);
@@ -595,7 +668,10 @@ export class PeerEndpoint {
595
668
  * reply is matched by origin socket and arrival time.
596
669
  */
597
670
  waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
671
+ const expectsTranscript = Boolean(msgId && this.sentMessages.get(msgId)?.transcriptSession);
598
672
  const matches = (record) => record.fromSocket === fromSocket
673
+ && (!expectsTranscript || (record.source === "transcript" && record.inReplyTo === msgId))
674
+ && (!record.inReplyTo || !msgId || record.inReplyTo === msgId)
599
675
  && (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
600
676
  const existing = this.inbox.find(matches);
601
677
  if (existing) return Promise.resolve(existing);
@@ -619,9 +695,31 @@ export class PeerEndpoint {
619
695
  }
620
696
 
621
697
  drainInbox(limit = 20) {
622
- const messages = this.inbox.slice(-limit);
623
- this.inbox = [];
624
- return messages;
698
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("Inbox limit must be a positive integer");
699
+ return this.inbox.splice(0, limit);
700
+ }
701
+
702
+ readDelivery(msgId) {
703
+ const sent = this.sentMessages.get(msgId);
704
+ if (!sent) return null;
705
+ const delivery = this.deliveryReceipts.get(msgId);
706
+ const session = sent.transcriptSession;
707
+ return {
708
+ msgId,
709
+ status: sent.reply ? "reply_received" : sent.failed ? "send_failed" : delivery?.status ?? "sent_unconfirmed",
710
+ reason: delivery?.reason ?? sent.error ?? null,
711
+ sentAt: sent.sentAt,
712
+ sessionId: session?.sessionId ?? null,
713
+ cwd: session?.cwd ?? null,
714
+ entrypoint: session?.entrypoint ?? null,
715
+ taskId: session?.desktop?.taskId ?? null,
716
+ title: session?.desktop?.title ?? null,
717
+ senderMode: sent.permissionMode ?? null,
718
+ ...(sent.senderReview ? { senderReview: { ...sent.senderReview } } : {}),
719
+ replyThreadId: sent.replyThreadId ?? null,
720
+ pending: this.pendingMessages.has(msgId),
721
+ ...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
722
+ };
625
723
  }
626
724
 
627
725
  stop() {
package/src/platform.mjs CHANGED
@@ -20,7 +20,8 @@ export const PLATFORM_LABEL = IS_MACOS
20
20
  : process.platform;
21
21
 
22
22
  const CODEX_DESKTOP_APP_MACOS = "/Applications/ChatGPT.app";
23
- const CODEX_DESKTOP_BIN_MACOS = `${CODEX_DESKTOP_APP_MACOS}/Contents/Resources/codex`;
23
+ const CODEX_DESKTOP_RESOURCES_MACOS = `${CODEX_DESKTOP_APP_MACOS}/Contents/Resources`;
24
+ const CODEX_DESKTOP_BIN_MACOS = `${CODEX_DESKTOP_RESOURCES_MACOS}/codex`;
24
25
  const CODEX_THREAD_URL_PREFIX = "codex://threads/";
25
26
 
26
27
  /**
@@ -176,6 +177,59 @@ export function resolveCodexBin(explicit) {
176
177
  return "codex";
177
178
  }
178
179
 
180
+ /**
181
+ * Codex Desktop authenticates the code-signing identity of whatever process
182
+ * connects to its native tools pipe, and closes the connection before reading
183
+ * a single byte when that identity is not the vendor's - the app records
184
+ * `dynamic_app_tools_peer_rejected`. A companion launched by the user's own
185
+ * Node build carries the Node.js Foundation signature rather than OpenAI's,
186
+ * so the relay still reports itself installed and still creates its socket
187
+ * while every delivery fails; the symptom surfaces nowhere near the cause.
188
+ * The runtime therefore has to be the one the app ships, which is also the
189
+ * one the app hands its own bundled plugin through CODEX_MCP_NODE_PATH.
190
+ *
191
+ * The vendor's launcher additionally falls back to a cached runtime under
192
+ * ~/.cache/codex-runtimes and to a bare PATH lookup. Both are deliberately
193
+ * absent here: the cached binary measured on a real install is the same
194
+ * version and within 1.3 KB of the bundled one, yet carries the Node.js
195
+ * Foundation signature, so copying that list wholesale would reproduce the
196
+ * rejection this resolves under a different file name. For the same reason
197
+ * the runtime is never taken from PATH or from a version match.
198
+ *
199
+ * Only macOS ships this bundle and only macOS was measured to enforce the
200
+ * check, so the vendor-owned rungs are gated by platform. Every other
201
+ * platform keeps the runtime it has always used, and the last rung is an
202
+ * unconditional real path rather than a bare command name because the caller
203
+ * writes the result straight into client configuration.
204
+ */
205
+ export function resolveCodexDesktopNodeBin(
206
+ explicit,
207
+ { env = process.env, platform = process.platform, resourcesDir = CODEX_DESKTOP_RESOURCES_MACOS } = {},
208
+ ) {
209
+ const bundledNode = (dir) => path.join(dir, "cua_node", "bin", platform === "win32" ? "node.exe" : "node");
210
+ const vendorRungs =
211
+ platform === "darwin"
212
+ ? [
213
+ [env.CODEX_MCP_NODE_PATH, "CODEX_MCP_NODE_PATH"],
214
+ [env.CODEX_BROWSER_USE_NODE_PATH, "CODEX_BROWSER_USE_NODE_PATH"],
215
+ [
216
+ env.CODEX_ELECTRON_RESOURCES_PATH && bundledNode(env.CODEX_ELECTRON_RESOURCES_PATH),
217
+ "CODEX_ELECTRON_RESOURCES_PATH",
218
+ ],
219
+ [bundledNode(resourcesDir), "Codex Desktop bundle"],
220
+ ]
221
+ : [];
222
+
223
+ for (const [candidate, source] of [
224
+ [explicit, "explicit"],
225
+ [env.CODEX_NATIVE_RELAY_NODE, "CODEX_NATIVE_RELAY_NODE"],
226
+ ...vendorRungs,
227
+ ]) {
228
+ if (candidate && isRunnable(candidate)) return { path: candidate, source };
229
+ }
230
+ return { path: process.execPath, source: "process.execPath" };
231
+ }
232
+
179
233
  /**
180
234
  * The macOS and Linux `codex` launcher is a Node script with a
181
235
  * `#!/usr/bin/env node` shebang, so the spawned child needs a PATH that