@minhspark/codex-mcp-bridge 1.13.3 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [1.13.4] - 2026-09-06
8
+
9
+ ### Fixed
10
+
11
+ - Latch queued Desktop delivery deadlines when they expire, so a timed-out prompt cannot dispatch later or release the lock owned by an active send.
12
+ - Require and revalidate the exact intended workspace for Claude Desktop sends, expose pending recipient receipts through `read_claude_delivery`, and block no-wait retries of held or uncertain messages. Preserve pending ownership after an uncertain socket write.
13
+ - Detect source and Desktop-policy changes in loaded bridge processes and refuse new sends until reconnect. Diagnostics identify their separate MCP process instead of implying the Desktop connection was refreshed.
14
+ - Preserve the Codex MCP registration environment on upgrade, pin Desktop routing explicitly, disable its external autostart, and refuse to reset custom access or transport settings. Prefer existing Desktop tasks in MCP instructions.
15
+ - Enforce Desktop task mode for Claude destinations as well as Codex delivery. Exclude non-Desktop sessions from discovery, refuse CLI or unknown entrypoints before connecting, and require a unique exact target. Status reports eligible and excluded sessions; receipts identify the actual entrypoint, workspace, and session. Legacy CLI mode remains available when Desktop task mode is disabled.
16
+ - Register the native relay companion under a runtime Codex Desktop trusts instead of whichever Node happens to run the installer. The app authenticates the code-signing identity of any process connecting to its native tools pipe and closes the connection before reading a byte when that identity is not its own, so a companion started by a user-installed Node still created its socket and still reported itself installed while every delivery failed as `NATIVE_DELIVERY_UNCONFIRMED` — the refusal is only visible in the app's own log as `dynamic_app_tools_peer_rejected`. On macOS the installer now resolves the runtime the app ships, honours the variables the app declares for its own bundled plugin, and prints the runtime and its source; `CODEX_NATIVE_RELAY_NODE` overrides it. Windows and Linux keep the runtime running the installer, since neither was measured to enforce the check.
17
+
18
+ ### Upgrade notes
19
+
20
+ - Reconnect the MCP connections in both Desktop clients after upgrading. Reinstall an existing native relay companion with the updated installer to apply runtime selection. Updating package files alone does not replace running processes or add the stale-runtime guard to an older process.
21
+ - In Desktop task mode, provide the independently verified absolute `expectedCwd` when sending to Claude. A held receipt still requires the recipient's approval; inspect its `msgId` with `read_claude_delivery` instead of resending. Receipt history is in memory and is not restored after a bridge restart.
22
+
5
23
  ## [1.13.3] - 2026-09-05
6
24
 
7
25
  ### Fixed
package/README.md CHANGED
@@ -258,7 +258,7 @@ claude mcp remove codex-bridge --scope user
258
258
  node scripts/install-codex-mcp.mjs
259
259
  ```
260
260
 
261
- This runs `codex mcp add claude-bridge -- <node> src/claude-bridge.mjs`, writing to `~/.codex/config.toml`. The equivalent by hand:
261
+ The installer updates the named entry through `codex mcp add`, preserves existing environment settings, pins the configured Desktop policy, and disables external autostart in Desktop mode. It refuses to reset custom access, timeout, or transport settings. It never infers the sender permission class from the recipient. The basic registration by hand is:
262
262
 
263
263
  ```bash
264
264
  codex mcp add claude-bridge --env CLAUDE_BRIDGE_PEER_NAME=codex-desktop -- "$(command -v node)" "$PWD/src/claude-bridge.mjs"
@@ -272,11 +272,7 @@ codex mcp get claude-bridge
272
272
  node scripts/install-codex-mcp.mjs --remove
273
273
  ```
274
274
 
275
- Restart the Codex app (or open a new Codex session) to load it. If a shared app-server is already running under launchd, reload its config with:
276
-
277
- ```bash
278
- launchctl kickstart -k gui/$UID/com.codex-mcp-bridge.app-server
279
- ```
275
+ Reconnect the MCP server in the existing Codex Desktop task, or restart the app and reopen that same task. Verify `claude_bridge_status` **from that task**: `runtime state` must be `current` and `session policy` must match the intended mode. Do not create a replacement task. `npm run check:claude` starts a separate diagnostic process and cannot prove that the app's existing MCP connection reloaded.
280
276
 
281
277
  `CLAUDE_BRIDGE_PEER_NAME` sets the name Claude shows for this bridge in its agent list.
282
278
 
@@ -288,7 +284,7 @@ Optional, and only worth installing if you keep the bound thread **open in Codex
288
284
  node scripts/install-native-relay.mjs
289
285
  ```
290
286
 
291
- This registers the companion with Codex (`codex mcp add codex-native-relay -- <node> src/native-relay-companion.mjs`) and bootstraps the executor thread the native dispatch needs, writing its id to `~/.codex/native-relay.json`. The bootstrap creates one thread, unsubscribes it, and closes its connection. It leaves other clients and threads running; an idle server may retain the executor until its configured unload delay expires.
287
+ This registers the companion with Codex (`codex mcp add codex-native-relay -- <node> src/native-relay-companion.mjs`) and bootstraps the executor thread the native dispatch needs, writing its id to `~/.codex/native-relay.json`. The `<node>` is resolved rather than inherited: Codex Desktop authenticates the code-signing identity of any process that connects to its native tools pipe, so on macOS the installer registers the runtime the app ships and prints the `relay runtime:` line naming it and where it came from. `CODEX_NATIVE_RELAY_NODE` overrides that on any platform; Windows and Linux keep the Node running the installer, as before. The bootstrap creates one thread, unsubscribes it, and closes its connection. It leaves other clients and threads running; an idle server may retain the executor until its configured unload delay expires.
292
288
 
293
289
  ```bash
294
290
  codex mcp get codex-native-relay
@@ -384,6 +380,7 @@ Under `owned`, a thread a human opened is **unreachable rather than merely restr
384
380
  |---|---|---|
385
381
  | `list_claude_sessions` | Lists Claude Code sessions running on this machine (name, pid, sessionId, cwd, entrypoint). | read-only |
386
382
  | `send_to_claude_session` | Sends to a Claude inbox and waits for a correlated reply. Receipt statuses distinguish `reply_received`, `sent_unconfirmed`, `reply_timeout`, and receiver policy outcomes such as `held` or `refused`. `waitSec: 0` sends without waiting for confirmation. Prefer an exact `sessionId` because session names can change. | destructive |
383
+ | `read_claude_delivery` | Inspects the latest recipient receipt or correlated reply by original message ID without resending or clearing the inbox. Receipts belong to this MCP process; an unknown ID after reconnect never proves non-delivery. | read-only |
387
384
  | `read_claude_inbox` | Reads **and clears** messages Claude pushed over on its own, including replies that arrived late. | destructive |
388
385
  | `read_claude_transcript` | Reads a Claude session's recent conversation without sending anything. | read-only |
389
386
  | `bind_codex_thread` | Binds a Codex thread so every message from Claude is relayed into it, **visible in the Codex desktop app**. Pass an empty string to stop. | writes |
@@ -391,7 +388,15 @@ Under `owned`, a thread a human opened is **unreachable rather than merely restr
391
388
 
392
389
  Every tool declares MCP annotation hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), because a client decides whether a call needs a human in the loop from those hints and a missing one reads as "unknown". Two are worth naming: `read_claude_inbox` empties the inbox as it reads it, so it is **not** read-only despite the name, and `claude_bridge_status` registers the peer endpoint on first call, so it writes too.
393
390
 
394
- Waited sends to the same Claude session run in order. If an earlier send timed out or used `waitSec: 0` and its reply has not arrived, a new waited send returns `PEER_REPLY_PENDING` before delivering another message. Wait for the earlier reply and inspect `read_claude_inbox`, or use `waitSec: 0` if another asynchronous message is intended. Different destination sessions remain independent.
391
+ Desktop task mode (`CODEX_BRIDGE_DESKTOP_TASKS=1` or the shared setting written by `codex-native-relay-install --desktop-tasks`) applies in both directions. `list_claude_sessions` only offers sessions advertised by Claude Desktop (`entrypoint: claude-desktop`), and sending refuses CLI or unknown entrypoints before connecting. Targets must match a unique exact session ID, PID, or name; partial names are not accepted. Open or reconnect an existing Code session in Claude Desktop at the intended project when none is available. Do not launch a replacement CLI session: CLI and Desktop have separate conversation histories. Legacy mode still supports CLI sessions.
392
+
393
+ `claude_bridge_status` reports the destination policy, eligible session count, and excluded non-Desktop count. Send receipts include the destination's `entrypoint`, `cwd`, and `sessionId`. A correlated reply confirms receipt in that session; it does not independently prove that its conversation is visible in the Desktop UI. Desktop sends require `expectedCwd`, an absolute independently verified project directory. The bridge resolves both paths and refuses a missing, nonexistent, parent, or different directory before sending; it rechecks the destination after queueing. Reconnect the bridge's MCP connection after upgrading to load the enforcement code.
394
+
395
+ Sends to the same Claude session run in order. An outstanding message blocks all further sends to that session, including `waitSec: 0`; changing wait time must not bypass a held or uncertain delivery. Use `read_claude_delivery` with the original message ID to observe `held`, `expired`, `refused`, or a late reply. A transport error after writing began also retains pending ownership. Different destination sessions remain independent. A process restart loses in-memory receipts: retain the original ID and inspect the existing recipient conversation before any resend; never treat an unknown ID as permission to retry.
396
+
397
+ Both bridge status tools report the loaded source fingerprint and process identity. Source or Desktop-routing configuration changes make the running process stale and block new sends until reconnect, while Claude inbox/receipt reads remain available. This guard applies only after the version containing it has loaded; upgrading files cannot retrofit the guard into an older process already running.
398
+
399
+ Claude's [inbound permission controls](https://code.claude.com/docs/en/cross-session-messaging#control-inbound-messages) remain authoritative. A missing sender permission class can cause a bypassing recipient to hold the message. Do not change `CLAUDE_BRIDGE_PERMISSION_MODE` or recipient settings merely to bypass a hold/refusal. Configure a sender class only when it truthfully represents every session using that MCP entry. If the Desktop UI does not expose the pending approval, report that boundary and keep the original receipt rather than creating a CLI session.
395
400
 
396
401
  Claude Desktop disables the CLI-native `SendMessage` tool. For Desktop targets, the bridge requests an ordinary answer in the destination conversation and reads only a completed assistant turn descended from the injected message UUID. Unrelated human prompts and sidechains are excluded. Late answers remain available through the inbox while the bridge process is running. This does not change Desktop tool permissions or the receiver's inbound policy.
397
402
 
@@ -535,6 +540,8 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
535
540
 
536
541
  **The Codex app says a thread is "open in another application".** The app-server holds a writer lock while the thread is loaded. With `releaseAfterTurn: true`, the bridge unsubscribes after completion; unload can still wait for the server's idle delay or another subscriber. Open the thread after it unloads. Use `stop_codex_app_server` only when all work on that shared server may be stopped. A thread held by a different Codex window must be released there. To keep a thread open in Codex Desktop while Claude messages into it, use the [native relay](#codex-desktop-native-relay), which never takes a second writer lock.
537
542
 
543
+ **Desktop delivery fails on macOS while the relay reports itself installed.** The companion still creates its socket and `native_relay_status` still reports the native pipe, because Codex Desktop accepts the connection and only then closes it: it authenticates the connecting process's code-signing identity first, and a companion running under a Node build signed by anyone other than the vendor is refused. Every send then fails hard as `NATIVE_DELIVERY_UNCONFIRMED` rather than falling back, since the companion was reached. The app records the refusal as `dynamic_app_tools_peer_rejected reason=untrusted-code-signing-identity` (`~/Library/Logs/com.openai.codex/<date>/`; a process the app did not spawn is logged as `missing-code-signing-identity` instead). Re-run `scripts/install-native-relay.mjs` and check its `relay runtime:` line — it must not say `process.execPath` when Codex Desktop is installed. Point `CODEX_NATIVE_RELAY_NODE` at the runtime inside the app bundle if discovery misses it, and restart Codex Desktop so it relaunches the companion.
544
+
538
545
  **`claude_bridge_status` says the delivery backend is `app-server` on Windows or macOS with the relay installed.** The `delivery:` line carries the reason: *"no companion socket at …"* means Codex Desktop has not launched the companion, so restart the app after `install-native-relay.mjs` and check `codex mcp get codex-native-relay`; *"disabled by CODEX_BRIDGE_NATIVE_RELAY=0"* means it was switched off in the MCP server's `env`; *"unavailable on Linux"* means the native relay is not supported there. A relay that is reachable but has no executor thread fails at send time instead, with `RELAY_THREAD_UNCONFIGURED` naming both `CODEX_RELAY_ID` and the file to bootstrap.
539
546
 
540
547
  **A thread opens against the wrong directory.** The same project sits at a different absolute path on each machine: on the shared drive's letter under Windows, under its mount point when that drive is visible from macOS (**read-only** there), and in a native checkout otherwise. Since 1.4.0 the bridge picks the candidate that both **exists and is writable** on the current machine and prints a `note: cwd remapped …` line whenever it rewrites one. If nothing usable exists it fails immediately instead of opening a thread somewhere wrong. Handing Codex a read-only cwd is a reliable way to hit the freeze above: it runs a few reads, then asks for write permission and stalls.
@@ -578,6 +585,7 @@ The bridge reads these from the environment its MCP client hands it — there is
578
585
  | `CODEX_NATIVE_RELAY_SOCKET` | Windows named pipe or `$CODEX_HOME/native-relay.sock` on macOS | Override the companion endpoint on both halves of the relay. |
579
586
  | `CODEX_NATIVE_RELAY_METHOD` | `tools/call` | The undocumented Codex Desktop JSON-RPC method the companion dispatches through; override only for a verified protocol change. |
580
587
  | `CODEX_NATIVE_RELAY_NAME` | `codex-native-relay` | The MCP server name `scripts/install-native-relay.mjs` registers with Codex. |
588
+ | `CODEX_NATIVE_RELAY_NODE` | the runtime Codex Desktop ships on macOS, otherwise `process.execPath` | Node runtime `scripts/install-native-relay.mjs` registers the companion under. Codex Desktop rejects a peer whose code-signing identity is not its own, so the companion has to run under a runtime the app trusts. |
581
589
  | `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
582
590
  | `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
583
591
  | `CLAUDE_DESKTOP_CONFIG` | auto-detected | Override the config path used by `install-claude-desktop.mjs`. |
@@ -641,7 +649,7 @@ The Codex → Claude direction has its own live check:
641
649
 
642
650
  ```bash
643
651
  npm run check:claude # list the Claude sessions Codex can see
644
- CLAUDE_TARGET=<sessionId> CLAUDE_WAIT=150 npm run check:claude # deliver a message and wait for the answer
652
+ CLAUDE_TARGET=<sessionId> CLAUDE_EXPECTED_CWD=/absolute/project CLAUDE_WAIT=150 npm run check:claude # deliver a message and wait for the answer
645
653
  ```
646
654
 
647
655
  A reply coming back proves both directions work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.13.3",
3
+ "version": "1.13.4",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -12,6 +12,7 @@ const transport = new StdioClientTransport({
12
12
  });
13
13
  const client = new Client({ name: "claude-bridge-check", version: "1.0.0" });
14
14
  try {
15
+ console.log("This diagnostic starts a separate MCP process; it does not reload or verify an existing Desktop task's MCP connection.");
15
16
  await client.connect(transport);
16
17
 
17
18
  const tools = await client.listTools();
@@ -32,7 +33,7 @@ try {
32
33
  console.log(`\nsending to "${target}" (waiting ${waitSec}s)...`);
33
34
  const sent = await client.callTool({
34
35
  name: "send_to_claude_session",
35
- arguments: { target, message, waitSec },
36
+ arguments: { target, message, waitSec, expectedCwd: process.env.CLAUDE_EXPECTED_CWD },
36
37
  });
37
38
  console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
38
39
  if (sent.isError) throw new Error("Claude message roundtrip failed; receipt or outcome is unconfirmed");
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
8
8
  import { exitForVersionRequest } from "../src/cli-version.mjs";
9
+ import { desktopTasksConfigured } from "../src/native-relay.mjs";
10
+ import { codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
9
11
 
10
12
  exitForVersionRequest(import.meta.url);
11
13
 
@@ -29,22 +31,17 @@ if (remove) {
29
31
  process.exit(0);
30
32
  }
31
33
 
32
- try {
33
- run(["mcp", "remove", serverName]);
34
- } catch {
35
- // not registered yet
36
- }
37
-
38
- const args = ["mcp", "add", serverName];
39
- const peerName = process.env.CLAUDE_BRIDGE_PEER_NAME;
40
- if (peerName) args.push("--env", `CLAUDE_BRIDGE_PEER_NAME=${peerName}`);
41
- if (permissionMode) args.push("--env", `CLAUDE_BRIDGE_PERMISSION_MODE=${permissionMode}`);
42
- args.push("--", process.execPath, entry);
43
-
44
- run(args);
34
+ const servers = JSON.parse(run(["mcp", "list", "--json"]));
35
+ if (!Array.isArray(servers)) throw new Error("Codex returned an invalid MCP inventory; existing configuration was not changed");
36
+ const existing = servers.some((server) => server.name === serverName)
37
+ ? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
38
+ const registration = codexMcpRegistration({ name: serverName, existing, node: process.execPath, entry, env: process.env, desktopOnly: desktopTasksConfigured() });
39
+ run(registration.args);
45
40
 
46
41
  console.log(`platform: ${PLATFORM_LABEL}`);
47
42
  console.log(`registered MCP server "${serverName}" with Codex:`);
48
- console.log(run(["mcp", "get", serverName]));
49
- console.log("\nRestart the Codex app (or start a new Codex session) to load the bridge.");
43
+ console.log(`command: ${process.execPath} ${entry}`);
44
+ console.log(`environment keys: ${registration.environmentKeys.join(", ")}`);
45
+ console.log("\nReconnect the MCP server in the existing Codex Desktop task, or restart the app and reopen that same task. Do not create a replacement task.");
46
+ console.log("Verify claude_bridge_status from that task: runtime state must be current and the session policy must match this installation. A separate diagnostic process does not verify the app's loaded MCP process.");
50
47
  console.log(`remove: node scripts/install-codex-mcp.mjs --remove`);
@@ -6,12 +6,21 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
8
  import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath, writeRelayConfig } from "../src/native-relay.mjs";
9
- import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
9
+ import {
10
+ IS_MACOS,
11
+ IS_WINDOWS,
12
+ PLATFORM_LABEL,
13
+ hasCodexDesktopApp,
14
+ homeDir,
15
+ resolveCodexBin,
16
+ resolveCodexDesktopNodeBin,
17
+ spawnEnv,
18
+ } from "../src/platform.mjs";
10
19
  import { exitForVersionRequest } from "../src/cli-version.mjs";
11
20
 
12
21
  exitForVersionRequest(import.meta.url);
13
22
 
14
- const VERSION = "1.13.3";
23
+ const VERSION = "1.13.4";
15
24
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
25
  const entry = path.join(root, "src", "native-relay-companion.mjs");
17
26
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -56,9 +65,32 @@ try {
56
65
  } catch {
57
66
  // not registered yet
58
67
  }
59
- run(["mcp", "add", serverName, "--", process.execPath, entry]);
68
+ /**
69
+ * Registering under whichever Node happens to run this installer is what made
70
+ * the companion unreachable on macOS: Codex Desktop rejects a peer whose
71
+ * code-signing identity is not the vendor's, so the relay reported itself
72
+ * installed while every delivery failed. Resolve a runtime the app trusts
73
+ * instead, and print it - a mismatch has to be visible here rather than
74
+ * inferred later from a delivery that never confirms.
75
+ */
76
+ const runtime = resolveCodexDesktopNodeBin();
77
+
78
+ run(["mcp", "add", serverName, "--", runtime.path, entry]);
60
79
 
61
80
  console.log(`platform: ${PLATFORM_LABEL}`);
81
+ console.log(`relay runtime: ${runtime.path} (${runtime.source})`);
82
+ if (runtime.source === "process.execPath" && hasCodexDesktopApp()) {
83
+ console.log(
84
+ "\nNOTE: no Codex Desktop runtime was found, so the companion is registered under this Node.",
85
+ );
86
+ console.log(
87
+ "Codex Desktop rejects peers whose code-signing identity is not its own (untrusted-code-signing-identity),",
88
+ );
89
+ console.log(
90
+ "so delivery can fail while the relay still reports itself installed. Set CODEX_NATIVE_RELAY_NODE to the",
91
+ );
92
+ console.log("runtime shipped inside Codex Desktop if that happens.");
93
+ }
62
94
  console.log(`registered MCP server "${serverName}" with Codex:`);
63
95
  console.log(run(["mcp", "get", serverName]));
64
96
 
@@ -5,19 +5,23 @@ import { z } from "zod";
5
5
 
6
6
  import { CodexAppServerClient } from "./app-server-client.mjs";
7
7
  import { PLATFORM_LABEL } from "./platform.mjs";
8
- import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
8
+ import { PeerEndpoint, assertClaudeSessionCwd, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
9
9
  import { createThreadDelivery } from "./thread-delivery.mjs";
10
10
  import { exitForVersionRequest } from "./cli-version.mjs";
11
+ import { desktopTasksConfigured } from "./native-relay.mjs";
12
+ import { createRuntimeState } from "./runtime-state.mjs";
11
13
 
12
14
  exitForVersionRequest(import.meta.url);
13
15
 
14
- const VERSION = "1.13.3";
16
+ const VERSION = "1.13.4";
15
17
  const FORWARD_MIN_INTERVAL_MS = 5000;
16
18
  const FORWARD_MAX_PER_SESSION = 50;
17
19
 
18
20
  const log = (msg) => process.stderr.write(`[claude-bridge] ${msg}\n`);
19
21
 
20
22
  const defaultPeerName = process.env.CLAUDE_BRIDGE_PEER_NAME ?? `codex-${process.pid}`;
23
+ const desktopOnly = desktopTasksConfigured();
24
+ const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
21
25
 
22
26
  const peer = new PeerEndpoint({
23
27
  name: defaultPeerName,
@@ -36,7 +40,7 @@ const codex = new CodexAppServerClient({
36
40
  * other thread through the shared one. `claude-bridge` never picks between
37
41
  * them - see `thread-delivery.mjs`.
38
42
  */
39
- const delivery = createThreadDelivery({ codex, log });
43
+ const delivery = createThreadDelivery({ codex, log, desktopOnly });
40
44
 
41
45
  const forwarding = {
42
46
  threadId: process.env.CODEX_THREAD_ID ?? null,
@@ -49,7 +53,12 @@ const textResult = (text, isError = false) => ({
49
53
  ...(isError ? { isError: true } : {}),
50
54
  });
51
55
 
52
- const failure = (err) => textResult(`Claude bridge error: ${err?.message ?? String(err)}`, true);
56
+ const failure = (err) => ({
57
+ ...textResult(`Claude bridge error: ${err?.message ?? String(err)}${err?.msgId ? `\nMessage id: ${err.msgId}; inspect read_claude_delivery before any resend.` : ""}`, true),
58
+ ...(err?.msgId ? { structuredContent: { receipt: peer.readDelivery(err.msgId) } } : {}),
59
+ });
60
+
61
+ const missingDesktopSession = "No live Claude Desktop session with an exact matching ID or name and a messaging endpoint. Open or reconnect an existing Code session in Claude Desktop for the intended project. CLI sessions are excluded; do not launch a replacement CLI session.";
53
62
 
54
63
  function formatSessionRow(s) {
55
64
  const started = s.startedAt ? new Date(s.startedAt).toISOString().replace("T", " ").slice(0, 16) : "?";
@@ -75,6 +84,7 @@ async function forwardToCodexThread(record) {
75
84
  forwarding.lastAt = now;
76
85
  forwarding.count += 1;
77
86
  try {
87
+ runtime.assertCurrent();
78
88
  const { backend } = await delivery.deliver(
79
89
  forwarding.threadId,
80
90
  `[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`,
@@ -96,7 +106,10 @@ const server = new McpServer(
96
106
  "Talk to a live Claude Code session from Codex. list_claude_sessions finds the session, " +
97
107
  "send_to_claude_session sends to its peer transport and waits for a reply to confirm receipt. " +
98
108
  "This bridge registers itself as a peer, so Claude sees it in its own agent list and can " +
99
- "message back; bind_codex_thread relays those messages into a Codex thread.",
109
+ "message back; bind_codex_thread relays those messages into a Codex thread. " +
110
+ "In Desktop-only mode, both destinations must belong to their Desktop apps. " +
111
+ "Use an existing session with the exact project directory and sessionId; never launch a CLI session " +
112
+ "or an external app-server as a substitute. A receipt confirms a reply, not visual verification in the app.",
100
113
  },
101
114
  );
102
115
 
@@ -106,7 +119,7 @@ server.registerTool(
106
119
  title: "List live Claude Code sessions",
107
120
  description:
108
121
  "List Claude Code sessions running on this machine (name, pid, sessionId, cwd, how it was started). " +
109
- "Use it to pick the session to talk to.",
122
+ "In Desktop-only mode, only Claude Desktop Code sessions are listed. Match the exact project cwd and sessionId before sending.",
110
123
  inputSchema: {
111
124
  includeDead: z.boolean().optional().describe("Also list sessions whose process is gone (default false)"),
112
125
  },
@@ -118,10 +131,10 @@ server.registerTool(
118
131
  async ({ includeDead }) => {
119
132
  try {
120
133
  const sessions = listClaudeSessions({ includeDead: includeDead ?? false }).filter(
121
- (s) => s.pid !== process.pid,
134
+ (s) => s.pid !== process.pid && (!desktopOnly || s.entrypoint === "claude-desktop"),
122
135
  );
123
- if (!sessions.length) return textResult("No live Claude Code session found.");
124
- return textResult(`${sessions.length} Claude session(s):\n\n${sessions.map(formatSessionRow).join("\n")}`);
136
+ if (!sessions.length) return textResult(desktopOnly ? missingDesktopSession : "No live Claude Code session found.");
137
+ return textResult(`${sessions.length} Claude${desktopOnly ? " Desktop" : ""} session(s):\n\n${sessions.map(formatSessionRow).join("\n")}`);
125
138
  } catch (err) {
126
139
  return failure(err);
127
140
  }
@@ -135,11 +148,13 @@ server.registerTool(
135
148
  description:
136
149
  "Send a message to a running Claude Code session's peer transport and wait for its reply. " +
137
150
  "A socket write alone does not confirm that Claude received the message. Set waitSec to 0 to send without confirmation. " +
138
- "A waited send is refused while earlier messages to that session still await replies; " +
139
- "wait for those replies and read_claude_inbox before trying again.",
151
+ "Every send is refused while earlier messages to that session still await replies, including waitSec 0; " +
152
+ "wait for those replies and read_claude_inbox before trying again. Desktop-only mode refuses CLI or unknown " +
153
+ "entrypoints, partial names, ambiguous targets, and a missing or mismatched expectedCwd before sending. It never creates a replacement session.",
140
154
  inputSchema: {
141
155
  target: z.string().describe("Session name, pid or sessionId from list_claude_sessions"),
142
156
  message: z.string().describe("The message text to deliver"),
157
+ expectedCwd: z.string().optional().describe("Exact absolute project directory independently verified by the caller; required in Desktop-only mode"),
143
158
  waitSec: z
144
159
  .number()
145
160
  .int()
@@ -155,26 +170,36 @@ server.registerTool(
155
170
  openWorldHint: true,
156
171
  },
157
172
  },
158
- async ({ target, message, waitSec }) => {
173
+ async ({ target, message, waitSec, expectedCwd }) => {
159
174
  try {
175
+ runtime.assertCurrent();
176
+ const session = findClaudeSession(target, { desktopOnly });
177
+ if (!session) return textResult(desktopOnly ? missingDesktopSession : `No live Claude session matches "${target}".`, true);
178
+ if (desktopOnly || expectedCwd !== undefined) assertClaudeSessionCwd(session, expectedCwd);
160
179
  await peer.start();
161
- const session = findClaudeSession(target);
162
- if (!session) return textResult(`No live Claude session matches "${target}".`, true);
163
180
 
164
181
  const wait = waitSec ?? 180;
165
182
  const desktop = session.entrypoint === "claude-desktop";
166
183
  const text = desktop ? `${message}\n\n[Bridge response routing: reply with ordinary text in this conversation. The bridge reads the response associated with this message from the local transcript; no cross-session reply tool is needed.]` : message;
167
184
  const { msgId, reply, delivery } = await peer.sendAndWait(session.socket, text, {
168
185
  timeoutMs: wait * 1000,
186
+ beforeSend: () => {
187
+ runtime.assertCurrent();
188
+ const current = findClaudeSession(session.sessionId ?? String(session.pid), { desktopOnly });
189
+ if (!current || current.pid !== session.pid || current.socket !== session.socket) {
190
+ throw new Error("The Claude destination changed while this message was queued. No message was sent; inspect the existing Desktop session.");
191
+ }
192
+ if (desktopOnly || expectedCwd !== undefined) assertClaudeSessionCwd(current, expectedCwd);
193
+ },
169
194
  ...(desktop ? { transcriptSession: session } : {}),
170
195
  });
171
196
  const status = reply ? "reply_received" : delivery?.status ?? (wait === 0 ? "sent_unconfirmed" : "reply_timeout");
172
- const receipt = { status, msgId, target: session.name ?? String(session.pid), sessionId: session.sessionId, waitSec: wait, ...(reply ? { source: reply.source ?? "peer" } : {}) };
197
+ const receipt = { status, msgId, target: session.name ?? String(session.pid), sessionId: session.sessionId, cwd: session.cwd, entrypoint: session.entrypoint, waitSec: wait, ...(reply ? { source: reply.source ?? "peer" } : {}) };
173
198
  const result = (text, isError = false) => ({ ...textResult(text, isError), structuredContent: { receipt } });
174
- const targetLabel = `${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
199
+ const targetLabel = `${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"}, via ${session.entrypoint ?? "unknown"}, cwd ${session.cwd ?? "?"})`;
175
200
 
176
201
  if (!reply && delivery && delivery.status !== "delivered") {
177
- return result(`Claude inbox reported ${delivery.status} for ${targetLabel}.\n${delivery.reason}\nMessage id: ${msgId}`, true);
202
+ return result(`Claude inbox reported ${delivery.status} for ${targetLabel}.\n${delivery.reason}\nMessage id: ${msgId}\nInspect read_claude_delivery with this message ID. Do not resend, change the sender permission class, or alter recipient permissions to bypass this receipt.`, true);
178
203
  }
179
204
 
180
205
  if (wait === 0) {
@@ -195,6 +220,21 @@ server.registerTool(
195
220
  },
196
221
  );
197
222
 
223
+ server.registerTool(
224
+ "read_claude_delivery",
225
+ {
226
+ title: "Inspect a Claude message receipt without resending",
227
+ description: "Read the latest recipient control receipt or correlated reply for a message sent by this MCP process. Unknown IDs do not prove non-delivery; retain the original receipt after a reconnect and inspect the existing Claude session before any resend.",
228
+ inputSchema: { msgId: z.string().describe("The original message ID returned by send_to_claude_session") },
229
+ annotations: { readOnlyHint: true, openWorldHint: false },
230
+ },
231
+ async ({ msgId }) => {
232
+ const receipt = peer.readDelivery(msgId);
233
+ if (!receipt) return textResult("This MCP process has no receipt for that message ID. It may belong to a previous process; do not infer failure or resend. Inspect the original Claude Desktop session.", true);
234
+ return { ...textResult(JSON.stringify(receipt, null, 2)), structuredContent: { receipt } };
235
+ },
236
+ );
237
+
198
238
  server.registerTool(
199
239
  "read_claude_inbox",
200
240
  {
@@ -310,18 +350,25 @@ server.registerTool(
310
350
  try {
311
351
  await peer.start();
312
352
  const sessions = listClaudeSessions().filter((s) => s.pid !== process.pid);
353
+ const eligible = sessions.filter((session) => !desktopOnly || session.entrypoint === "claude-desktop");
313
354
  const lines = [
314
355
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
315
356
  `bridge: claude-bridge ${VERSION}`,
316
357
  `peer name: ${peer.name} (Claude sees this in its agent list)`,
317
358
  `peer socket: ${peer.socketPath}`,
318
359
  `sender mode: ${peer.permissionMode ?? "unknown (recipient may hold messages for approval)"}`,
319
- `live sessions: ${sessions.length}`,
360
+ `session policy: ${desktopOnly ? "desktop-only" : "all Claude Code entrypoints"}`,
361
+ `live sessions: ${eligible.length}`,
362
+ `excluded: ${sessions.length - eligible.length} non-Desktop session(s)`,
320
363
  `relay thread: ${forwarding.threadId ?? "(none - use bind_codex_thread)"}`,
321
364
  `delivery: ${delivery.describe()}`,
322
365
  `inbox: ${peer.inbox.length} pending message(s)`,
366
+ `outstanding: ${peer.pendingMessages.size} message(s) awaiting receipt or reply`,
367
+ ...[...peer.pendingMessages.keys()].map((id) => `pending message: ${id} (${peer.readDelivery(id)?.status ?? "sent_unconfirmed"})`),
323
368
  ];
324
- return textResult(lines.join("\n"));
369
+ const state = runtime.status();
370
+ lines.push(`runtime pid: ${state.pid}`, `loaded source: ${state.revision}`, `disk source: ${state.diskRevision ?? "unreadable"}`, `runtime state: ${state.current ? "current" : `STALE - ${state.reason}; reconnect this MCP server in the existing task`}`);
371
+ return { ...textResult(lines.join("\n"), !state.current), structuredContent: { runtime: state } };
325
372
  } catch (err) {
326
373
  return failure(err);
327
374
  }
@@ -0,0 +1,25 @@
1
+ export function codexMcpRegistration({ name, existing, node, entry, env, desktopOnly }) {
2
+ if (existing) {
3
+ const customized = ["enabled_tools", "disabled_tools", "startup_timeout_sec", "tool_timeout_sec", "disabled_reason"]
4
+ .some((key) => existing[key] != null && (!Array.isArray(existing[key]) || existing[key].length > 0));
5
+ if (existing.enabled === false || customized || existing.transport?.type !== "stdio" || existing.transport?.cwd || existing.transport?.env_vars?.length) {
6
+ throw new Error("The existing MCP entry has custom access, timeout, or transport settings. Keep that entry and update only its command/args using Codex settings; the installer will not remove or reset it.");
7
+ }
8
+ }
9
+ const values = { ...existing?.transport?.env };
10
+ for (const key of ["CLAUDE_BRIDGE_PEER_NAME", "CLAUDE_BRIDGE_PERMISSION_MODE", "CODEX_BRIDGE_DESKTOP_TASKS"]) {
11
+ if (env[key] !== undefined) values[key] = env[key];
12
+ }
13
+ values.CODEX_BRIDGE_DESKTOP_TASKS ??= desktopOnly ? "1" : "0";
14
+ if (!["0", "1"].includes(values.CODEX_BRIDGE_DESKTOP_TASKS)) throw new Error("CODEX_BRIDGE_DESKTOP_TASKS must be 0 or 1");
15
+ if (values.CLAUDE_BRIDGE_PERMISSION_MODE !== undefined && !["bypass", "prompting"].includes(values.CLAUDE_BRIDGE_PERMISSION_MODE)) {
16
+ throw new Error("CLAUDE_BRIDGE_PERMISSION_MODE must be bypass or prompting; never infer it from the recipient");
17
+ }
18
+ if (values.CODEX_BRIDGE_DESKTOP_TASKS === "1") values.CODEX_BRIDGE_AUTOSTART = "0";
19
+ const args = ["mcp", "add", name];
20
+ for (const [key, value] of Object.entries(values).sort(([a], [b]) => a.localeCompare(b))) {
21
+ if (typeof value !== "string") throw new Error(`MCP environment value ${key} must be a string`);
22
+ args.push("--env", `${key}=${value}`);
23
+ }
24
+ return { args: [...args, "--", node, entry], environmentKeys: Object.keys(values).sort() };
25
+ }
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.4";
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.4";
27
27
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
28
28
 
29
29
  function errorResponse(code, message) {
@@ -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);