@minhspark/codex-mcp-bridge 1.12.0 → 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 CHANGED
@@ -2,6 +2,23 @@
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
+
5
22
  ## [1.12.0] - 2026-09-04
6
23
 
7
24
  ### 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,13 +20,13 @@ 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
 
@@ -34,7 +34,7 @@ claude-bridge ───────┤ unix socket ~/.codex/native-relay.sock
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
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).
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,9 +227,9 @@ 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
@@ -240,7 +240,7 @@ This registers the companion with Codex (`codex mcp add codex-native-relay -- <n
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.
@@ -379,7 +379,7 @@ send_to_codex_thread { threadId: "01a0…", prompt: "…", openInApp: true }
379
379
 
380
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.
381
381
 
382
- ### Codex Desktop native relay (macOS)
382
+ ### Codex Desktop native relay
383
383
 
384
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
385
 
@@ -394,7 +394,7 @@ The native relay removes the second writer rather than fighting it. A companion
394
394
  ```
395
395
  Claude
396
396
  → claude-bridge
397
- → ~/.codex/native-relay.sock (unix socket, mode 0600)
397
+ Windows named pipe / macOS ~/.codex/native-relay.sock (unix socket, mode 0600)
398
398
  → codex-native-relay (launched by Codex Desktop's app-server)
399
399
  → codex_app.send_message_to_thread (over that same connection)
400
400
  → the thread already open in Codex Desktop
@@ -412,25 +412,25 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
412
412
 
413
413
  | Condition | Otherwise |
414
414
  |---|---|
415
- | macOS | the app-server path (`CODEX_BRIDGE_NATIVE_RELAY=1` forces the attempt anyway) |
415
+ | Windows or macOS, companion endpoint exists | the app-server path (`CODEX_BRIDGE_NATIVE_RELAY=1` forces the attempt anyway) |
416
416
  | `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 |
417
+ | the Windows named pipe or macOS unix socket exists | the companion is not installed, or Codex Desktop is not running |
418
418
 
419
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
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 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:
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
422
  >
423
423
  > ```json
424
424
  > {"jsonrpc":"2.0","id":1,"method":"codex_app.send_message_to_thread",
425
425
  > "params":{"executorThreadId":"<relay thread>","threadId":"<destination>","message":"..."}}
426
426
  > ```
427
427
 
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.
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
429
 
430
430
  ### Caveats
431
431
 
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.
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.
434
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.
435
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.
436
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://`.
@@ -454,9 +454,9 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
454
454
 
455
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.
456
456
 
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.
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
458
 
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.
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.
460
460
 
461
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.
462
462
 
@@ -493,10 +493,10 @@ The bridge reads these from the environment its MCP client hands it — there is
493
493
  | `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
494
494
  | `CODEX_BRIDGE_OPEN_IN_APP` | `1` on Windows, `0` elsewhere | Open delegated or sent threads through the `codex://threads/<id>` desktop link. |
495
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. |
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
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
501
  | `CODEX_NATIVE_RELAY_NAME` | `codex-native-relay` | The MCP server name `scripts/install-native-relay.mjs` registers with Codex. |
502
502
  | `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
@@ -542,10 +542,10 @@ Runs the whole suite with `node --test`. It needs no Codex install, no login and
542
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 |
543
543
  | `test/peer-protocol.test.mjs` | frame round-trips, the session registry, transcript scanning, and a live peer endpoint over a real unix socket |
544
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, 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 |
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 |
546
546
  | `test/repo-hygiene.test.mjs` | no environment file or build output is ever tracked, versions do not drift, documentation stays in English |
547
547
 
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.
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.
549
549
 
550
550
  Two checks need a real Codex and are not part of `npm test`:
551
551
 
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.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",
@@ -6,7 +6,7 @@ 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";
9
+ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
10
10
 
11
11
  /**
12
12
  * Installs the Codex Desktop native relay: registers the companion as an MCP
@@ -39,7 +39,7 @@ if (remove) {
39
39
  } catch (err) {
40
40
  console.log(`${serverName} was not registered (${err.message.trim().split("\n").at(-1)})`);
41
41
  }
42
- for (const leftover of [relaySocketPath(), relayConfigPath()]) {
42
+ for (const leftover of [relayConfigPath(), ...(IS_WINDOWS ? [] : [relaySocketPath()])]) {
43
43
  if (fs.existsSync(leftover)) {
44
44
  fs.rmSync(leftover, { force: true });
45
45
  console.log(`removed ${leftover}`);
@@ -49,12 +49,12 @@ if (remove) {
49
49
  }
50
50
 
51
51
  /**
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.
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
55
  */
56
- if (!IS_MACOS) {
57
- console.log(`note: the native relay only delivers on macOS; this is ${PLATFORM_LABEL}.`);
56
+ if (!IS_MACOS && !IS_WINDOWS) {
57
+ console.log(`note: the native relay is unavailable on ${PLATFORM_LABEL}.`);
58
58
  console.log("claude-bridge will keep using the app-server path here.");
59
59
  }
60
60
 
@@ -76,7 +76,7 @@ if (existing) {
76
76
  console.log(`\nskipped the relay thread bootstrap; set CODEX_RELAY_ID or rerun without --no-bootstrap.`);
77
77
  } else {
78
78
  const client = new CodexAppServerClient({
79
- clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: "1.11.3" },
79
+ clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: "1.12.1" },
80
80
  log: (msg) => console.log(` ${msg}`),
81
81
  });
82
82
  console.log("\nbootstrapping the relay executor thread...");
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
8
8
  import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
9
9
  import { createThreadDelivery } from "./thread-delivery.mjs";
10
10
 
11
- const VERSION = "1.12.0";
11
+ const VERSION = "1.12.1";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
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.12.0";
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 && terminal) {
139
+ if (releaseAfterTurn && releasable) {
138
140
  try {
139
141
  const released = await client.stopServer();
140
142
  if (released.stopped) {
@@ -16,7 +16,7 @@ import {
16
16
  relaySocketPath,
17
17
  resolveRelayThreadId,
18
18
  } from "./native-relay.mjs";
19
- import { PLATFORM_LABEL } from "./platform.mjs";
19
+ import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
20
20
 
21
21
  /**
22
22
  * The companion half of the Codex Desktop native relay.
@@ -31,7 +31,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
31
31
  * (`{ targetThreadId, message }`), one dispatch, one acknowledgement.
32
32
  */
33
33
 
34
- const VERSION = "1.12.0";
34
+ const VERSION = "1.12.1";
35
35
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
36
36
 
37
37
  function errorResponse(code, message) {
@@ -91,20 +91,19 @@ export async function handleRelayRequest(
91
91
  }
92
92
 
93
93
  /**
94
- * Listens on a private unix socket and answers one NDJSON line per request.
95
- *
96
- * The socket is mode 0600 inside the Codex home directory, which is the same
97
- * boundary the Claude peer protocol already relies on: owning the user account
98
- * is what grants access, and nothing weaker does. Named separately here because
99
- * this socket can put text into a Codex thread, so the file mode is the
100
- * security control rather than a detail of the transport.
94
+ * Listens on a private local socket or Windows named pipe and answers one NDJSON line per request.
95
+ *
96
+ * POSIX sockets are mode 0600 inside the Codex home directory. Windows uses the
97
+ * Claude-compatible local named-pipe namespace instead of a filesystem mode.
101
98
  */
102
99
  export class RelaySocketServer {
103
100
  constructor({
104
101
  socketPath,
105
102
  dispatch,
106
103
  resolveExecutor = resolveRelayThreadId,
107
- restrictSocket = (target) => fs.chmodSync(target, 0o600),
104
+ restrictSocket = (target) => {
105
+ if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
106
+ },
108
107
  log: logFn = () => {},
109
108
  } = {}) {
110
109
  this.socketPath = socketPath;
@@ -118,7 +117,7 @@ export class RelaySocketServer {
118
117
 
119
118
  async start() {
120
119
  if (this.started) return this.socketPath;
121
- fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
120
+ if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
122
121
 
123
122
  this.server = net.createServer((socket) => this.#handleConnection(socket));
124
123
  await this.#listen({ replaceStale: true });
@@ -171,6 +170,13 @@ export class RelaySocketServer {
171
170
  });
172
171
  } catch (err) {
173
172
  if (err.code !== "EADDRINUSE" || !replaceStale) throw err;
173
+ if (IS_WINDOWS) {
174
+ if (await this.#socketIsLive()) {
175
+ throw new Error(`another native relay companion already owns ${this.socketPath}`);
176
+ }
177
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
178
+ return this.#listen({ replaceStale: false });
179
+ }
174
180
  if (await this.#socketIsLive()) {
175
181
  throw new Error(`another native relay companion already owns ${this.socketPath}`);
176
182
  }
@@ -241,7 +247,7 @@ export class RelaySocketServer {
241
247
  this.server?.close();
242
248
  } catch {}
243
249
  try {
244
- if (this.started) fs.rmSync(this.socketPath, { force: true });
250
+ if (this.started && !IS_WINDOWS) fs.rmSync(this.socketPath, { force: true });
245
251
  } catch {}
246
252
  this.started = false;
247
253
  }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
4
 
5
- import { IS_MACOS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
5
+ import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
6
6
 
7
7
  /**
8
8
  * The Codex Desktop app owns the per-thread writer lock of every thread it has
@@ -43,6 +43,7 @@ export const MAX_FRAME_BYTES = 128 * 1024;
43
43
 
44
44
  const DEFAULT_TIMEOUT_MS = 30000;
45
45
  const RELAY_SOCKET_NAME = "native-relay.sock";
46
+ const WINDOWS_RELAY_SOCKET = "\\\\.\\pipe\\LOCAL\\codex-native-relay";
46
47
  const RELAY_CONFIG_NAME = "native-relay.json";
47
48
 
48
49
  export class NativeRelayError extends Error {
@@ -66,7 +67,7 @@ export function codexHome(env = process.env) {
66
67
  }
67
68
 
68
69
  export function relaySocketPath(env = process.env) {
69
- return env.CODEX_NATIVE_RELAY_SOCKET ?? path.join(codexHome(env), RELAY_SOCKET_NAME);
70
+ return env.CODEX_NATIVE_RELAY_SOCKET ?? (IS_WINDOWS ? WINDOWS_RELAY_SOCKET : path.join(codexHome(env), RELAY_SOCKET_NAME));
70
71
  }
71
72
 
72
73
  export function relayConfigPath(env = process.env) {
@@ -81,6 +82,13 @@ function isSocketFile(target) {
81
82
  }
82
83
  }
83
84
 
85
+ function isRelayEndpoint(target) {
86
+ if (IS_WINDOWS) {
87
+ return typeof target === "string" && target.toLowerCase().startsWith("\\\\.\\pipe\\") && fs.existsSync(target);
88
+ }
89
+ return isSocketFile(target);
90
+ }
91
+
84
92
  export function readRelayConfig(env = process.env) {
85
93
  const file = relayConfigPath(env);
86
94
  try {
@@ -145,15 +153,15 @@ export function nativeRelayStatus(env = process.env) {
145
153
  return { enabled: false, mode, socketPath, reason: "disabled by CODEX_BRIDGE_NATIVE_RELAY=0" };
146
154
  }
147
155
  const forced = mode === "1" || mode === "on";
148
- if (!IS_MACOS && !forced) {
156
+ if (!IS_MACOS && !IS_WINDOWS && !forced) {
149
157
  return {
150
158
  enabled: false,
151
159
  mode,
152
160
  socketPath,
153
- reason: `the Codex Desktop native relay is macOS-only (this is ${PLATFORM_LABEL})`,
161
+ reason: `the Codex Desktop native relay is unavailable on ${PLATFORM_LABEL}`,
154
162
  };
155
163
  }
156
- if (!isSocketFile(socketPath)) {
164
+ if (!isRelayEndpoint(socketPath)) {
157
165
  return {
158
166
  enabled: false,
159
167
  mode,
@@ -32,7 +32,7 @@ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
32
32
 
33
33
  /**
34
34
  * A Claude Code session advertises itself in ~/.claude/sessions/<pid>.json and
35
- * listens for peer messages on a unix socket. Messages are newline-delimited
35
+ * listens for peer messages on a local socket or Windows named pipe. Messages are newline-delimited
36
36
  * JSON; the wrapper element is what Claude renders in its chat surface.
37
37
  */
38
38
  export function buildFrame({ text, fromSocket, priority = "next" }) {
@@ -84,6 +84,30 @@ function isProcessAlive(pid) {
84
84
  }
85
85
  }
86
86
 
87
+ const SESSION_READ_ATTEMPTS = 3;
88
+ const PEER_SEND_ATTEMPTS = 3;
89
+ const PEER_CONNECT_TIMEOUT_MS = 2000;
90
+ const PEER_RETRY_DELAY_MS = 75;
91
+ const RETRYABLE_PEER_ERRORS = new Set(["ECONNREFUSED", "ECONNRESET", "ENOENT", "EPIPE", "ENOTFOUND", "ETIMEDOUT"]);
92
+
93
+ function readSessionEntry(file) {
94
+ for (let attempt = 0; attempt < SESSION_READ_ATTEMPTS; attempt += 1) {
95
+ try {
96
+ const first = fs.readFileSync(file, "utf8");
97
+ const second = fs.readFileSync(file, "utf8");
98
+ if (first !== second) continue;
99
+ return JSON.parse(second);
100
+ } catch {}
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function hasMessagingEndpoint(entry) {
106
+ const socket = entry?.messagingSocketPath;
107
+ if (IS_WINDOWS && typeof socket === "string" && socket.toLowerCase().startsWith("\\\\.\\pipe\\")) return true;
108
+ return Boolean(socket) && fs.existsSync(socket);
109
+ }
110
+
87
111
  export const BRIDGE_ENTRYPOINT = "codex-bridge";
88
112
 
89
113
  export function listClaudeSessions({ includeDead = false, includeBridges = false } = {}) {
@@ -92,15 +116,11 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
92
116
  const rows = [];
93
117
  for (const file of fs.readdirSync(dir)) {
94
118
  if (!file.endsWith(".json")) continue;
95
- let entry;
96
- try {
97
- entry = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
98
- } catch {
99
- continue;
100
- }
119
+ const entry = readSessionEntry(path.join(dir, file));
120
+ if (!entry) continue;
101
121
  if (!entry?.pid || !entry?.messagingSocketPath) continue;
102
122
  if (entry.entrypoint === BRIDGE_ENTRYPOINT && !includeBridges) continue;
103
- const alive = isProcessAlive(entry.pid) && fs.existsSync(entry.messagingSocketPath);
123
+ const alive = isProcessAlive(entry.pid) && hasMessagingEndpoint(entry);
104
124
  if (!alive && !includeDead) continue;
105
125
  rows.push({
106
126
  pid: entry.pid,
@@ -206,12 +226,8 @@ export class PeerEndpoint {
206
226
  for (const file of fs.readdirSync(dir)) {
207
227
  if (!file.endsWith(".json")) continue;
208
228
  const registry = path.join(dir, file);
209
- let entry;
210
- try {
211
- entry = JSON.parse(fs.readFileSync(registry, "utf8"));
212
- } catch {
213
- continue;
214
- }
229
+ const entry = readSessionEntry(registry);
230
+ if (!entry) continue;
215
231
  if (entry?.entrypoint !== BRIDGE_ENTRYPOINT) continue;
216
232
  if (!entry.pid || entry.pid === this.pid || isProcessAlive(entry.pid)) continue;
217
233
  for (const stale of [registry, entry.messagingSocketPath, ...fs.readdirSync(dir)
@@ -328,15 +344,53 @@ export class PeerEndpoint {
328
344
 
329
345
  async send(targetSocket, text, { priority = "next" } = {}) {
330
346
  const frame = buildFrame({ text, fromSocket: this.socketPath, priority });
331
- await new Promise((resolve, reject) => {
332
- const client = net.connect({ path: targetSocket }, () => {
333
- client.write(`${JSON.stringify(frame)}\n`, () => {
334
- client.end();
335
- resolve();
347
+ const line = JSON.stringify(frame) + "\n";
348
+ for (let attempt = 1; attempt <= PEER_SEND_ATTEMPTS; attempt += 1) {
349
+ try {
350
+ await new Promise((resolve, reject) => {
351
+ const client = net.connect({ path: targetSocket });
352
+ let connected = false;
353
+ let writeStarted = false;
354
+ let settled = false;
355
+ const timer = globalThis.setTimeout(() => {
356
+ const error = new Error("timed out connecting to " + targetSocket);
357
+ error.code = "ETIMEDOUT";
358
+ finish(error);
359
+ client.destroy();
360
+ }, PEER_CONNECT_TIMEOUT_MS);
361
+ const finish = (error) => {
362
+ if (settled) return;
363
+ settled = true;
364
+ globalThis.clearTimeout(timer);
365
+ if (error) {
366
+ reject({ error, retryable: !connected && !writeStarted });
367
+ } else {
368
+ resolve();
369
+ }
370
+ };
371
+ client.once("connect", () => {
372
+ connected = true;
373
+ writeStarted = true;
374
+ client.write(line, (error) => {
375
+ if (error) {
376
+ finish(error);
377
+ return;
378
+ }
379
+ client.end();
380
+ finish();
381
+ });
382
+ });
383
+ client.once("error", finish);
336
384
  });
337
- });
338
- client.on("error", reject);
339
- });
385
+ return frame.msg_id;
386
+ } catch (failure) {
387
+ const error = failure?.error ?? failure;
388
+ if (!failure?.retryable || attempt === PEER_SEND_ATTEMPTS || !RETRYABLE_PEER_ERRORS.has(error?.code)) {
389
+ throw error;
390
+ }
391
+ await new Promise((resolve) => globalThis.setTimeout(resolve, PEER_RETRY_DELAY_MS));
392
+ }
393
+ }
340
394
  return frame.msg_id;
341
395
  }
342
396
 
package/src/platform.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { accessSync, constants, existsSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, readdirSync, statSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -94,7 +94,26 @@ function isRunnable(candidate) {
94
94
  function windowsCodexCandidates() {
95
95
  const roaming = process.env.APPDATA;
96
96
  const local = process.env.LOCALAPPDATA;
97
+ const versionedRoot = local && path.join(local, "OpenAI", "Codex", "bin");
98
+ let versioned = [];
99
+ if (versionedRoot) {
100
+ try {
101
+ versioned = readdirSync(versionedRoot, { withFileTypes: true })
102
+ .filter((entry) => entry.isDirectory())
103
+ .map((entry) => path.join(versionedRoot, entry.name, "codex.exe"))
104
+ .filter((candidate) => isRunnable(candidate))
105
+ .sort((left, right) => {
106
+ try {
107
+ return statSync(path.dirname(right)).mtimeMs - statSync(path.dirname(left)).mtimeMs;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ });
112
+ } catch {}
113
+ }
97
114
  return [
115
+ process.env.CODEX_CLI_PATH,
116
+ ...versioned,
98
117
  local && path.join(local, "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
99
118
  roaming && path.join(roaming, "npm", "codex.cmd"),
100
119
  process.env.ProgramFiles && path.join(process.env.ProgramFiles, "nodejs", "codex.cmd"),
@@ -17,12 +17,17 @@ import { runTurn } from "./turn.mjs";
17
17
  */
18
18
  export const NATIVE_BACKEND = "codex-desktop-native";
19
19
  export const APP_SERVER_BACKEND = "app-server";
20
+ const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed", "disconnected"]);
20
21
 
21
22
  export function createThreadDelivery({
22
23
  codex,
23
24
  relay = new NativeDesktopRelay(),
24
25
  log = () => {},
25
26
  timeoutMs = 240000,
27
+ releaseAfterTurn =
28
+ process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN !== undefined
29
+ ? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
30
+ : process.platform === "win32",
26
31
  } = {}) {
27
32
  let reportedUnavailable = null;
28
33
 
@@ -57,6 +62,15 @@ export function createThreadDelivery({
57
62
  input: [{ type: "text", text }],
58
63
  timeoutMs,
59
64
  });
65
+ if (releaseAfterTurn && RELEASE_STATUSES.has(turn.status) && typeof codex.stopServer === "function") {
66
+ try {
67
+ const released = await codex.stopServer();
68
+ if (released?.stillListening) log("app-server release requested but it is still listening");
69
+ if (released?.stopped === false) log("app-server release skipped: " + (released.reason ?? "unknown reason"));
70
+ } catch (err) {
71
+ log("app-server release failed: " + err.message);
72
+ }
73
+ }
60
74
  return { backend: APP_SERVER_BACKEND, threadId, turn };
61
75
  }
62
76