@minhspark/codex-mcp-bridge 1.11.3 → 1.12.1
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 +76 -0
- package/README.md +100 -11
- package/package.json +7 -3
- package/scripts/install-native-relay.mjs +100 -0
- package/scripts/sync-version.mjs +5 -1
- package/src/claude-bridge.mjs +19 -16
- package/src/index.mjs +4 -2
- package/src/native-relay-companion.mjs +343 -0
- package/src/native-relay.mjs +327 -0
- package/src/peer-protocol.mjs +76 -22
- package/src/platform.mjs +20 -1
- package/src/thread-delivery.mjs +85 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [1.12.1] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Windows no longer loses the Codex Desktop native relay to a Unix-only transport.** The Claude peer endpoint
|
|
10
|
+
and the Codex Desktop companion now use local named pipes on Windows, while macOS and Linux keep their Unix
|
|
11
|
+
sockets. The same platform-aware endpoint detection is used by status checks and cleanup, so a live Windows
|
|
12
|
+
companion is not mistaken for a missing session or deleted as a stale file.
|
|
13
|
+
- **Live Claude sessions no longer disappear intermittently while their registry file is being rewritten.** Session
|
|
14
|
+
metadata is read twice and retried until stable, and peer delivery retries the short startup window before the
|
|
15
|
+
named pipe is listening. A session that is alive but still bringing up its Windows pipe remains visible and
|
|
16
|
+
deliverable.
|
|
17
|
+
- **A disconnected hand-off now releases the bridge app-server when release-after-turn is enabled.** This prevents
|
|
18
|
+
a dropped turn from leaving a writer lock behind and blocking Codex Desktop on the next open.
|
|
19
|
+
- **Windows resolves the versioned Codex Desktop binary before older install locations.** The bridge and installers
|
|
20
|
+
therefore use the current desktop executable instead of silently attaching to an obsolete copy.
|
|
21
|
+
|
|
22
|
+
## [1.12.0] - 2026-09-04
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **A thread open in Codex Desktop can now receive Claude's messages without being taken away from the app.**
|
|
27
|
+
Binding a thread with `bind_codex_thread` and then watching it in Codex Desktop was the workflow the relay
|
|
28
|
+
was built for, and it was the one case that could not work: Codex takes a per-thread writer lock when the
|
|
29
|
+
app loads a thread and holds it while the thread is open, so the relay's `thread/resume` was refused with
|
|
30
|
+
`thread <id> already has an active writer`. The only way through was to close the thread before every
|
|
31
|
+
message - which gives up the reason the thread was bound in the first place.
|
|
32
|
+
|
|
33
|
+
The fix is to stop bringing a second writer. `codex-native-relay` is a companion MCP process that Codex
|
|
34
|
+
Desktop launches itself, so it already sits inside the app's context and can ask the app's own app-server
|
|
35
|
+
to deliver the message. Nothing attaches, nothing resumes, no second app-server starts, and the desktop app
|
|
36
|
+
stays the single writer of the thread throughout. `claude-bridge` reaches it over a private mode-`0600`
|
|
37
|
+
unix socket at `~/.codex/native-relay.sock` and sends exactly `{ targetThreadId, message }`.
|
|
38
|
+
|
|
39
|
+
`codex_app.send_message_to_thread` runs against an *executor* thread, distinct from the destination and
|
|
40
|
+
validated by Codex - a synthetic UUID is rejected. A dedicated relay thread carries that role so the watched
|
|
41
|
+
thread never has to: `scripts/install-native-relay.mjs` creates it once, records it in
|
|
42
|
+
`~/.codex/native-relay.json`, and stops the app-server it borrowed so no lock is left held. Resolution is
|
|
43
|
+
`CODEX_RELAY_ID`, then that file, then an explicit error naming both - never an invented id, which Codex
|
|
44
|
+
would reject with a message that says nothing about the missing configuration behind it.
|
|
45
|
+
|
|
46
|
+
- Delivery is now a **backend choice** rather than a call, in `src/thread-delivery.mjs`. Nothing else moves:
|
|
47
|
+
the Claude peer protocol, `list_claude_sessions`, `send_to_claude_session`, `bind_codex_thread`, the routing,
|
|
48
|
+
the ping-pong limits, `codex-mcp-bridge`, `CodexAppServerClient` and the thread authorization policies are
|
|
49
|
+
unchanged, and the app-server path stays the default for every thread Codex Desktop does not own. The native
|
|
50
|
+
path is macOS-only, feature-detected on the companion socket, and switched off entirely with
|
|
51
|
+
`CODEX_BRIDGE_NATIVE_RELAY=0`.
|
|
52
|
+
|
|
53
|
+
An unreachable companion falls back to the app-server path, because an absent relay says nothing about the
|
|
54
|
+
target thread. A companion that answered with a *refusal* does not: Codex has already been asked, and a
|
|
55
|
+
second app-server would only contend for the `~/.codex` state before failing on the very writer lock the
|
|
56
|
+
native path exists to avoid.
|
|
57
|
+
|
|
58
|
+
- `claude_bridge_status` and `bind_codex_thread` report the backend in force, and carry the reason when it is
|
|
59
|
+
not the native one - a missing companion socket, an explicit `0`, and an unsupported platform are three
|
|
60
|
+
different problems that otherwise look identical from the outside.
|
|
61
|
+
|
|
62
|
+
- `native_relay_status` on the companion reports its socket, its executor thread and the dispatch method.
|
|
63
|
+
`npm run install:relay` / `npm run uninstall:relay` register and remove it.
|
|
64
|
+
|
|
65
|
+
### Security
|
|
66
|
+
|
|
67
|
+
- The relay socket is created mode `0600` inside the Codex home and swept on exit; that file mode is the whole
|
|
68
|
+
boundary, exactly as it already is for the Claude peer protocol. The companion accepts one payload shape,
|
|
69
|
+
caps a frame at 128 KiB on both halves, and refuses a destination that is its own executor thread - otherwise
|
|
70
|
+
a mistaken bind would deliver into the invisible relay thread and report success. A socket already held by a
|
|
71
|
+
live companion is never stolen: an in-use path is probed before any leftover from a killed process is swept.
|
|
72
|
+
|
|
73
|
+
### Notes
|
|
74
|
+
|
|
75
|
+
- `codex_app.send_message_to_thread` and the Codex Desktop native tools pipe are **internals with no public
|
|
76
|
+
documentation**, on the same footing as the Claude peer protocol in `src/peer-protocol.mjs`. That is why this
|
|
77
|
+
path is optional, feature-detected and fallback-safe rather than the default. If Codex changes it, the two
|
|
78
|
+
places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`, and
|
|
79
|
+
`CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release.
|
|
80
|
+
|
|
5
81
|
## [1.11.3] - 2026-09-03
|
|
6
82
|
|
|
7
83
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://m8ven.ai/mcp/buidangminh23-codex-mcp-bridge-1ke8t1)
|
|
8
8
|
|
|
9
|
-
A **two-way** bridge between Claude and Codex: Claude pushes prompts into a **live Codex thread**, and Codex messages back into a **running Claude Code session**. Each side sees the other's sessions and follows the conversation inside its own app. Runs on **macOS, Windows and Linux** (the Codex → Claude direction
|
|
9
|
+
A **two-way** bridge between Claude and Codex: Claude pushes prompts into a **live Codex thread**, and Codex messages back into a **running Claude Code session**. Each side sees the other's sessions and follows the conversation inside its own app. Runs on **macOS, Windows and Linux** (the Codex → Claude direction uses unix sockets on macOS/Linux and named pipes on Windows).
|
|
10
10
|
|
|
11
11
|
This is not `codex exec`, which starts a fresh session every time. The bridge speaks JSON-RPC to the real Codex app-server, so the thread keeps its history, its `cwd`, its model and its rollout file — and a human can watch it run in the Codex desktop app instead of reading the transcript afterwards.
|
|
12
12
|
|
|
@@ -20,16 +20,21 @@ Claude Desktop ──────┤ stdio We
|
|
|
20
20
|
└────────────────────────────────────────────────────────────────────┘
|
|
21
21
|
|
|
22
22
|
┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
|
|
23
|
-
Codex ───────────────┤ stdio
|
|
23
|
+
Codex ───────────────┤ stdio unix socket / named pipe for the Claude session ├──> Claude Code session ──> message shows in Claude Desktop
|
|
24
24
|
└────────────────────────────────────────────────────────────────────┘
|
|
25
25
|
|
|
26
26
|
Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
|
|
27
|
+
|
|
28
|
+
┌── codex-native-relay (launched by Codex Desktop, Windows/macOS) ────┐
|
|
29
|
+
claude-bridge ───────┤ named pipe / unix socket native tools ├──> the thread already open in Codex Desktop
|
|
30
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
27
31
|
```
|
|
28
32
|
|
|
29
33
|
- The app-server is a **singleton per port**. The bridge probes `http://127.0.0.1:8791/readyz`; if nothing answers it spawns a detached `codex app-server --listen ws://127.0.0.1:8791`, which keeps running after the bridge exits.
|
|
30
34
|
- Every client pointed at the same URL shares **one app-server**, so `thread/resume` with a `threadId` rejoins the running thread instead of opening a new session.
|
|
31
35
|
- The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
|
|
32
36
|
- `delegate_to_codex` is the one-call Claude → Codex hand-off: it starts the thread at the supplied `cwd`, names it, sends the prompt, stops the bridge app-server after a terminal turn, and opens `codex://threads/<id>` in Codex Desktop when enabled.
|
|
37
|
+
- The **native relay** (Windows/macOS, optional) is the third line: a thread the human is watching in Codex Desktop belongs to the app, and a second app-server cannot write to it. Instead of taking the thread away, `claude-bridge` hands the message to a companion the app itself launched, and the app delivers it. See [Codex Desktop native relay](#codex-desktop-native-relay).
|
|
33
38
|
|
|
34
39
|
## Requirements
|
|
35
40
|
|
|
@@ -222,7 +227,25 @@ launchctl kickstart -k gui/$UID/com.codex-mcp-bridge.app-server
|
|
|
222
227
|
|
|
223
228
|
`CLAUDE_BRIDGE_PEER_NAME` sets the name Claude shows for this bridge in its agent list.
|
|
224
229
|
|
|
225
|
-
### 5. macOS
|
|
230
|
+
### 5. Windows/macOS: the Codex Desktop native relay
|
|
231
|
+
|
|
232
|
+
Optional, and only worth installing if you keep the bound thread **open in Codex Desktop** while Claude messages back. See [Codex Desktop native relay](#codex-desktop-native-relay) for what it does and why.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
node scripts/install-native-relay.mjs
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
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 starts a throwaway app-server, creates one thread, and stops the app-server again so nothing is left holding a lock.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
codex mcp get codex-native-relay
|
|
242
|
+
node scripts/install-native-relay.mjs --no-bootstrap # register only; supply CODEX_RELAY_ID yourself
|
|
243
|
+
node scripts/install-native-relay.mjs --remove # unregister, and remove the relay config and thread id
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Restart Codex Desktop so it launches the companion, then call its `native_relay_status` tool — or `claude_bridge_status`, whose `delivery:` line names the backend in force. Until both the companion and the executor thread are in place, `claude-bridge` keeps using the app-server path exactly as before.
|
|
247
|
+
|
|
248
|
+
### 6. macOS only: keep the app-server alive with launchd
|
|
226
249
|
|
|
227
250
|
> ⚠️ **Do not enable the LaunchAgent while using the Codex desktop app.** The app runs its **own** stdio app-server against the **same** `~/.codex` sqlite state. Two app-servers contend even while idle — measured here: the launchd one burned ~11% CPU doing nothing and **the Codex app UI stuttered**. Keep exactly one alive; `codex_bridge_status` detects and warns about this.
|
|
228
251
|
>
|
|
@@ -240,20 +263,21 @@ tail -f ~/Library/Logs/codex-mcp-bridge/app-server.err.log # logs
|
|
|
240
263
|
node scripts/install-launch-agent.mjs --uninstall # remove
|
|
241
264
|
```
|
|
242
265
|
|
|
243
|
-
###
|
|
266
|
+
### 7. Verify the install
|
|
244
267
|
|
|
245
268
|
```bash
|
|
246
269
|
npm run check # boots the bridge, autostarts an app-server, lists threads
|
|
247
270
|
npm run check:claude # lists the live Claude Code sessions Codex can reach
|
|
248
271
|
```
|
|
249
272
|
|
|
250
|
-
From inside Claude, call the `codex_bridge_status` tool; from inside Codex, call `claude_bridge_status`. Both print the resolved binary, the endpoint and whether anything is listening.
|
|
273
|
+
From inside Claude, call the `codex_bridge_status` tool; from inside Codex, call `claude_bridge_status`. Both print the resolved binary, the endpoint and whether anything is listening. `claude_bridge_status` also prints a `delivery:` line naming the backend that would carry a relayed message, and the reason when it is not the native one.
|
|
251
274
|
|
|
252
|
-
###
|
|
275
|
+
### 8. Uninstall everything
|
|
253
276
|
|
|
254
277
|
```bash
|
|
255
278
|
claude mcp remove codex-bridge --scope user
|
|
256
279
|
node scripts/install-codex-mcp.mjs --remove
|
|
280
|
+
node scripts/install-native-relay.mjs --remove
|
|
257
281
|
node scripts/install-launch-agent.mjs --uninstall
|
|
258
282
|
```
|
|
259
283
|
|
|
@@ -334,6 +358,14 @@ There is no token in the frame — **the socket is mode `0600`, so owning the us
|
|
|
334
358
|
|
|
335
359
|
The relay has two hard limits in `src/claude-bridge.mjs`: at most **one message every 5s** and **50 per bridge run**. Two agents left talking to each other unattended still come to a stop.
|
|
336
360
|
|
|
361
|
+
## Tools — `codex-native-relay` (launched by Codex Desktop, macOS)
|
|
362
|
+
|
|
363
|
+
| Tool | What it does | Hints |
|
|
364
|
+
|---|---|---|
|
|
365
|
+
| `native_relay_status` | Reports the local socket the companion listens on, the executor thread it dispatches through, and the dispatch method in force. | read-only |
|
|
366
|
+
|
|
367
|
+
The companion carries no work of its own. Its job is the socket and the dispatch; everything a human asks for still goes through the two bridges above.
|
|
368
|
+
|
|
337
369
|
## macOS notes
|
|
338
370
|
|
|
339
371
|
### Watch a thread in the Codex desktop app
|
|
@@ -347,10 +379,58 @@ send_to_codex_thread { threadId: "01a0…", prompt: "…", openInApp: true }
|
|
|
347
379
|
|
|
348
380
|
This is how a human watches Codex work in real time instead of reading the rollout at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` after the fact.
|
|
349
381
|
|
|
382
|
+
### Codex Desktop native relay
|
|
383
|
+
|
|
384
|
+
`bind_codex_thread` relays every message Claude sends into a Codex thread. That works — until the thread is one **you are watching in Codex Desktop**, which is the case it was built for. Codex takes a per-thread writer lock when the app loads a thread and holds it for as long as the thread is open, so the app-server path, which has to `thread/resume` before it can send, is refused:
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
thread <id> already has an active writer
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Closing the thread first is not a fix. It is the opposite of the point: Codex Desktop is meant to stay the permanent owner of the thread and the surface the human is looking at.
|
|
391
|
+
|
|
392
|
+
The native relay removes the second writer rather than fighting it. A companion MCP process, **launched by Codex Desktop's own app-server**, already sits inside the app's context, so it can ask that app-server to deliver the message. Nothing attaches, nothing resumes, no second app-server starts, and the lock never changes hands:
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
Claude
|
|
396
|
+
→ claude-bridge
|
|
397
|
+
→ Windows named pipe / macOS ~/.codex/native-relay.sock (unix socket, mode 0600)
|
|
398
|
+
→ codex-native-relay (launched by Codex Desktop's app-server)
|
|
399
|
+
→ codex_app.send_message_to_thread (over that same connection)
|
|
400
|
+
→ the thread already open in Codex Desktop
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**The executor thread.** `codex_app.send_message_to_thread` runs against an executor thread, and that thread is not the destination — it is validated, so a synthetic UUID is rejected with `NATIVE_DISPATCH_FAILED`. A dedicated relay thread keeps that requirement away from the thread you are watching. It is created once by `scripts/install-native-relay.mjs` and recorded in `~/.codex/native-relay.json`:
|
|
404
|
+
|
|
405
|
+
```json
|
|
406
|
+
{ "relayThreadId": "<uuid>" }
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Never a guess: an invented executor fails inside Codex with a message that says nothing about the configuration that actually caused it. The recorded thread works as an executor even if it has never been opened in the app.
|
|
410
|
+
|
|
411
|
+
**It is a backend, not a replacement.** `claude-bridge` picks between two delivery backends and reports which one it used; the tools, the peer protocol, the routing, the rate limits and `CodexAppServerClient` are untouched. The native path is used only when all of these hold — otherwise the app-server path runs exactly as it did before:
|
|
412
|
+
|
|
413
|
+
| Condition | Otherwise |
|
|
414
|
+
|---|---|
|
|
415
|
+
| Windows or macOS, companion endpoint exists | the app-server path (`CODEX_BRIDGE_NATIVE_RELAY=1` forces the attempt anyway) |
|
|
416
|
+
| `CODEX_BRIDGE_NATIVE_RELAY` is not `0` | switched off by hand |
|
|
417
|
+
| the Windows named pipe or macOS unix socket exists | the companion is not installed, or Codex Desktop is not running |
|
|
418
|
+
|
|
419
|
+
A companion that cannot be reached falls back to the app-server path, because an absent relay says nothing about the target thread. A companion that **answered with a refusal** does not: Codex has already been asked, and a second app-server would only contend for the `~/.codex` state and then fail on the very writer lock this backend exists to avoid.
|
|
420
|
+
|
|
421
|
+
> ⚠️ `codex_app.send_message_to_thread` and the native tools pipe are **Codex Desktop internals with no public documentation**, on the same footing as the Claude peer protocol above. That is why the relay is Windows/macOS, feature-detected, optional and fallback-safe. If Codex changes it, the two places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`; `CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release. The request the companion sends is:
|
|
422
|
+
>
|
|
423
|
+
> ```json
|
|
424
|
+
> {"jsonrpc":"2.0","id":1,"method":"codex_app.send_message_to_thread",
|
|
425
|
+
> "params":{"executorThreadId":"<relay thread>","threadId":"<destination>","message":"..."}}
|
|
426
|
+
> ```
|
|
427
|
+
|
|
428
|
+
**Security.** On macOS/Linux the relay socket is mode `0600` inside `~/.codex`; Windows uses the Claude-compatible local named-pipe namespace. Anything able to open the relay can put text into a Codex thread, so the endpoint is feature-detected and the companion accepts exactly one shape, `{ targetThreadId, message }`, caps a frame at 128 KiB, and refuses a destination that is its own executor thread — otherwise a mistaken bind would deliver into the invisible relay thread and report success.
|
|
429
|
+
|
|
350
430
|
### Caveats
|
|
351
431
|
|
|
352
|
-
- The Codex desktop app runs its own app-server over stdio (`ChatGPT.app/Contents/Resources/codex … app-server`, **no** `--listen`), so nothing external can attach to it. `~/.codex/ipc/ipc.sock` is the Electron app's internal IPC, not an app-server. Threads opened there can still be driven through the bridge, but by resuming from the rollout `.jsonl` rather than attaching live.
|
|
353
|
-
- **A thread currently open in the desktop app cannot be written to** — Codex holds a per-thread writer lock (`~/.codex/thread-writer-locks/`) and returns `thread <id> already has an active writer`. That error is the guard working, not data loss. Check `status` with `list_codex_threads` first and only send when it is `idle` or `notLoaded` and not open in the app.
|
|
432
|
+
- The Codex desktop app runs its own app-server over stdio (`ChatGPT.app/Contents/Resources/codex … app-server`, **no** `--listen`), so nothing external can attach to it. `~/.codex/ipc/ipc.sock` is the Electron app's internal IPC, not an app-server. Threads opened there can still be driven through the bridge, but by resuming from the rollout `.jsonl` rather than attaching live. The [native relay](#codex-desktop-native-relay) is not an exception to this: the companion never attaches to that app-server, it is *launched by* it as one of the app's own MCP servers.
|
|
433
|
+
- **A thread currently open in the desktop app cannot be written to** through a second app-server — Codex holds a per-thread writer lock (`~/.codex/thread-writer-locks/`) and returns `thread <id> already has an active writer`. That error is the guard working, not data loss. Check `status` with `list_codex_threads` first and only send when it is `idle` or `notLoaded` and not open in the app. For the Claude → Codex relay specifically, the [native relay](#codex-desktop-native-relay) removes the second writer instead of waiting for the lock.
|
|
354
434
|
- **Bridge-created threads are named before they are opened.** The bridge calls the app-server's `thread/name/set` with the requested title, or derives `[project] first line of prompt`, then opens the exact `codex://threads/<id>` link. This gives Codex Desktop a visible session title and preserves the precise `cwd` in the thread metadata.
|
|
355
435
|
- A repo living on the NTFS partition of a dual-boot machine (`/Volumes/<label>/...`) is **read-only** under macOS. Keep a separate checkout on an APFS volume to run and edit it.
|
|
356
436
|
- `codex app-server daemon start` uses the `unix://` transport with a control socket at `~/.codex/app-server-control/app-server-control.sock`. The bridge does **not** use that path (different framing, no public API) — it always talks over `ws://`.
|
|
@@ -374,7 +454,9 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
|
|
|
374
454
|
|
|
375
455
|
**The bridge disappears from Claude after sending into a busy thread.** Fixed in 1.6.0. A rejected `turn/start` — which is exactly what a thread locked by the desktop app produces — also rejected an internal promise nothing was awaiting. Node treats that as an unhandled rejection and, by default, exits the process, so the MCP server died while the tool handler was still formatting a tidy error message for a client that no longer had a server. Pinned by a test that runs the failure in a real child process and asserts it exits 0.
|
|
376
456
|
|
|
377
|
-
**The Codex app says a thread is "open in another application".** That is the per-thread writer lock, and the other application is usually this bridge: the shared app-server takes the lock when it loads a thread and keeps it until it exits, so the desktop app cannot write to the same thread. `delegate_to_codex` releases the bridge server before opening the final desktop link when `releaseAfterTurn` is enabled. For an existing thread, pass `releaseAfterTurn: true` or call `stop_codex_app_server` once the hand-off is done — the bridge starts a new app-server the next time it needs one. A thread held by a *different* Codex window is the app's own lock; close it there.
|
|
457
|
+
**The Codex app says a thread is "open in another application".** That is the per-thread writer lock, and the other application is usually this bridge: the shared app-server takes the lock when it loads a thread and keeps it until it exits, so the desktop app cannot write to the same thread. `delegate_to_codex` releases the bridge server before opening the final desktop link when `releaseAfterTurn` is enabled. For an existing thread, pass `releaseAfterTurn: true` or call `stop_codex_app_server` once the hand-off is done — the bridge starts a new app-server the next time it needs one. A thread held by a *different* Codex window is the app's own lock; close it there. If what you want is for Codex Desktop to **keep** the thread while Claude messages into it, that is what the [native relay](#codex-desktop-native-relay) is for — it never asks for the lock.
|
|
458
|
+
|
|
459
|
+
**`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.
|
|
378
460
|
|
|
379
461
|
**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.
|
|
380
462
|
|
|
@@ -411,6 +493,12 @@ The bridge reads these from the environment its MCP client hands it — there is
|
|
|
411
493
|
| `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
|
|
412
494
|
| `CODEX_BRIDGE_OPEN_IN_APP` | `1` on Windows, `0` elsewhere | Open delegated or sent threads through the `codex://threads/<id>` desktop link. |
|
|
413
495
|
| `CODEX_BRIDGE_RELEASE_AFTER_TURN` | `1` on Windows, `0` elsewhere | Stop the shared bridge app-server after a terminal turn so Codex Desktop can write the handed-off thread. |
|
|
496
|
+
| `CODEX_BRIDGE_NATIVE_RELAY` | `auto` | Delivery backend for relayed Claude messages. `auto` uses the Codex Desktop native relay on Windows/macOS when its companion endpoint exists; `0` never does; `1` attempts it on any platform. |
|
|
497
|
+
| `CODEX_RELAY_ID` | from `~/.codex/native-relay.json` | Executor thread for `codex_app.send_message_to_thread`. Not the destination — see [Codex Desktop native relay](#codex-desktop-native-relay). |
|
|
498
|
+
| `CODEX_HOME` | `~/.codex` | Where `native-relay.json` lives; POSIX relay sockets also live here, while Windows uses a named pipe. |
|
|
499
|
+
| `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. |
|
|
500
|
+
| `CODEX_NATIVE_RELAY_METHOD` | `codex_app.send_message_to_thread` | The undocumented Codex Desktop method the companion dispatches through; override it if Codex renames it. |
|
|
501
|
+
| `CODEX_NATIVE_RELAY_NAME` | `codex-native-relay` | The MCP server name `scripts/install-native-relay.mjs` registers with Codex. |
|
|
414
502
|
| `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
|
|
415
503
|
| `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
|
|
416
504
|
| `CLAUDE_DESKTOP_CONFIG` | auto-detected | Override the config path used by `install-claude-desktop.mjs`. |
|
|
@@ -448,15 +536,16 @@ Runs the whole suite with `node --test`. It needs no Codex install, no login and
|
|
|
448
536
|
|
|
449
537
|
| File | Covers |
|
|
450
538
|
|---|---|
|
|
451
|
-
| `test/tool-contract.test.mjs` |
|
|
539
|
+
| `test/tool-contract.test.mjs` | all three servers boot over stdio and every tool declares a title, a description, per-parameter descriptions and complete annotation hints |
|
|
452
540
|
| `test/server-requests.test.mjs` | all 10 app-server requests get a reply in the shape their schema declares — the regression test for "the turn pauses itself" |
|
|
453
541
|
| `test/reconnect.test.mjs` | reconnect after a dropped socket, no leaked pending requests or listeners, an interrupted turn ending promptly, a refused first handshake being retried |
|
|
454
542
|
| `test/turn.test.mjs` | the turn state machine: buffered notifications, terminal statuses, timeout, disconnect, retryable vs fatal errors, and that a failed `turn/start` cannot kill the process |
|
|
455
543
|
| `test/peer-protocol.test.mjs` | frame round-trips, the session registry, transcript scanning, and a live peer endpoint over a real unix socket |
|
|
456
544
|
| `test/platform.test.mjs` | binary resolution, the PATH handed to child processes, per-OS config paths and cwd remapping |
|
|
545
|
+
| `test/native-relay.test.mjs` | the Codex Desktop relay: executor thread resolution, feature detection, Windows named-pipe and POSIX socket round trips, backend selection and fallback, and the companion answering a real MCP client that plays Codex Desktop |
|
|
457
546
|
| `test/repo-hygiene.test.mjs` | no environment file or build output is ever tracked, versions do not drift, documentation stays in English |
|
|
458
547
|
|
|
459
|
-
GitHub Actions runs the same command on every push and pull request, across Node 22 and 24 on Linux, macOS and Windows (`.github/workflows/ci.yml`). The Codex → Claude direction
|
|
548
|
+
GitHub Actions runs the same command on every push and pull request, across Node 22 and 24 on Linux, macOS and Windows (`.github/workflows/ci.yml`). The Codex → Claude direction uses unix sockets on macOS/Linux and named pipes on Windows; the native relay has matching platform-specific coverage.
|
|
460
549
|
|
|
461
550
|
Two checks need a real Codex and are not part of `npm test`:
|
|
462
551
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minhspark/codex-mcp-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
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",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"codex-mcp-bridge": "src/index.mjs",
|
|
26
26
|
"claude-mcp-bridge": "src/claude-bridge.mjs",
|
|
27
27
|
"codex-mcp-bridge-install": "scripts/install-claude-desktop.mjs",
|
|
28
|
-
"claude-mcp-bridge-install": "scripts/install-codex-mcp.mjs"
|
|
28
|
+
"claude-mcp-bridge-install": "scripts/install-codex-mcp.mjs",
|
|
29
|
+
"codex-native-relay": "src/native-relay-companion.mjs",
|
|
30
|
+
"codex-native-relay-install": "scripts/install-native-relay.mjs"
|
|
29
31
|
},
|
|
30
32
|
"files": [
|
|
31
33
|
"src",
|
|
@@ -48,9 +50,11 @@
|
|
|
48
50
|
"install:desktop": "node scripts/install-claude-desktop.mjs",
|
|
49
51
|
"install:codex": "node scripts/install-codex-mcp.mjs",
|
|
50
52
|
"uninstall:codex": "node scripts/install-codex-mcp.mjs --remove",
|
|
53
|
+
"install:relay": "node scripts/install-native-relay.mjs",
|
|
54
|
+
"uninstall:relay": "node scripts/install-native-relay.mjs --remove",
|
|
51
55
|
"install:agent": "node scripts/install-launch-agent.mjs",
|
|
52
56
|
"uninstall:agent": "node scripts/install-launch-agent.mjs --uninstall",
|
|
53
|
-
"version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs"
|
|
57
|
+
"version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs"
|
|
54
58
|
},
|
|
55
59
|
"engines": {
|
|
56
60
|
"node": ">=22"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { CodexAppServerClient } from "../src/app-server-client.mjs";
|
|
8
|
+
import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
|
|
9
|
+
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Installs the Codex Desktop native relay: registers the companion as an MCP
|
|
13
|
+
* server so Codex Desktop launches it, and bootstraps the executor thread the
|
|
14
|
+
* native dispatch needs.
|
|
15
|
+
*
|
|
16
|
+
* The bootstrap is the one step that has to take a writer lock, and it takes it
|
|
17
|
+
* on a thread that belongs to nobody: a dedicated relay thread, created through
|
|
18
|
+
* an ordinary app-server which is then stopped so the lock is released. After
|
|
19
|
+
* this runs, no part of the relay ever attaches a thread again.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const entry = path.join(root, "src", "native-relay-companion.mjs");
|
|
24
|
+
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
|
25
|
+
const remove = process.argv.includes("--remove");
|
|
26
|
+
const skipBootstrap = process.argv.includes("--no-bootstrap");
|
|
27
|
+
|
|
28
|
+
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
29
|
+
const run = (args) => execFileSync(codexBin, args, { env: spawnEnv(), stdio: "pipe" }).toString().trim();
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(entry)) throw new Error(`companion entry point missing: ${entry}`);
|
|
32
|
+
if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
|
|
33
|
+
throw new Error(`codex binary not found (resolved to "${codexBin}"). Set CODEX_EXE to its absolute path.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (remove) {
|
|
37
|
+
try {
|
|
38
|
+
console.log(run(["mcp", "remove", serverName]) || `removed ${serverName}`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.log(`${serverName} was not registered (${err.message.trim().split("\n").at(-1)})`);
|
|
41
|
+
}
|
|
42
|
+
for (const leftover of [relayConfigPath(), ...(IS_WINDOWS ? [] : [relaySocketPath()])]) {
|
|
43
|
+
if (fs.existsSync(leftover)) {
|
|
44
|
+
fs.rmSync(leftover, { force: true });
|
|
45
|
+
console.log(`removed ${leftover}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Not a hard failure on an unsupported platform: the companion is harmless and
|
|
53
|
+
* refusing to register it would make the install order depend on which machine
|
|
54
|
+
* runs it.
|
|
55
|
+
*/
|
|
56
|
+
if (!IS_MACOS && !IS_WINDOWS) {
|
|
57
|
+
console.log(`note: the native relay is unavailable on ${PLATFORM_LABEL}.`);
|
|
58
|
+
console.log("claude-bridge will keep using the app-server path here.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
run(["mcp", "remove", serverName]);
|
|
63
|
+
} catch {
|
|
64
|
+
// not registered yet
|
|
65
|
+
}
|
|
66
|
+
run(["mcp", "add", serverName, "--", process.execPath, entry]);
|
|
67
|
+
|
|
68
|
+
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
69
|
+
console.log(`registered MCP server "${serverName}" with Codex:`);
|
|
70
|
+
console.log(run(["mcp", "get", serverName]));
|
|
71
|
+
|
|
72
|
+
const existing = readRelayConfig()?.relayThreadId;
|
|
73
|
+
if (existing) {
|
|
74
|
+
console.log(`\nrelay thread already bootstrapped: ${existing} (${relayConfigPath()})`);
|
|
75
|
+
} else if (skipBootstrap) {
|
|
76
|
+
console.log(`\nskipped the relay thread bootstrap; set CODEX_RELAY_ID or rerun without --no-bootstrap.`);
|
|
77
|
+
} else {
|
|
78
|
+
const client = new CodexAppServerClient({
|
|
79
|
+
clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: "1.12.1" },
|
|
80
|
+
log: (msg) => console.log(` ${msg}`),
|
|
81
|
+
});
|
|
82
|
+
console.log("\nbootstrapping the relay executor thread...");
|
|
83
|
+
try {
|
|
84
|
+
const { threadId, configPath } = await bootstrapRelayThread(client, { cwd: homeDir() });
|
|
85
|
+
console.log(`relay thread: ${threadId}`);
|
|
86
|
+
console.log(`written to: ${configPath}`);
|
|
87
|
+
} finally {
|
|
88
|
+
/**
|
|
89
|
+
* The bootstrap thread must not stay locked by this app-server: leaving it
|
|
90
|
+
* held would reintroduce, for the relay's own thread, exactly the writer
|
|
91
|
+
* conflict the relay exists to remove.
|
|
92
|
+
*/
|
|
93
|
+
const stopped = await client.stopServer();
|
|
94
|
+
console.log(stopped.stopped ? "released the bootstrap app-server" : `app-server not stopped: ${stopped.reason}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`\nrelay socket: ${relaySocketPath()}`);
|
|
99
|
+
console.log("Restart Codex Desktop so it launches the companion, then check with native_relay_status.");
|
|
100
|
+
console.log("remove: node scripts/install-native-relay.mjs --remove");
|
package/scripts/sync-version.mjs
CHANGED
|
@@ -15,7 +15,11 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
* claude-bridge sat at 1.3.0 while the package shipped 1.10.0.
|
|
16
16
|
*/
|
|
17
17
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
-
const entries = [
|
|
18
|
+
const entries = [
|
|
19
|
+
path.join("src", "index.mjs"),
|
|
20
|
+
path.join("src", "claude-bridge.mjs"),
|
|
21
|
+
path.join("src", "native-relay-companion.mjs"),
|
|
22
|
+
];
|
|
19
23
|
const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
20
24
|
|
|
21
25
|
for (const entry of entries) {
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import { z } from "zod";
|
|
|
6
6
|
import { CodexAppServerClient } from "./app-server-client.mjs";
|
|
7
7
|
import { PLATFORM_LABEL } from "./platform.mjs";
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { createThreadDelivery } from "./thread-delivery.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.
|
|
11
|
+
const VERSION = "1.12.1";
|
|
12
12
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
13
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
14
|
|
|
@@ -27,6 +27,14 @@ const codex = new CodexAppServerClient({
|
|
|
27
27
|
log,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Delivery is a backend choice, not a call: a thread the human is watching in
|
|
32
|
+
* Codex Desktop is written through the desktop's own app-server, and every
|
|
33
|
+
* other thread through the shared one. `claude-bridge` never picks between
|
|
34
|
+
* them - see `thread-delivery.mjs`.
|
|
35
|
+
*/
|
|
36
|
+
const delivery = createThreadDelivery({ codex, log });
|
|
37
|
+
|
|
30
38
|
const forwarding = {
|
|
31
39
|
threadId: process.env.CODEX_THREAD_ID ?? null,
|
|
32
40
|
lastAt: 0,
|
|
@@ -64,18 +72,11 @@ async function forwardToCodexThread(record) {
|
|
|
64
72
|
forwarding.lastAt = now;
|
|
65
73
|
forwarding.count += 1;
|
|
66
74
|
try {
|
|
67
|
-
await
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
type: "text",
|
|
73
|
-
text: `[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`,
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
timeoutMs: 240000,
|
|
77
|
-
});
|
|
78
|
-
log(`forwarded a Claude message into thread ${forwarding.threadId}`);
|
|
75
|
+
const { backend } = await delivery.deliver(
|
|
76
|
+
forwarding.threadId,
|
|
77
|
+
`[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`,
|
|
78
|
+
);
|
|
79
|
+
log(`forwarded a Claude message into thread ${forwarding.threadId} via ${backend}`);
|
|
79
80
|
} catch (err) {
|
|
80
81
|
log(`forward failed: ${err.message}`);
|
|
81
82
|
}
|
|
@@ -245,7 +246,8 @@ server.registerTool(
|
|
|
245
246
|
title: "Relay Claude messages into a Codex thread",
|
|
246
247
|
description:
|
|
247
248
|
"Bind a Codex thread so every message Claude pushes to this bridge is relayed into that thread, where it " +
|
|
248
|
-
"shows up in the Codex desktop app.
|
|
249
|
+
"shows up in the Codex desktop app. On macOS a thread already open in Codex Desktop is written through " +
|
|
250
|
+
"the desktop's own app-server, so it keeps its writer lock and stays open. Pass an empty threadId to stop.",
|
|
249
251
|
inputSchema: {
|
|
250
252
|
threadId: z.string().describe("Codex thread id, or an empty string to unbind"),
|
|
251
253
|
},
|
|
@@ -264,7 +266,7 @@ server.registerTool(
|
|
|
264
266
|
peer.rename(name);
|
|
265
267
|
return textResult(
|
|
266
268
|
trimmed
|
|
267
|
-
? `Relaying Claude messages into Codex thread ${trimmed} (max ${FORWARD_MAX_PER_SESSION} per bridge run, at most one every ${FORWARD_MIN_INTERVAL_MS / 1000}s).\nClaude now sees this bridge as "${name}".`
|
|
269
|
+
? `Relaying Claude messages into Codex thread ${trimmed} (max ${FORWARD_MAX_PER_SESSION} per bridge run, at most one every ${FORWARD_MIN_INTERVAL_MS / 1000}s).\ndelivery: ${delivery.describe()}\nClaude now sees this bridge as "${name}".`
|
|
268
270
|
: `Relay disabled. Messages stay in the inbox. Claude sees this bridge as "${name}".`,
|
|
269
271
|
);
|
|
270
272
|
},
|
|
@@ -296,6 +298,7 @@ server.registerTool(
|
|
|
296
298
|
`peer socket: ${peer.socketPath}`,
|
|
297
299
|
`live sessions: ${sessions.length}`,
|
|
298
300
|
`relay thread: ${forwarding.threadId ?? "(none - use bind_codex_thread)"}`,
|
|
301
|
+
`delivery: ${delivery.describe()}`,
|
|
299
302
|
`inbox: ${peer.inbox.length} pending message(s)`,
|
|
300
303
|
];
|
|
301
304
|
return textResult(lines.join("\n"));
|
package/src/index.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { runTurn } from "./turn.mjs";
|
|
22
22
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
23
23
|
|
|
24
|
-
const VERSION = "1.
|
|
24
|
+
const VERSION = "1.12.1";
|
|
25
25
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -38,6 +38,7 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
|
|
|
38
38
|
? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
|
|
39
39
|
: IS_WINDOWS;
|
|
40
40
|
const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
41
|
+
const RELEASE_TURN_STATUSES = new Set([...TERMINAL_TURN_STATUSES, "disconnected"]);
|
|
41
42
|
const security = new BridgeSecurityPolicy();
|
|
42
43
|
|
|
43
44
|
const client = new CodexAppServerClient({
|
|
@@ -133,8 +134,9 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
|
|
|
133
134
|
const notes = [];
|
|
134
135
|
let canOpenAfterRelease = true;
|
|
135
136
|
const terminal = TERMINAL_TURN_STATUSES.has(result.status);
|
|
137
|
+
const releasable = RELEASE_TURN_STATUSES.has(result.status);
|
|
136
138
|
|
|
137
|
-
if (releaseAfterTurn &&
|
|
139
|
+
if (releaseAfterTurn && releasable) {
|
|
138
140
|
try {
|
|
139
141
|
const released = await client.stopServer();
|
|
140
142
|
if (released.stopped) {
|