@minhspark/codex-mcp-bridge 1.13.2 → 1.13.4

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.
@@ -187,9 +187,21 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
187
187
  return rows.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
188
188
  }
189
189
 
190
- export function findClaudeSession(target) {
190
+ export function findClaudeSession(target, { desktopOnly = false, expectedCwd } = {}) {
191
191
  const sessions = listClaudeSessions();
192
192
  const needle = String(target).trim();
193
+ if (desktopOnly) {
194
+ const byId = sessions.filter((session) => String(session.pid) === needle || session.sessionId === needle);
195
+ const matches = byId.length ? byId : sessions.filter((session) => session.name === needle);
196
+ if (!needle || matches.length === 0) return null;
197
+ if (matches.length !== 1) throw new Error(`Desktop-only mode requires an unambiguous sessionId or pid; "${needle}" matches multiple sessions. No message was sent.`);
198
+ const session = matches[0];
199
+ if (session.entrypoint !== "claude-desktop") {
200
+ 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.`);
201
+ }
202
+ if (expectedCwd !== undefined) assertClaudeSessionCwd(session, expectedCwd);
203
+ return session;
204
+ }
193
205
  return (
194
206
  sessions.find((s) => String(s.pid) === needle) ??
195
207
  sessions.find((s) => s.sessionId === needle) ??
@@ -199,6 +211,24 @@ export function findClaudeSession(target) {
199
211
  );
200
212
  }
201
213
 
214
+ export function assertClaudeSessionCwd(session, expectedCwd) {
215
+ if (typeof expectedCwd !== "string" || !path.isAbsolute(expectedCwd) || !session.cwd || !path.isAbsolute(session.cwd)) {
216
+ throw new Error("Desktop delivery requires an explicit absolute expectedCwd and an absolute session cwd. No message was sent.");
217
+ }
218
+ let expected;
219
+ let actual;
220
+ try {
221
+ expected = fs.realpathSync.native(expectedCwd);
222
+ actual = fs.realpathSync.native(session.cwd);
223
+ } catch {
224
+ throw new Error("The intended project directory or the Claude session cwd no longer exists. No message was sent.");
225
+ }
226
+ const normalize = (value) => IS_WINDOWS ? value.toLowerCase() : value;
227
+ if (normalize(expected) !== normalize(actual)) {
228
+ throw new Error(`Claude session cwd ${actual} does not match expectedCwd ${expected}. No message was sent.`);
229
+ }
230
+ }
231
+
202
232
  /**
203
233
  * Claude Code stores a transcript at ~/.claude/projects/<slug>/<sessionId>.jsonl
204
234
  * where the slug rewrites more than just path separators (/mnt/dev_disk ->
@@ -292,6 +322,7 @@ export class PeerEndpoint {
292
322
  this.peerToken = null;
293
323
  this.permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
294
324
  this.deliveryReceipts = new Map();
325
+ this.sentMessages = new Map();
295
326
  this.pendingMessages = new Map();
296
327
  this.responsePoll = null;
297
328
  }
@@ -432,7 +463,12 @@ export class PeerEndpoint {
432
463
  #receiveMessage(message) {
433
464
  const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
434
465
  this.inbox.push(record);
435
- this.#removePendingReply(record.fromSocket, record.inReplyTo);
466
+ const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
467
+ if (key && this.pendingMessages.get(key)?.targetSocket === record.fromSocket) {
468
+ const sent = this.sentMessages.get(key);
469
+ if (sent) sent.reply = record;
470
+ this.#removePendingReply(record.fromSocket, key);
471
+ }
436
472
  this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
437
473
  for (const listener of [...this.listeners]) {
438
474
  try { listener(record); }
@@ -503,6 +539,7 @@ export class PeerEndpoint {
503
539
  settled = true;
504
540
  globalThis.clearTimeout(timer);
505
541
  if (error) {
542
+ if (connected || writeStarted) error.deliveryUncertain = true;
506
543
  reject({ error, retryable: !connected && !writeStarted });
507
544
  } else {
508
545
  resolve();
@@ -534,14 +571,15 @@ export class PeerEndpoint {
534
571
  return frame.msg_id;
535
572
  }
536
573
 
537
- async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession } = {}) {
574
+ async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend } = {}) {
538
575
  const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
539
576
  const pending = previous.catch(() => {}).then(async () => {
577
+ beforeSend?.();
540
578
  const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
541
- if (timeoutMs > 0 && unconfirmed > 0) {
579
+ if (unconfirmed > 0) {
542
580
  const error = new Error(
543
581
  `${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.",
582
+ + "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
583
  );
546
584
  error.code = "PEER_REPLY_PENDING";
547
585
  throw error;
@@ -551,22 +589,31 @@ export class PeerEndpoint {
551
589
  this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
552
590
  let msgId = crypto.randomUUID();
553
591
  this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
592
+ this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since });
593
+ if (transcriptSession && !this.responsePoll) {
594
+ this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
595
+ this.responsePoll.unref();
596
+ }
554
597
  try {
555
598
  const sentId = await this.send(targetSocket, text, { priority, msgId });
556
599
  if (sentId !== msgId) {
557
600
  const pendingMessage = this.pendingMessages.get(msgId);
558
601
  this.pendingMessages.delete(msgId);
602
+ this.sentMessages.set(sentId, this.sentMessages.get(msgId));
603
+ this.sentMessages.delete(msgId);
559
604
  msgId = sentId;
560
605
  if (pendingMessage) this.pendingMessages.set(msgId, pendingMessage);
561
606
  }
562
607
  } catch (err) {
563
- this.#removePendingReply(targetSocket, msgId);
608
+ const sent = this.sentMessages.get(msgId);
609
+ if (sent) sent.error = err.message;
610
+ if (!err.deliveryUncertain) {
611
+ this.#removePendingReply(targetSocket, msgId);
612
+ if (sent) sent.failed = true;
613
+ }
614
+ err.msgId = msgId;
564
615
  throw err;
565
616
  }
566
- if (transcriptSession && !this.responsePoll && this.pendingMessages.has(msgId)) {
567
- this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
568
- this.responsePoll.unref();
569
- }
570
617
  const reply = timeoutMs > 0
571
618
  ? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence, msgId })
572
619
  : null;
@@ -584,7 +631,8 @@ export class PeerEndpoint {
584
631
 
585
632
  #removePendingReply(fromSocket, msgId) {
586
633
  const key = msgId ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === fromSocket)?.[0];
587
- if (key) this.pendingMessages.delete(key);
634
+ if (!key || this.pendingMessages.get(key)?.targetSocket !== fromSocket) return;
635
+ this.pendingMessages.delete(key);
588
636
  const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
589
637
  if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
590
638
  else this.unconfirmedReplies.delete(fromSocket);
@@ -596,6 +644,7 @@ export class PeerEndpoint {
596
644
  */
597
645
  waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
598
646
  const matches = (record) => record.fromSocket === fromSocket
647
+ && (!record.inReplyTo || !msgId || record.inReplyTo === msgId)
599
648
  && (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
600
649
  const existing = this.inbox.find(matches);
601
650
  if (existing) return Promise.resolve(existing);
@@ -624,6 +673,24 @@ export class PeerEndpoint {
624
673
  return messages;
625
674
  }
626
675
 
676
+ readDelivery(msgId) {
677
+ const sent = this.sentMessages.get(msgId);
678
+ if (!sent) return null;
679
+ const delivery = this.deliveryReceipts.get(msgId);
680
+ const session = sent.transcriptSession;
681
+ return {
682
+ msgId,
683
+ status: sent.reply ? "reply_received" : sent.failed ? "send_failed" : delivery?.status ?? "sent_unconfirmed",
684
+ reason: delivery?.reason ?? sent.error ?? null,
685
+ sentAt: sent.sentAt,
686
+ sessionId: session?.sessionId ?? null,
687
+ cwd: session?.cwd ?? null,
688
+ entrypoint: session?.entrypoint ?? null,
689
+ pending: this.pendingMessages.has(msgId),
690
+ ...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer" } : {}),
691
+ };
692
+ }
693
+
627
694
  stop() {
628
695
  globalThis.clearInterval(this.responsePoll);
629
696
  this.responsePoll = null;
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
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+
6
+ export function createRuntimeState({ directory = path.dirname(fileURLToPath(import.meta.url)), configuration = () => null } = {}) {
7
+ const fingerprint = () => {
8
+ const hash = createHash("sha256");
9
+ for (const file of ["../package.json", ...readdirSync(directory).filter((file) => file.endsWith(".mjs")).sort()]) {
10
+ hash.update(file).update("\0").update(readFileSync(path.join(directory, file))).update("\0");
11
+ }
12
+ return hash.digest("hex");
13
+ };
14
+ const revision = fingerprint();
15
+ const configured = JSON.stringify(configuration());
16
+ const startedAt = new Date().toISOString();
17
+ const status = () => {
18
+ let diskRevision = null;
19
+ let reason = null;
20
+ try {
21
+ diskRevision = fingerprint();
22
+ if (diskRevision !== revision) reason = "Bridge source changed after this MCP process started";
23
+ else if (JSON.stringify(configuration()) !== configured) reason = "Bridge routing configuration changed after this MCP process started";
24
+ } catch {
25
+ reason = "Bridge source or routing configuration can no longer be read";
26
+ }
27
+ return { pid: process.pid, startedAt, revision, diskRevision, current: reason === null, reason };
28
+ };
29
+ const assertCurrent = () => {
30
+ const state = status();
31
+ if (!state.current) throw new Error(`${state.reason}. Reconnect this MCP server in the existing Desktop task before sending. No message was sent; do not create a replacement task or use an external app-server.`);
32
+ };
33
+ return { status, assertCurrent };
34
+ }
@@ -64,8 +64,9 @@ export class DesktopTaskDelivery {
64
64
 
65
65
  async withThread(threadId, operation, { deadline } = {}) {
66
66
  const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
67
+ let expired = false;
67
68
  const current = previous.catch(() => {}).then(() => {
68
- if (deadline !== undefined && this.now() >= deadline) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
69
+ if (expired || (deadline !== undefined && this.now() >= deadline)) throw new Error("The response deadline elapsed while another operation held this thread. No new prompt was sent.");
69
70
  return operation();
70
71
  });
71
72
  this.threadOperations.set(threadId, current);
@@ -75,7 +76,10 @@ export class DesktopTaskDelivery {
75
76
  let timer;
76
77
  try {
77
78
  return await Promise.race([current, new Promise((_, reject) => {
78
- timer = setTimeout(() => reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again.")), Math.max(1, deadline - this.now()));
79
+ timer = setTimeout(() => {
80
+ expired = true;
81
+ reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again."));
82
+ }, Math.max(1, deadline - this.now()));
79
83
  })]);
80
84
  } finally {
81
85
  clearTimeout(timer);
@@ -129,7 +133,45 @@ export class DesktopTaskDelivery {
129
133
  if (thread?.id !== receipt.threadId || thread.hostId !== "local" || !thread.cwd || path.relative(cwd, realpathSync.native(thread.cwd))) throw new Error(`Existing Desktop task ${receipt.threadId} did not confirm the requested local workspace. No prompt was sent.`);
130
134
  this.security.assertCwd(thread.cwd);
131
135
  this.security.assertThread(thread.id, thread.cwd);
132
- return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, projectId: receipt.projectId, projectName: receipt.projectName, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
136
+ const assignment = await this.receiptProjectAssignment(receipt, cwd, { deadline });
137
+ return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
138
+ }
139
+
140
+ async receiptProjectAssignment(receipt, cwd, { deadline }) {
141
+ let project;
142
+ try {
143
+ const listed = await this.request("list_projects", {}, { deadline });
144
+ if (!Array.isArray(listed?.projects)) throw new Error("Desktop returned no project list");
145
+ project = matchDesktopProject(listed.projects, cwd);
146
+ if (receipt.projectId && receipt.projectId !== project.projectId) throw new Error("The saved project identity changed for this directory");
147
+ } catch (err) {
148
+ throw new Error(`Existing Desktop task ${receipt.threadId}'s saved project could not be verified: ${err.message}. No prompt was resent and no duplicate task was created. Inspect this existing task and its project settings.`, { cause: err });
149
+ }
150
+ const unverified = (reason) => ({
151
+ expectedProjectId: project.projectId,
152
+ expectedProjectName: project.label,
153
+ projectAssignmentStatus: "unverified",
154
+ projectAssignmentNote: `${reason} The task's current project assignment is unverified. Its workspace and existing ID were retained; no prompt was resent and no duplicate task was created. Inspect this task in Codex Desktop.`,
155
+ });
156
+ let snapshot;
157
+ try {
158
+ snapshot = await this.request("list_threads", { limit: 50 }, { deadline });
159
+ if (!Array.isArray(snapshot?.threads) || !Array.isArray(snapshot?.pinnedThreads)) throw new Error("Desktop returned an invalid thread list");
160
+ if (snapshot.unavailableHosts?.some((host) => host === "local" || host?.hostId === "local")) throw new Error("The local Desktop host is unavailable");
161
+ } catch (err) {
162
+ return unverified(`Desktop's recent/pinned listing could not be confirmed: ${err.message}.`);
163
+ }
164
+ const observed = [...snapshot.pinnedThreads, ...snapshot.threads].filter((thread) => thread?.id === receipt.threadId && thread.kind === "codex" && thread.hostId === "local");
165
+ if (observed.length === 0) return unverified("This task is absent from Desktop's recent/pinned listing.");
166
+ for (const thread of observed) {
167
+ if (thread.projectId !== undefined && thread.projectId !== project.projectId) throw new Error(`Existing Desktop task ${receipt.threadId}'s project assignment changed. No prompt was resent and no duplicate task was created. Inspect its assignment in Codex Desktop.`);
168
+ if (thread.cwd) {
169
+ this.security.assertCwd(thread.cwd);
170
+ if (path.relative(cwd, realpathSync.native(thread.cwd))) throw new Error(`Existing Desktop task ${receipt.threadId}'s listed workspace changed. No prompt was resent and no duplicate task was created.`);
171
+ }
172
+ }
173
+ if (observed.some((thread) => thread.projectId === undefined || !thread.cwd)) return unverified("Desktop's listing omitted this task's project or workspace metadata.");
174
+ return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
133
175
  }
134
176
 
135
177
  async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {