@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 +18 -0
- package/README.md +17 -9
- package/package.json +1 -1
- package/scripts/check-claude-bridge.mjs +2 -1
- package/scripts/install-codex-mcp.mjs +12 -15
- package/scripts/install-native-relay.mjs +35 -3
- package/src/claude-bridge.mjs +66 -19
- package/src/codex-mcp-registration.mjs +25 -0
- package/src/index.mjs +33 -11
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +78 -11
- package/src/platform.mjs +55 -1
- package/src/runtime-state.mjs +34 -0
- package/src/thread-delivery.mjs +6 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
49
|
-
console.log(
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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) =>
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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}
|
|
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
|
-
`
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
+ "
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -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(() =>
|
|
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);
|