@minhspark/codex-mcp-bridge 1.12.0 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
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.2] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - **Use the native protocol measured by Seb in [#23](https://github.com/buidangminh23/codex-mcp-bridge/issues/23#issuecomment-5547163688).** The companion uses the native pipe inherited from Codex Desktop, separately from MCP stdio. Dispatch is `tools/call` with distinct executor and destination thread IDs, fresh call/turn IDs, and a UInt32LE length prefix. A response must explicitly report `success: true`. The companion recovers when a configured native pipe appears after startup.
10
+ - **Do not kill other delegations when one finishes.** Automatic handoff unsubscribes only the completed thread. It confirms unload before opening Desktop, reports pending idle unload honestly, and fails safely on servers without `thread/unsubscribe`. Explicit shared-server shutdown remains a separate destructive tool.
11
+ - **Keep RPC and turn state consistent across failures.** Failed sends settle their pending promise, concurrent calls wait for initialization, and disconnects clean up listeners and requests. Same-thread transactions are serialized while other threads remain independent; timed-out or disconnected turns require state reconciliation before another turn starts.
12
+ - **Prevent duplicate or misattributed messages.** A native request with an unconfirmed outcome is never retried through another backend, and invalid messages cannot bypass validation through fallback. Claude requests are queued per destination; unresolved earlier replies block new waited sends instead of assigning a late reply to the wrong request. Fire-and-forget and late responses remain accessible through the inbox.
13
+ - **Preserve Unicode across fragmented pipe traffic.** Both relay protocols and Claude peer messages retain complete UTF-8 characters when stream chunks split a character.
14
+ - **Validate the workspace actually used.** Relative input becomes absolute, regular files are refused as directories, and a created thread must report the requested workspace within allowed roots. A path map cannot disguise an unexpected server-created cwd.
15
+ - **List loaded threads correctly.** The bridge reads the IDs returned by `thread/loaded/list`, applies workspace/title filters, and follows pagination to find matching threads.
16
+ - **Keep diagnostics and installer settings truthful.** The Desktop installer honors both enabling and revoking `CODEX_BRIDGE_AUTO_APPROVE_ACK`. Check commands fail on MCP tool errors, and the live continuity smoke test requires two completed turns to preserve a unique codeword.
17
+
18
+ ### Tests
19
+
20
+ - Added regressions for connection and turn lifecycle, authorization, concurrent handoffs, late replies, fragmented/malformed native frames, missing/late native pipes, fallback boundaries, configuration preservation, and diagnostic exit status. Native wire tests run a real MCP child against isolated mock pipes without sending messages to user sessions.
21
+
22
+ ## [1.12.1] - 2026-09-05
23
+
24
+ ### Fixed
25
+
26
+ - **Windows no longer loses the Codex Desktop native relay to a Unix-only transport.** The Claude peer endpoint
27
+ and the Codex Desktop companion now use local named pipes on Windows, while macOS and Linux keep their Unix
28
+ sockets. The same platform-aware endpoint detection is used by status checks and cleanup, so a live Windows
29
+ companion is not mistaken for a missing session or deleted as a stale file.
30
+ - **Live Claude sessions no longer disappear intermittently while their registry file is being rewritten.** Session
31
+ metadata is read twice and retried until stable, and peer delivery retries the short startup window before the
32
+ named pipe is listening. A session that is alive but still bringing up its Windows pipe remains visible and
33
+ deliverable.
34
+ - **A disconnected hand-off now releases the bridge app-server when release-after-turn is enabled.** This prevents
35
+ a dropped turn from leaving a writer lock behind and blocking Codex Desktop on the next open.
36
+ - **Windows resolves the versioned Codex Desktop binary before older install locations.** The bridge and installers
37
+ therefore use the current desktop executable instead of silently attaching to an obsolete copy.
38
+
5
39
  ## [1.12.0] - 2026-09-04
6
40
 
7
41
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![license](https://img.shields.io/npm/l/@minhspark/codex-mcp-bridge)](LICENSE)
7
7
  [![M8ven Verified](https://m8ven.ai/badge/mcp/buidangminh23-codex-mcp-bridge-1ke8t1?variant=verified)](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 needs unix sockets, so macOS/Linux only).
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,21 +20,21 @@ Claude Desktop ──────┤ stdio We
20
20
  └────────────────────────────────────────────────────────────────────┘
21
21
 
22
22
  ┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
23
- Codex ───────────────┤ stdio unix socket /tmp/cc-socks/<pid>.sock ├──> Claude Code session ──> message shows in Claude Desktop
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
27
 
28
- ┌──── codex-native-relay (launched by Codex Desktop, macOS) ──────────┐
29
- claude-bridge ───────┤ unix socket ~/.codex/native-relay.sock native tools ├──> the thread already open in Codex Desktop
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
30
  └────────────────────────────────────────────────────────────────────┘
31
31
  ```
32
32
 
33
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.
34
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.
35
35
  - The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
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** (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-macos).
36
+ - `delegate_to_codex` starts the thread at the supplied `cwd`, names it, sends the prompt, and unsubscribes that thread after a terminal turn. It opens `codex://threads/<id>` only after unload is confirmed; other threads on the shared app-server keep running.
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).
38
38
 
39
39
  ## Requirements
40
40
 
@@ -227,20 +227,20 @@ launchctl kickstart -k gui/$UID/com.codex-mcp-bridge.app-server
227
227
 
228
228
  `CLAUDE_BRIDGE_PEER_NAME` sets the name Claude shows for this bridge in its agent list.
229
229
 
230
- ### 5. macOS only: the Codex Desktop native relay
230
+ ### 5. Windows/macOS: the Codex Desktop native relay
231
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-macos) for what it does and why.
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
233
 
234
234
  ```bash
235
235
  node scripts/install-native-relay.mjs
236
236
  ```
237
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.
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 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.
239
239
 
240
240
  ```bash
241
241
  codex mcp get codex-native-relay
242
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 delete the socket and the thread id
243
+ node scripts/install-native-relay.mjs --remove # unregister, and remove the relay config and thread id
244
244
  ```
245
245
 
246
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.
@@ -308,6 +308,10 @@ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env
308
308
 
309
309
  `delegate_to_codex` accepts `cwd`, `prompt`, an optional `name`, `timeoutSec` (default 240), `model`, `effort`, `openInApp`, and `releaseAfterTurn`. `send_to_codex_thread` accepts the same hand-off controls plus an existing `threadId`. On Windows, the installer defaults `openInApp` and `releaseAfterTurn` to `1`; explicit tool arguments override them. A timeout does **not** cancel the turn: the bridge returns what it collected plus the `turnId`; keep reading with `read_codex_thread` or stop it with `interrupt_codex_turn`.
310
310
 
311
+ `releaseAfterTurn` uses `thread/unsubscribe`, never an automatic process stop. The server may keep a thread loaded during its idle grace period or while another client subscribes. The bridge reports that state and defers Desktop opening rather than claiming the writer lock is released. Older servers without `thread/unsubscribe` return an explicit release failure. `stop_codex_app_server` remains a separate, destructive operation that affects every client on that endpoint.
312
+
313
+ Concurrent sends to the same Codex thread are serialized; different threads can run in parallel. After a timeout or disconnect, the bridge checks unresolved turn state before allowing another turn. `loadedOnly` resolves the server's thread IDs and searches subsequent pages before applying workspace and title filters.
314
+
311
315
  For the normal Claude → Codex workflow, Claude should call `delegate_to_codex` with the exact project directory in `cwd`. The response always includes the Codex `threadId`, visible session `name`, exact `cwd`, rollout path, and the desktop deep link or the reason it could not be opened. The bridge sets the protocol-supported `thread/name/set` before the first turn, so the session is not an unnamed entry in Recents.
312
316
 
313
317
  Thread operations are checked before the bridge attaches, and `CODEX_BRIDGE_THREAD_POLICY` decides what counts as permission:
@@ -334,6 +338,8 @@ Under `owned`, a thread a human opened is **unreachable rather than merely restr
334
338
 
335
339
  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.
336
340
 
341
+ Waited sends to the same Claude session run in order. If an earlier send timed out or used `waitSec: 0` and its reply has not arrived, a new waited send returns `PEER_REPLY_PENDING` before delivering another message. Wait for the earlier reply and inspect `read_claude_inbox`, or use `waitSec: 0` if another asynchronous message is intended. Different destination sessions remain independent.
342
+
337
343
  ### How each side sees the other
338
344
 
339
345
  - **Claude sees Codex:** `claude-bridge` registers itself as a *peer session* under `~/.claude/sessions/`. Claude lists it with `ListAgents` and messages it with `SendMessage` — not hidden, not an invisible background process. The default name is `codex-<pid>`; after `bind_codex_thread` it renames itself to `codex-<first 8 chars of threadId>`, which is what makes several bridges distinguishable (Codex starts **one bridge per session**, so a few peers usually advertise at once).
@@ -379,7 +385,7 @@ send_to_codex_thread { threadId: "01a0…", prompt: "…", openInApp: true }
379
385
 
380
386
  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.
381
387
 
382
- ### Codex Desktop native relay (macOS)
388
+ ### Codex Desktop native relay
383
389
 
384
390
  `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
391
 
@@ -394,7 +400,7 @@ The native relay removes the second writer rather than fighting it. A companion
394
400
  ```
395
401
  Claude
396
402
  → claude-bridge
397
- → ~/.codex/native-relay.sock (unix socket, mode 0600)
403
+ Windows named pipe / macOS ~/.codex/native-relay.sock (unix socket, mode 0600)
398
404
  → codex-native-relay (launched by Codex Desktop's app-server)
399
405
  → codex_app.send_message_to_thread (over that same connection)
400
406
  → the thread already open in Codex Desktop
@@ -412,31 +418,39 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
412
418
 
413
419
  | Condition | Otherwise |
414
420
  |---|---|
415
- | macOS | the app-server path (`CODEX_BRIDGE_NATIVE_RELAY=1` forces the attempt anyway) |
421
+ | Windows or macOS, companion endpoint exists | the app-server path (`CODEX_BRIDGE_NATIVE_RELAY=1` forces the attempt anyway) |
416
422
  | `CODEX_BRIDGE_NATIVE_RELAY` is not `0` | switched off by hand |
417
- | `~/.codex/native-relay.sock` exists and is a socket | the companion is not installed, or Codex Desktop is not running |
423
+ | the Windows named pipe or macOS unix socket exists | the companion is not installed, or Codex Desktop is not running |
418
424
 
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.
425
+ A companion that cannot be reached before sending falls back to the app-server path. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
420
426
 
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 macOS-only, 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:
427
+ Codex Desktop supplies `CODEX_APP_TOOLS_PIPE_PATH` to its companion. The companion keeps that native connection separate from MCP stdio. Without the inherited native pipe it does not advertise a working relay endpoint; if a configured pipe is late during startup, it retries in the background.
428
+
429
+ > ⚠️ `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
430
  >
423
431
  > ```json
424
- > {"jsonrpc":"2.0","id":1,"method":"codex_app.send_message_to_thread",
425
- > "params":{"executorThreadId":"<relay thread>","threadId":"<destination>","message":"..."}}
432
+ > {"jsonrpc":"2.0","id":1,"method":"tools/call",
433
+ > "params":{"arguments":{"threadId":"<destination>","prompt":"..."},
434
+ > "callId":"<unique call id>","namespace":"codex_app","threadId":"<relay thread>",
435
+ > "tool":"send_message_to_thread","turnId":"<unique turn id>"}}
426
436
  > ```
427
437
 
428
- **Security.** The socket is mode `0600` inside `~/.codex`, and that file mode is the entire boundary — the same one the Claude peer protocol relies on. Anything able to open it can put text into a Codex thread, so it is created private and swept on exit. 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.
438
+ The native pipe uses a 4-byte UInt32LE payload length followed by UTF-8 JSON-RPC, and delivery requires `response.result.success === true`. `callId` and `turnId` are fresh for each dispatch. The local bridge-to-companion protocol remains NDJSON. Both transports preserve Unicode across arbitrary stream chunk boundaries. This wire format follows [Seb's measured prototype in issue #23](https://github.com/buidangminh23/codex-mcp-bridge/issues/23#issuecomment-5547163688).
439
+
440
+ **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
441
 
430
442
  ### Caveats
431
443
 
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-macos) 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-macos) removes the second writer instead of waiting for the lock.
444
+ - 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.
445
+ - **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.
434
446
  - **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.
435
447
  - 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.
436
448
  - `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://`.
437
449
 
438
450
  ## Troubleshooting
439
451
 
452
+ **A turn fails with "model requires a newer version of Codex" after upgrading Desktop.** An app-server already listening at `CODEX_APP_SERVER_URL` keeps running its original executable. Resolving a newer `CODEX_EXE` does not replace that process. Check the listening process and its executable version, then restart that server with the current Codex binary when its active work has finished. An isolated server on another local port can verify the upgrade without interrupting shared work.
453
+
440
454
  **`readyz` never returns 200 after restarting the app-server.** The log says `failed to initialize sqlite state runtime under ~/.codex`. Cause: an older app-server is still alive and holding the sqlite state of `~/.codex` — only **one** process may hold it. Hard kills (`pkill -9`) or repeated `launchctl kickstart -k` leave zombies that `pkill -f "app-server --listen ws://…"` misses, because the process name is the vendored binary path.
441
455
 
442
456
  ```bash
@@ -454,9 +468,9 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
454
468
 
455
469
  **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.
456
470
 
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-macos) is for it never asks for the lock.
471
+ **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.
458
472
 
459
- **`claude_bridge_status` says the delivery backend is `app-server` on a Mac with the relay installed.** The `delivery:` line carries the reason, and there are only three. *"no companion socket at …"* Codex Desktop has not launched the companion: restart the app after `install-native-relay.mjs`, and check `codex mcp get codex-native-relay`. *"disabled by CODEX_BRIDGE_NATIVE_RELAY=0"* it was switched off in the MCP server's `env`. *"macOS-only"* the bridge is not running where the Codex Desktop app is; the app-server path is the correct answer 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.
473
+ **`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.
460
474
 
461
475
  **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.
462
476
 
@@ -492,12 +506,12 @@ The bridge reads these from the environment its MCP client hands it — there is
492
506
  | `CODEX_BRIDGE_MODEL` | from `~/.codex/config.toml` | Default model for threads and turns the bridge creates, e.g. `gpt-5.6-luna`. |
493
507
  | `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
494
508
  | `CODEX_BRIDGE_OPEN_IN_APP` | `1` on Windows, `0` elsewhere | Open delegated or sent threads through the `codex://threads/<id>` desktop link. |
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 macOS when the companion socket 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-macos). |
498
- | `CODEX_HOME` | `~/.codex` | Where the relay socket and `native-relay.json` live. |
499
- | `CODEX_NATIVE_RELAY_SOCKET` | `$CODEX_HOME/native-relay.sock` | Override the companion's socket path 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. |
509
+ | `CODEX_BRIDGE_RELEASE_AFTER_TURN` | `1` on Windows, `0` elsewhere | Unsubscribe the completed thread without stopping other work; defer Desktop opening until unload is confirmed. |
510
+ | `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. |
511
+ | `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). |
512
+ | `CODEX_HOME` | `~/.codex` | Where `native-relay.json` lives; POSIX relay sockets also live here, while Windows uses a named pipe. |
513
+ | `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. |
514
+ | `CODEX_NATIVE_RELAY_METHOD` | `tools/call` | The undocumented Codex Desktop JSON-RPC method the companion dispatches through; override only for a verified protocol change. |
501
515
  | `CODEX_NATIVE_RELAY_NAME` | `codex-native-relay` | The MCP server name `scripts/install-native-relay.mjs` registers with Codex. |
502
516
  | `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
503
517
  | `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
@@ -539,13 +553,15 @@ Runs the whole suite with `node --test`. It needs no Codex install, no login and
539
553
  | `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 |
540
554
  | `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" |
541
555
  | `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 |
556
+ | `test/app-server-lifecycle.test.mjs` | concurrent initialization, failed sends, per-thread serialization and unsubscribe, and reconciliation of turns after reconnect |
557
+ | `test/bridge-integration.test.mjs` | real MCP children against isolated app-servers: workspace authorization, loaded-thread pagination, diagnostic exit status, and concurrent handoffs |
542
558
  | `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 |
543
559
  | `test/peer-protocol.test.mjs` | frame round-trips, the session registry, transcript scanning, and a live peer endpoint over a real unix socket |
544
560
  | `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, socket round trips over a real unix socket, reclaiming a socket a killed companion left behind, backend selection and when it may fall back, and the companion answering a real MCP client that plays Codex Desktop |
561
+ | `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 |
546
562
  | `test/repo-hygiene.test.mjs` | no environment file or build output is ever tracked, versions do not drift, documentation stays in English |
547
563
 
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 and the native relay both need unix sockets, so those tests skip on Windows; the rest of the suite runs there like anywhere else.
564
+ 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.
549
565
 
550
566
  Two checks need a real Codex and are not part of `npm test`:
551
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
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",
@@ -54,7 +54,7 @@
54
54
  "uninstall:relay": "node scripts/install-native-relay.mjs --remove",
55
55
  "install:agent": "node scripts/install-launch-agent.mjs",
56
56
  "uninstall:agent": "node scripts/install-launch-agent.mjs --uninstall",
57
- "version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs"
57
+ "version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs scripts/install-native-relay.mjs"
58
58
  },
59
59
  "engines": {
60
60
  "node": ">=22"
@@ -10,28 +10,32 @@ const transport = new StdioClientTransport({
10
10
  stderr: "inherit",
11
11
  });
12
12
  const client = new Client({ name: "claude-bridge-check", version: "1.0.0" });
13
- await client.connect(transport);
13
+ try {
14
+ await client.connect(transport);
14
15
 
15
- const tools = await client.listTools();
16
- console.log("tools:", tools.tools.map((t) => t.name).join(", "));
16
+ const tools = await client.listTools();
17
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
17
18
 
18
- const status = await client.callTool({ name: "claude_bridge_status", arguments: {} });
19
- console.log("\n--- claude_bridge_status ---\n" + status.content[0].text);
19
+ const status = await client.callTool({ name: "claude_bridge_status", arguments: {} });
20
+ console.log("\n--- claude_bridge_status ---\n" + status.content[0].text);
21
+ if (status.isError) throw new Error("Claude bridge status failed");
20
22
 
21
- const listed = await client.callTool({ name: "list_claude_sessions", arguments: {} });
22
- console.log("\n--- list_claude_sessions ---\n" + listed.content[0].text);
23
+ const listed = await client.callTool({ name: "list_claude_sessions", arguments: {} });
24
+ console.log("\n--- list_claude_sessions ---\n" + listed.content[0].text);
25
+ if (listed.isError) throw new Error("Claude session listing failed");
23
26
 
24
- const target = process.env.CLAUDE_TARGET;
25
- if (target) {
26
- const message = process.env.CLAUDE_MESSAGE ?? "Ping from the Codex side of the bridge.";
27
- const waitSec = Number(process.env.CLAUDE_WAIT ?? 180);
28
- console.log(`\nsending to "${target}" (waiting ${waitSec}s)...`);
29
- const sent = await client.callTool({
30
- name: "send_to_claude_session",
31
- arguments: { target, message, waitSec },
32
- });
33
- console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
27
+ const target = process.env.CLAUDE_TARGET;
28
+ if (target) {
29
+ const message = process.env.CLAUDE_MESSAGE ?? "Ping from the Codex side of the bridge.";
30
+ const waitSec = Number(process.env.CLAUDE_WAIT ?? 180);
31
+ console.log(`\nsending to "${target}" (waiting ${waitSec}s)...`);
32
+ const sent = await client.callTool({
33
+ name: "send_to_claude_session",
34
+ arguments: { target, message, waitSec },
35
+ });
36
+ console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
37
+ if (sent.isError) throw new Error("Claude message delivery failed");
38
+ }
39
+ } finally {
40
+ await client.close();
34
41
  }
35
-
36
- await client.close();
37
- process.exit(0);
package/scripts/check.mjs CHANGED
@@ -34,10 +34,16 @@ const transport = new StdioClientTransport({
34
34
  });
35
35
  const client = new Client({ name: "bridge-check", version: "1.0.0" });
36
36
 
37
- await client.connect(transport);
38
- const tools = await client.listTools();
39
- console.log("tools:", tools.tools.map((t) => t.name).join(", "));
40
- const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
41
- console.log(listed.content[0].text.slice(0, 600));
42
- await client.close();
43
- process.exit(0);
37
+ try {
38
+ await client.connect(transport);
39
+ const tools = await client.listTools();
40
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
41
+ const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
42
+ console.log(listed.content[0].text.slice(0, 600));
43
+ if (listed.isError) process.exitCode = 1;
44
+ } catch (err) {
45
+ console.error(err.message);
46
+ process.exitCode = 1;
47
+ } finally {
48
+ await client.close();
49
+ }
@@ -72,6 +72,7 @@ cfg.mcpServers["codex-bridge"] = {
72
72
  CODEX_APP_SERVER_URL: settled("CODEX_APP_SERVER_URL", "ws://127.0.0.1:8791"),
73
73
  CODEX_BRIDGE_ALLOWED_ROOTS: settled("CODEX_BRIDGE_ALLOWED_ROOTS", defaultRoots),
74
74
  CODEX_BRIDGE_APPROVAL: settled("CODEX_BRIDGE_APPROVAL", "deny"),
75
+ CODEX_BRIDGE_AUTO_APPROVE_ACK: settled("CODEX_BRIDGE_AUTO_APPROVE_ACK", "0"),
75
76
  CODEX_BRIDGE_APPROVAL_POLICY: settled("CODEX_BRIDGE_APPROVAL_POLICY", "on-request"),
76
77
  /**
77
78
  * Written out even at its default so it is visible in the file. Left
@@ -6,19 +6,9 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
8
  import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
9
- import { IS_MACOS, 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
- */
9
+ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
21
10
 
11
+ const VERSION = "1.12.2";
22
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
13
  const entry = path.join(root, "src", "native-relay-companion.mjs");
24
14
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -39,7 +29,7 @@ if (remove) {
39
29
  } catch (err) {
40
30
  console.log(`${serverName} was not registered (${err.message.trim().split("\n").at(-1)})`);
41
31
  }
42
- for (const leftover of [relaySocketPath(), relayConfigPath()]) {
32
+ for (const leftover of [relayConfigPath(), ...(IS_WINDOWS ? [] : [relaySocketPath()])]) {
43
33
  if (fs.existsSync(leftover)) {
44
34
  fs.rmSync(leftover, { force: true });
45
35
  console.log(`removed ${leftover}`);
@@ -49,12 +39,12 @@ if (remove) {
49
39
  }
50
40
 
51
41
  /**
52
- * Not a hard failure: the companion is harmless on any platform - it simply
53
- * never gets a native tools connection to dispatch through - and refusing to
54
- * register it would make the install order depend on which machine runs it.
42
+ * Not a hard failure on an unsupported platform: the companion is harmless and
43
+ * refusing to register it would make the install order depend on which machine
44
+ * runs it.
55
45
  */
56
- if (!IS_MACOS) {
57
- console.log(`note: the native relay only delivers on macOS; this is ${PLATFORM_LABEL}.`);
46
+ if (!IS_MACOS && !IS_WINDOWS) {
47
+ console.log(`note: the native relay is unavailable on ${PLATFORM_LABEL}.`);
58
48
  console.log("claude-bridge will keep using the app-server path here.");
59
49
  }
60
50
 
@@ -76,22 +66,17 @@ if (existing) {
76
66
  console.log(`\nskipped the relay thread bootstrap; set CODEX_RELAY_ID or rerun without --no-bootstrap.`);
77
67
  } else {
78
68
  const client = new CodexAppServerClient({
79
- clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: "1.11.3" },
69
+ clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: VERSION },
80
70
  log: (msg) => console.log(` ${msg}`),
81
71
  });
82
72
  console.log("\nbootstrapping the relay executor thread...");
83
73
  try {
84
- const { threadId, configPath } = await bootstrapRelayThread(client, { cwd: homeDir() });
74
+ const { threadId, configPath, release } = await bootstrapRelayThread(client, { cwd: homeDir() });
85
75
  console.log(`relay thread: ${threadId}`);
86
76
  console.log(`written to: ${configPath}`);
77
+ console.log(release.released ? "released the bootstrap thread" : `bootstrap thread release pending: ${release.reason ?? release.status}`);
87
78
  } 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}`);
79
+ await client.close();
95
80
  }
96
81
  }
97
82
 
package/scripts/smoke.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import path from "node:path";
5
6
 
6
7
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
8
  const cwdForTest = process.env.SMOKE_CWD ?? root;
8
-
9
+ const codeword = "BRIDGE-" + randomUUID();
9
10
  const transport = new StdioClientTransport({
10
11
  command: process.execPath,
11
12
  args: [path.join(root, "src", "index.mjs")],
@@ -13,38 +14,40 @@ const transport = new StdioClientTransport({
13
14
  stderr: "inherit",
14
15
  });
15
16
  const client = new Client({ name: "smoke", version: "1.0.0" });
16
- await client.connect(transport);
17
-
18
- const tools = await client.listTools();
19
- console.log("TOOLS:", tools.tools.map((t) => t.name).join(", "));
20
-
21
- const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
22
- console.log("\n--- list_codex_threads ---\n" + listed.content[0].text.slice(0, 900));
23
-
24
- const created = await client.callTool({
25
- name: "start_codex_thread",
26
- arguments: { cwd: cwdForTest },
27
- });
28
- console.log("\n--- start_codex_thread ---\n" + created.content[0].text);
29
- const threadId = created.content[0].text.match(/threadId: ([0-9a-f-]+)/)?.[1];
30
- if (!threadId) throw new Error("could not parse threadId");
31
-
32
- const first = await client.callTool({
33
- name: "send_to_codex_thread",
34
- arguments: { threadId, prompt: "Remember the codeword PAPAYA. Reply with only: SAVED", timeoutSec: 180 },
35
- });
36
- console.log("\n--- send #1 ---\n" + first.content[0].text);
37
-
38
- const second = await client.callTool({
39
- name: "send_to_codex_thread",
40
- arguments: { threadId, prompt: "What codeword did I give you? Reply with only that word.", timeoutSec: 180 },
41
- });
42
- console.log("\n--- send #2 (same thread, must recall) ---\n" + second.content[0].text);
43
-
44
- const read = await client.callTool({ name: "read_codex_thread", arguments: { threadId, limit: 6 } });
45
- console.log("\n--- read_codex_thread ---\n" + read.content[0].text.slice(0, 900));
46
17
 
47
- const ok = /PAPAYA/i.test(second.content[0].text);
48
- console.log("\nRESULT:", ok ? "PASS - thread continuity works" : "FAIL - Codex did not recall the codeword");
49
- await client.close();
50
- process.exit(ok ? 0 : 1);
18
+ async function call(name, args) {
19
+ const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 240000 });
20
+ const text = result.content.map(item => item.text ?? "").join("\n");
21
+ if (result.isError) throw new Error(text);
22
+ return text;
23
+ }
24
+
25
+ try {
26
+ await client.connect(transport);
27
+ const tools = await client.listTools();
28
+ console.log("TOOLS:", tools.tools.map(tool => tool.name).join(", "));
29
+ const created = await call("start_codex_thread", { cwd: cwdForTest, name: "Bridge continuity smoke test" });
30
+ console.log(created);
31
+ const threadId = created.match(/threadId: ([0-9a-f-]+)/)?.[1];
32
+ if (!threadId) throw new Error("could not parse threadId");
33
+ const options = { threadId, timeoutSec: 180, openInApp: false, releaseAfterTurn: false };
34
+ const first = await call("send_to_codex_thread", {
35
+ ...options,
36
+ prompt: "This is a transport smoke test. Do not run tools, commands, or change files. Remember this exact codeword: " + codeword + ". Reply with only: SAVED",
37
+ });
38
+ if (!/status: completed/.test(first) || first.split("--- Codex reply ---").at(-1).trim() !== "SAVED") {
39
+ throw new Error("The first smoke turn did not complete with SAVED: " + first);
40
+ }
41
+ const second = await call("send_to_codex_thread", {
42
+ ...options,
43
+ prompt: "What exact codeword did I give you? Do not use tools. Reply with only that codeword.",
44
+ });
45
+ if (!/status: completed/.test(second) || second.split("--- Codex reply ---").at(-1).trim() !== codeword) {
46
+ throw new Error("Codex did not recall the per-run codeword: " + second);
47
+ }
48
+ const read = await call("read_codex_thread", { threadId, limit: 6 });
49
+ if (!read.includes(codeword)) throw new Error("The saved thread did not contain the smoke reply");
50
+ console.log("RESULT: PASS - two completed turns preserved a unique codeword and its transcript");
51
+ } finally {
52
+ await client.close();
53
+ }
@@ -19,6 +19,7 @@ const entries = [
19
19
  path.join("src", "index.mjs"),
20
20
  path.join("src", "claude-bridge.mjs"),
21
21
  path.join("src", "native-relay-companion.mjs"),
22
+ path.join("scripts", "install-native-relay.mjs"),
22
23
  ];
23
24
  const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
24
25