@minhspark/codex-mcp-bridge 1.10.0

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/README.md ADDED
@@ -0,0 +1,444 @@
1
+ # codex-mcp-bridge
2
+
3
+ [![M8ven Verified](https://m8ven.ai/badge/mcp/buidangminh23-codex-mcp-bridge-1ke8t1?variant=verified)](https://m8ven.ai/mcp/buidangminh23-codex-mcp-bridge-1ke8t1)
4
+
5
+ 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).
6
+
7
+ 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.
8
+
9
+ ## Architecture
10
+
11
+ Two MCP servers, one living inside each agent:
12
+
13
+ ```
14
+ ┌──────────────── codex-mcp-bridge (runs inside Claude) ──────────────┐
15
+ Claude Desktop ──────┤ stdio WebSocket ├──> codex app-server ──> thread shows in Codex Desktop
16
+ └────────────────────────────────────────────────────────────────────┘
17
+
18
+ ┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
19
+ Codex ───────────────┤ stdio unix socket /tmp/cc-socks/<pid>.sock ├──> Claude Code session ──> message shows in Claude Desktop
20
+ └────────────────────────────────────────────────────────────────────┘
21
+
22
+ Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
23
+ ```
24
+
25
+ - 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.
26
+ - 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.
27
+ - The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
28
+
29
+ ## Requirements
30
+
31
+ | Requirement | Why |
32
+ |---|---|
33
+ | Node.js 22 or newer | the bridge uses the built-in `WebSocket` and `node --test` |
34
+ | [Codex CLI](https://developers.openai.com/codex) (`codex`), logged in | the bridge drives its app-server |
35
+ | An MCP client that launches stdio servers | Claude Desktop, Claude Code, or Codex itself |
36
+
37
+ Install the prerequisites:
38
+
39
+ ```bash
40
+ # macOS
41
+ brew install node
42
+ npm install -g @openai/codex
43
+ codex login
44
+ ```
45
+
46
+ ```powershell
47
+ # Windows (PowerShell 7+)
48
+ winget install OpenJS.NodeJS.LTS
49
+ npm install -g @openai/codex
50
+ codex login
51
+ ```
52
+
53
+ ```bash
54
+ # Linux
55
+ curl -fsSL https://fnm.vercel.app/install | bash && exec $SHELL
56
+ fnm install 22 && fnm use 22
57
+ npm install -g @openai/codex
58
+ codex login
59
+ ```
60
+
61
+ Verify before going further — both commands must print a version:
62
+
63
+ ```bash
64
+ node --version && codex --version
65
+ ```
66
+
67
+ ## Install
68
+
69
+ ### 1. Get the bridge
70
+
71
+ Two ways in. Pick by what you intend to do with it.
72
+
73
+ **Install it — no clone, no checkout to keep in sync.** Right for a machine that only has to run the bridge:
74
+
75
+ ```bash
76
+ npm install -g @minhspark/codex-mcp-bridge
77
+ ```
78
+
79
+ Installing straight from the repository works the same way and needs no registry account:
80
+
81
+ ```bash
82
+ npm install -g git+https://github.com/buidangminh23/codex-mcp-bridge.git
83
+ ```
84
+
85
+ Either route puts four commands on your PATH — `codex-mcp-bridge` and `claude-mcp-bridge` are the two servers, `codex-mcp-bridge-install` and `claude-mcp-bridge-install` do the wiring in the steps below. Wherever this README runs `node scripts/install-claude-desktop.mjs`, an installed copy runs `codex-mcp-bridge-install` instead.
86
+
87
+ **Clone it** — right if you intend to read, test or change the code:
88
+
89
+ ```bash
90
+ git clone https://github.com/buidangminh23/codex-mcp-bridge.git
91
+ cd codex-mcp-bridge
92
+ npm install
93
+ ```
94
+
95
+ Confirm the tree is healthy before wiring it into anything:
96
+
97
+ ```bash
98
+ npm test
99
+ ```
100
+
101
+ ### 2. Claude → Codex, into Claude Desktop
102
+
103
+ ```bash
104
+ node scripts/install-claude-desktop.mjs
105
+ ```
106
+
107
+ The script detects the platform, creates the config file if it does not exist, backs up the previous one (`*.bak-<date>-codexbridge`) and preserves every other key:
108
+
109
+ | OS | Config path |
110
+ |---|---|
111
+ | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
112
+ | Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
113
+ | Linux | `${XDG_CONFIG_HOME:-~/.config}/Claude/claude_desktop_config.json` |
114
+
115
+ Tell it which projects the bridge may drive. The default is the checkout when you cloned, and the directory you ran the installer from when you installed the package — neither is a sensible guess for more than one project, so name them:
116
+
117
+ ```bash
118
+ # macOS and Linux separate entries with ":", Windows with ";"
119
+ CODEX_BRIDGE_ALLOWED_ROOTS="/path/to/project-a:/path/to/project-b" codex-mcp-bridge-install
120
+ ```
121
+
122
+ Skip this and the bridge still starts cleanly, then answers every `list_codex_threads` with `No workspace roots are configured`. It is the most common way an otherwise correct install refuses to do anything, so the installer now prints a warning when the roots it wrote point inside an install directory.
123
+
124
+ Pass defaults through the environment if you want them written into the entry:
125
+
126
+ ```bash
127
+ CODEX_BRIDGE_MODEL=gpt-5.6-luna CODEX_BRIDGE_EFFORT=xhigh node scripts/install-claude-desktop.mjs
128
+ ```
129
+
130
+ The result on macOS:
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "codex-bridge": {
136
+ "command": "/Users/<user>/.local/node/v24.18.0/bin/node",
137
+ "args": ["/Users/<user>/code/codex-mcp-bridge/src/index.mjs"],
138
+ "env": {
139
+ "CODEX_BIN": "/Users/<user>/.local/bin/codex",
140
+ "CODEX_APP_SERVER_URL": "ws://127.0.0.1:8791"
141
+ }
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ Restart Claude Desktop afterwards. To write the same entry by hand, use absolute paths for both `command` and `args` — Claude Desktop does not resolve either from PATH.
148
+
149
+ ### 3. Claude → Codex, into Claude Code (CLI)
150
+
151
+ Claude Code keeps its own MCP registry, so it needs its own registration. Run this from the repo root:
152
+
153
+ ```bash
154
+ claude mcp add codex-bridge --scope user -e CODEX_BIN="$(command -v codex)" -- "$(command -v node)" "$PWD/src/index.mjs"
155
+ ```
156
+
157
+ ```powershell
158
+ # Windows (PowerShell 7+)
159
+ claude mcp add codex-bridge --scope user -e CODEX_BIN="$((Get-Command codex).Source)" -- "$((Get-Command node).Source)" "$PWD\src\index.mjs"
160
+ ```
161
+
162
+ Check and remove it with:
163
+
164
+ ```bash
165
+ claude mcp list
166
+ claude mcp get codex-bridge
167
+ claude mcp remove codex-bridge --scope user
168
+ ```
169
+
170
+ `--scope user` makes the bridge available in every project. Use `--scope project` instead to commit the entry into a repo's `.mcp.json` and share it with the team.
171
+
172
+ ### 4. Codex → Claude, into Codex
173
+
174
+ ```bash
175
+ node scripts/install-codex-mcp.mjs
176
+ ```
177
+
178
+ This runs `codex mcp add claude-bridge -- <node> src/claude-bridge.mjs`, writing to `~/.codex/config.toml`. The equivalent by hand:
179
+
180
+ ```bash
181
+ codex mcp add claude-bridge --env CLAUDE_BRIDGE_PEER_NAME=codex-desktop -- "$(command -v node)" "$PWD/src/claude-bridge.mjs"
182
+ ```
183
+
184
+ Verify and remove:
185
+
186
+ ```bash
187
+ codex mcp list
188
+ codex mcp get claude-bridge
189
+ node scripts/install-codex-mcp.mjs --remove
190
+ ```
191
+
192
+ Restart the Codex app (or open a new Codex session) to load it. If a shared app-server is already running under launchd, reload its config with:
193
+
194
+ ```bash
195
+ launchctl kickstart -k gui/$UID/com.codex-mcp-bridge.app-server
196
+ ```
197
+
198
+ `CLAUDE_BRIDGE_PEER_NAME` sets the name Claude shows for this bridge in its agent list.
199
+
200
+ ### 5. macOS only: keep the app-server alive with launchd
201
+
202
+ > ⚠️ **Do not enable the LaunchAgent while using the Codex desktop app.** The app runs its **own** stdio app-server against the **same** `~/.codex` sqlite state. Two app-servers contend even while idle — measured here: the launchd one burned ~11% CPU doing nothing and **the Codex app UI stuttered**. Keep exactly one alive; `codex_bridge_status` detects and warns about this.
203
+ >
204
+ > The LaunchAgent makes sense on a machine **without** the desktop app (headless box, CLI/TUI only). With the app running, drop it and let the bridge spawn an app-server on demand — contention then lasts only while you are actually delegating work, not 24/7.
205
+
206
+ ```bash
207
+ node scripts/install-launch-agent.mjs
208
+ ```
209
+
210
+ Writes `~/Library/LaunchAgents/com.codex-mcp-bridge.app-server.plist` (`RunAtLoad` plus `KeepAlive` on crash, 10s `ThrottleInterval`) and bootstraps it into `gui/$UID`.
211
+
212
+ ```bash
213
+ launchctl print gui/$UID/com.codex-mcp-bridge.app-server | head -20 # status
214
+ tail -f ~/Library/Logs/codex-mcp-bridge/app-server.err.log # logs
215
+ node scripts/install-launch-agent.mjs --uninstall # remove
216
+ ```
217
+
218
+ ### 6. Verify the install
219
+
220
+ ```bash
221
+ npm run check # boots the bridge, autostarts an app-server, lists threads
222
+ npm run check:claude # lists the live Claude Code sessions Codex can reach
223
+ ```
224
+
225
+ From inside Claude, call the `codex_bridge_status` tool; from inside Codex, call `claude_bridge_status`. Both print the resolved binary, the endpoint and whether anything is listening.
226
+
227
+ ### 7. Uninstall everything
228
+
229
+ ```bash
230
+ claude mcp remove codex-bridge --scope user
231
+ node scripts/install-codex-mcp.mjs --remove
232
+ node scripts/install-launch-agent.mjs --uninstall
233
+ ```
234
+
235
+ Then delete the `codex-bridge` entry from `claude_desktop_config.json` (a dated `.bak-*` copy from before the install sits next to it) and remove the clone.
236
+
237
+ **Resolving the `codex` binary:** Claude Desktop (and launchd) start MCP servers with a trimmed PATH, so `codex` is usually not on it. The bridge probes `CODEX_BIN` first, then the usual install locations for the platform, then PATH:
238
+
239
+ | OS | Probe order |
240
+ |---|---|
241
+ | macOS / Linux | `/Applications/ChatGPT.app/Contents/Resources/codex` (macOS, when the desktop app is installed) → `~/.local/bin/codex` → `~/.npm-global/bin/codex` → `/opt/homebrew/bin/codex` → `/usr/local/bin/codex` → `~/.volta/bin` → `~/.bun/bin` → `~/.cargo/bin` → `~/.codex/packages/standalone/current/codex` |
242
+ | Windows | `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin\codex.exe` → `%APPDATA%\npm\codex.cmd` → `%ProgramFiles%\nodejs\codex.cmd` |
243
+
244
+ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env node` shebang, so the bridge also rebuilds `PATH` for the child process (current node directory + `/opt/homebrew/bin` + `/usr/local/bin` + system dirs). Without that step, spawning the app-server dies at the shebang.
245
+
246
+ ## Tools — `codex-mcp-bridge` (runs inside Claude)
247
+
248
+ | Tool | What it does | Hints |
249
+ |---|---|---|
250
+ | `send_to_codex_thread` | Sends a prompt as a user turn into `threadId`, waits for `turn/completed`, returns Codex's reply plus an activity trail (commands run, files changed). | destructive |
251
+ | `list_codex_threads` | Lists threads (id, title, cwd, last update, status) so you can pick the **exact** `threadId`. `loadedOnly: true` shows only threads live inside the app-server. On macOS each row carries a `codex://threads/<id>` deep link. | read-only |
252
+ | `start_codex_thread` | Opens a new Codex thread at a permitted `cwd` and returns its `threadId`; the bridge applies its configured safe sandbox and approval policy. | writes |
253
+ | `read_codex_thread` | Reads the recent conversation without sending anything. | read-only |
254
+ | `interrupt_codex_turn` | Stops a turn that is still running. | destructive |
255
+ | `open_codex_thread` | **macOS**: brings a thread to the front in the Codex desktop app via `codex://threads/<id>` so a human can watch it work. Pass `background: true` to open without stealing focus. | writes |
256
+ | `stop_codex_app_server` | Stops the shared app-server once a hand-off is done, so it stops competing with Codex Desktop for the `~/.codex` state. The bridge starts a new one when it next needs it. | destructive |
257
+ | `codex_bridge_status` | Reports the environment: platform, resolved `codex` binary, whether the app-server endpoint is live, plus the macOS integrations (LaunchAgent, desktop app), and **warns when two app-servers are running**. Start here when something misbehaves. | read-only |
258
+
259
+ `send_to_codex_thread` also accepts `timeoutSec` (default 240), `cwd`, `model`, `effort`, and `openInApp` (macOS — surface the thread in the desktop app before sending). 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`.
260
+
261
+ Thread operations are deny-by-default. Set `CODEX_BRIDGE_ALLOWED_THREADS` to a comma-separated list of exact thread IDs you want this MCP server to access; threads created by `start_codex_thread` are authorized for the lifetime of this bridge process. Set `CODEX_BRIDGE_ALLOWED_ROOTS` to the absolute project directories the bridge may use. The installer defaults that root to this repository, so change it when delegating into another project.
262
+
263
+ ## Tools — `claude-bridge` (runs inside Codex)
264
+
265
+ | Tool | What it does | Hints |
266
+ |---|---|---|
267
+ | `list_claude_sessions` | Lists Claude Code sessions running on this machine (name, pid, sessionId, cwd, entrypoint). | read-only |
268
+ | `send_to_claude_session` | Delivers a message into a Claude session — it lands **in that session's chat**, exactly like a teammate's message — and waits for the reply. `waitSec: 0` fires and forgets. `target` takes a name, pid or sessionId; **Claude session names drift over time**, so target by `sessionId` when it matters. | destructive |
269
+ | `read_claude_inbox` | Reads **and clears** messages Claude pushed over on its own, including replies that arrived late. | destructive |
270
+ | `read_claude_transcript` | Reads a Claude session's recent conversation without sending anything. | read-only |
271
+ | `bind_codex_thread` | Binds a Codex thread so every message from Claude is relayed into it, **visible in the Codex desktop app**. Pass an empty string to stop. | writes |
272
+ | `claude_bridge_status` | Reports the peer endpoint, how many Claude sessions are live, the relay thread and the inbox depth. | writes |
273
+
274
+ 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.
275
+
276
+ ### How each side sees the other
277
+
278
+ - **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).
279
+ - **Codex sees Claude:** `list_claude_sessions` reads that same registry, and `read_claude_transcript` shows what a Claude session is working on.
280
+ - **Visible in chat:** a message from Codex appears in the Claude Desktop chat of the target session; a message from Claude is relayed into the bound Codex thread, so it appears in the Codex desktop app.
281
+
282
+ ### Protocol (measured, not documented)
283
+
284
+ Every Claude Code session writes `~/.claude/sessions/<pid>.json` and listens on `/tmp/cc-socks/<pid>.sock`. Frames are **NDJSON**, one message per line:
285
+
286
+ ```json
287
+ {"msgV":1,"msg_id":"<uuid>","type":"user","message":{"role":"user",
288
+ "content":"<cross-session-message from=\"uds:/tmp/cc-socks/<pid>.sock\" from-mode=\"bypass\">\n...\n</cross-session-message>"},
289
+ "priority":"next","from":"uds:/tmp/cc-socks/<pid>.sock"}
290
+ ```
291
+
292
+ There is no token in the frame — **the socket is mode `0600`, so owning the user account is the entire security boundary**. To receive replies you must register a peer session of your own (registry entry + socket), because Claude answers to the address in `from`.
293
+
294
+ > ⚠️ This is a **Claude Code internal with no public documentation** (measured on 2.1.229). If the format changes, the Codex → Claude direction breaks; fix it in `src/peer-protocol.mjs`. The Claude → Codex direction goes through the official app-server and is unaffected.
295
+
296
+ ### Ping-pong guard
297
+
298
+ The relay has two hard limits in `src/claude-bridge.mjs`: at most **one message every 5s** and **50 per bridge run**. Two agents left talking to each other unattended still come to a stop.
299
+
300
+ ## macOS notes
301
+
302
+ ### Watch a thread in the Codex desktop app
303
+
304
+ The Codex desktop app on macOS is `/Applications/ChatGPT.app` and registers the `codex://` URL scheme. The bridge uses `codex://threads/<threadId>` to open the exact thread:
305
+
306
+ ```
307
+ open_codex_thread { threadId: "01a0…", background: true }
308
+ send_to_codex_thread { threadId: "01a0…", prompt: "…", openInApp: true }
309
+ ```
310
+
311
+ 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.
312
+
313
+ ### Caveats
314
+
315
+ - 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.
316
+ - **A thread currently open in the desktop app cannot be written to** — Codex holds a per-thread writer lock (`~/.codex/thread-writer-locks/`) and returns `thread <id> already has an active writer`. That error is the guard working, not data loss. Check `status` with `list_codex_threads` first and only send when it is `idle` or `notLoaded` and not open in the app.
317
+ - **Threads created by the bridge do not show a title in the app.** The app lists from `~/.codex/session_index.jsonl`, and entries land there only once a thread has been named — naming is done by the app, not the app-server. The thread still exists in `~/.codex/state_5.sqlite` and opens via the `codex://threads/<id>` deep link.
318
+ - 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.
319
+ - `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://`.
320
+
321
+ ## Troubleshooting
322
+
323
+ **`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.
324
+
325
+ ```bash
326
+ ps aux | grep "[a]pp-server --listen"
327
+ pkill -9 -f "codex-darwin-arm64/vendor.*app-server"
328
+ launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.codex-mcp-bridge.app-server.plist
329
+ ```
330
+
331
+ **A turn runs a few steps then freezes, as if Codex paused itself.** The app-server **blocks on the client's reply** to each server request before continuing, so an unanswered method never surfaces as an error — the turn simply stops. Before 1.4.0 the bridge answered only 7 of 10 methods; the three that fell through to `default:` and got `-32601` were `item/permissions/requestApproval` (Codex asking to widen permissions — by far the most common), `mcpServer/elicitation/request` and `item/tool/call`. Verify with `npm test`. Get the authoritative method list from Codex itself rather than guessing:
332
+
333
+ ```bash
334
+ codex app-server generate-json-schema --out /tmp/codex-schema
335
+ python3 -c "import json;[print(v['properties']['method'].get('const') or v['properties']['method'].get('enum')) for v in json.load(open('/tmp/codex-schema/ServerRequest.json'))['oneOf']]"
336
+ ```
337
+
338
+ **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.
339
+
340
+ **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. `open_codex_thread` and `openInApp` now say so at the moment they open a thread the bridge is holding, instead of leaving the app's message to be decoded. Release it with `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.
341
+
342
+ **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.
343
+
344
+ **The path as given always goes first.** If this machine can already write there, nothing is rewritten — guessing over an explicit instruction would be the bug. Rewriting only happens for a path this machine cannot use, which is exactly the case it exists for: macOS mounts NTFS read-only, so the drive a Windows brief quotes is visible and useless at the same time.
345
+
346
+ A path counts as coming from elsewhere when it names a drive letter (`D:\project`) or an attached volume (`/Volumes/<label>/project`, `/mnt/<label>/project`, `/media/<user>/<label>/project`). No particular letter or label is blessed, so any dual-boot or external-disk layout works without configuration. The bridge then looks for that project name under `$HOME`, then under **its own parent directory** — a bridge checked out at `~/code/codex-mcp-bridge` makes `~/code` the obvious place to find a sibling project, which is measured rather than configured. Override the list with `CODEX_BRIDGE_WORKSPACE_ROOTS`, or set `CODEX_BRIDGE_REMAP=0` to switch the rewriting off entirely.
347
+
348
+ Note: Codex Desktop does **not** group threads by directory — the sidebar has `Pinned` and everything else, and the protocol exposes no API to file a thread under a section (`thread/start` takes no `sectionId`; `thread/metadata/update` only patches gitInfo). What ties a thread to a project is its `cwd`.
349
+
350
+ **Connection errors or a silent stall on the first call after rebooting.** The app-server does not survive a restart, so the first call after boot has to bring it back. Since 1.5.0 `connect()` retries once for boot-time transients, `onclose` only tears down its own socket (a late close from the previous one used to wipe the freshly established connection), and **a turn interrupted by a dropped connection ends immediately with status `disconnected`** instead of waiting out `timeoutSec` (240s by default). Verify with `npm test`. No LaunchAgent is needed for this — the bridge spawns an app-server on demand, and running a LaunchAgent alongside Codex Desktop only contends for the `~/.codex` state.
351
+
352
+ **Codex hangs when opening a new thread after adding an MCP server.** An MCP client waits on the `initialize` handshake, so a server that dies before answering looks like a *hang*, not an error. Paid for in practice here: `claude-bridge` called `execFileSync("ps", …)` while Codex spawns MCP servers with an **empty PATH** → `ENOENT` → death before the handshake → every `thread/start` timed out after 60s. Fix: call `/bin/ps` by absolute path inside a try/catch (`src/peer-protocol.mjs`). General lesson: **an MCP server must not depend on its parent's PATH** — test with `env -i PATH="" node <server>` before shipping.
353
+
354
+ ## Environment variables
355
+
356
+ The bridge reads these from the environment its MCP client hands it — there is no `.env` file and no configuration to commit.
357
+
358
+ | Variable | Default | Meaning |
359
+ |---|---|---|
360
+ | `CODEX_APP_SERVER_URL` | `ws://127.0.0.1:8791` | Shared **loopback-only** app-server endpoint. Non-loopback endpoints are rejected because this bridge does not implement remote WebSocket authentication. |
361
+ | `CODEX_BIN` | auto-detected | Path to `codex` used for autostart. |
362
+ | `CODEX_BRIDGE_AUTOSTART` | `1` | `0` = never spawn an app-server; one must already be running. |
363
+ | `CODEX_BRIDGE_ALLOWED_THREADS` | empty | Exact comma-separated thread IDs permitted for read/send/interrupt/open/list. Empty means no pre-existing thread access. |
364
+ | `CODEX_BRIDGE_ALLOWED_ROOTS` | empty (installer sets its repo root) | Absolute project directories permitted for `cwd`, separated by `:` (`;` on Windows). |
365
+ | `CODEX_BRIDGE_APPROVAL` | `deny` | How to answer approval requests from Codex. `approve` is ignored unless `CODEX_BRIDGE_AUTO_APPROVE_ACK=1` is also set. |
366
+ | `CODEX_BRIDGE_AUTO_APPROVE_ACK` | empty | Explicit acknowledgement required to enable automatic command/file approval; set to `1` only after reviewing the risk. |
367
+ | `CODEX_BRIDGE_APPROVAL_POLICY` | `on-request` | Policy applied to threads created by the bridge. It is no longer caller-controlled. |
368
+ | `CODEX_BRIDGE_SANDBOX` | `workspace-write` | Sandbox applied to threads created by the bridge: `read-only` or `workspace-write`; unrestricted `danger-full-access` is rejected. |
369
+ | `CODEX_BRIDGE_REMAP` | `1` | `0` disables cwd remapping between a shared drive and a local checkout. |
370
+ | `CODEX_BRIDGE_WORKSPACE_ROOTS` | `$HOME` and the bridge's parent directory | Where to look for a project by name, most preferred first, separated by `:` (`;` on Windows). Setting it replaces the derived roots rather than adding to them. |
371
+ | `CODEX_BRIDGE_MODEL` | from `~/.codex/config.toml` | Default model for threads and turns the bridge creates, e.g. `gpt-5.6-luna`. |
372
+ | `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
373
+ | `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
374
+ | `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
375
+ | `CLAUDE_DESKTOP_CONFIG` | auto-detected | Override the config path used by `install-claude-desktop.mjs`. |
376
+ | `CODEX_EXE` | auto-detected | Override the `codex` path used by the install scripts. |
377
+
378
+ **About model and effort:** the Codex desktop app **does not read** `model`/`model_reasoning_effort` from `~/.codex/config.toml` — it runs its own. A thread opened through the bridge can therefore be quietly weaker than the same work done in the app. Set `CODEX_BRIDGE_MODEL` and `CODEX_BRIDGE_EFFORT` in the MCP server's `env` so both paths match; `codex_bridge_status` prints what is in effect. Verify by reading Codex's own state rather than trusting that the override applied:
379
+
380
+ ```bash
381
+ sqlite3 ~/.codex/state_5.sqlite "select model, reasoning_effort from threads order by updated_at desc limit 3;"
382
+ ```
383
+
384
+ **About approvals:** Codex asks for command and patch approval unless `approval_policy` is `never`. The bridge denies those requests by default and logs each decision to stderr. Automatic approval is an explicit opt-in requiring both `CODEX_BRIDGE_APPROVAL=approve` and `CODEX_BRIDGE_AUTO_APPROVE_ACK=1`; keep the sandbox at `workspace-write` or `read-only`.
385
+
386
+ ## Sharing the app-server with an interactive Codex session
387
+
388
+ Point the TUI at the same endpoint so the TUI thread and the bridge thread are literally the same thread:
389
+
390
+ ```bash
391
+ codex --remote ws://127.0.0.1:8791
392
+ ```
393
+
394
+ Run the app-server manually (independent of bridge autostart):
395
+
396
+ ```bash
397
+ codex app-server --listen ws://127.0.0.1:8791
398
+ ```
399
+
400
+ ## Tests
401
+
402
+ ```bash
403
+ npm test
404
+ ```
405
+
406
+ Runs the whole suite with `node --test`. It needs no Codex install, no login and no quota: connection behaviour is exercised against a fake app-server (`test/helpers/fake-app-server.mjs`, a hand-rolled WebSocket with no extra dependency), and everything touching `~/.claude` or `~/.codex` runs against a throwaway `HOME`.
407
+
408
+ | File | Covers |
409
+ |---|---|
410
+ | `test/tool-contract.test.mjs` | both servers boot over stdio and every tool declares a title, a description, per-parameter descriptions and complete annotation hints |
411
+ | `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" |
412
+ | `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 |
413
+ | `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 |
414
+ | `test/peer-protocol.test.mjs` | frame round-trips, the session registry, transcript scanning, and a live peer endpoint over a real unix socket |
415
+ | `test/platform.test.mjs` | binary resolution, the PATH handed to child processes, per-OS config paths and cwd remapping |
416
+ | `test/repo-hygiene.test.mjs` | no environment file or build output is ever tracked, versions do not drift, documentation stays in English |
417
+
418
+ GitHub Actions runs the same command on every push and pull request, across Node 22 and 24 on Linux and macOS (`.github/workflows/ci.yml`). Windows is supported by the bridge but not covered by CI: the Codex → Claude direction needs unix sockets, so those tests skip there anyway.
419
+
420
+ Two checks need a real Codex and are not part of `npm test`:
421
+
422
+ ```bash
423
+ npm run check # boots the bridge against a real app-server and lists threads
424
+ npm run smoke # creates a thread, sends two turns, asserts Codex still remembers a codeword from the first
425
+ ```
426
+
427
+ `npm run smoke` spends quota — run it when a change touches turn handling, not on every commit.
428
+
429
+ The Codex → Claude direction has its own live check:
430
+
431
+ ```bash
432
+ npm run check:claude # list the Claude sessions Codex can see
433
+ CLAUDE_TARGET=<sessionId> CLAUDE_WAIT=150 npm run check:claude # deliver a message and wait for the answer
434
+ ```
435
+
436
+ A reply coming back proves both directions work.
437
+
438
+ ## Contributing
439
+
440
+ Conventions, the test gate and the tool-annotation contract are in [CONTRIBUTING.md](CONTRIBUTING.md).
441
+
442
+ ## License
443
+
444
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@minhspark/codex-mcp-bridge",
3
+ "version": "1.10.0",
4
+ "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
+ "keywords": [
6
+ "mcp",
7
+ "codex",
8
+ "claude-code",
9
+ "claude-desktop",
10
+ "app-server",
11
+ "model-context-protocol"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Bui Dang Minh",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/buidangminh23/codex-mcp-bridge.git"
18
+ },
19
+ "homepage": "https://github.com/buidangminh23/codex-mcp-bridge#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/buidangminh23/codex-mcp-bridge/issues"
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "codex-mcp-bridge": "src/index.mjs",
26
+ "claude-mcp-bridge": "src/claude-bridge.mjs",
27
+ "codex-mcp-bridge-install": "scripts/install-claude-desktop.mjs",
28
+ "claude-mcp-bridge-install": "scripts/install-codex-mcp.mjs"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "scripts",
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "start": "node src/index.mjs",
42
+ "claude-bridge": "node src/claude-bridge.mjs",
43
+ "app-server": "codex app-server --listen ws://127.0.0.1:8791",
44
+ "test": "node --test",
45
+ "check": "node scripts/check.mjs",
46
+ "check:claude": "node scripts/check-claude-bridge.mjs",
47
+ "smoke": "node scripts/smoke.mjs",
48
+ "install:desktop": "node scripts/install-claude-desktop.mjs",
49
+ "install:codex": "node scripts/install-codex-mcp.mjs",
50
+ "uninstall:codex": "node scripts/install-codex-mcp.mjs --remove",
51
+ "install:agent": "node scripts/install-launch-agent.mjs",
52
+ "uninstall:agent": "node scripts/install-launch-agent.mjs --uninstall",
53
+ "version": "node scripts/sync-version.mjs && git add src/index.mjs"
54
+ },
55
+ "engines": {
56
+ "node": ">=22"
57
+ },
58
+ "dependencies": {
59
+ "@modelcontextprotocol/sdk": "^1.30.0",
60
+ "zod": "^3.25.76"
61
+ }
62
+ }
@@ -0,0 +1,37 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const transport = new StdioClientTransport({
8
+ command: process.execPath,
9
+ args: [path.join(root, "src", "claude-bridge.mjs")],
10
+ stderr: "inherit",
11
+ });
12
+ const client = new Client({ name: "claude-bridge-check", version: "1.0.0" });
13
+ await client.connect(transport);
14
+
15
+ const tools = await client.listTools();
16
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
17
+
18
+ const status = await client.callTool({ name: "claude_bridge_status", arguments: {} });
19
+ console.log("\n--- claude_bridge_status ---\n" + status.content[0].text);
20
+
21
+ const listed = await client.callTool({ name: "list_claude_sessions", arguments: {} });
22
+ console.log("\n--- list_claude_sessions ---\n" + listed.content[0].text);
23
+
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);
34
+ }
35
+
36
+ await client.close();
37
+ process.exit(0);
@@ -0,0 +1,20 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const transport = new StdioClientTransport({
8
+ command: process.execPath,
9
+ args: [path.join(root, "src", "index.mjs")],
10
+ stderr: "inherit",
11
+ });
12
+ const client = new Client({ name: "bridge-check", version: "1.0.0" });
13
+
14
+ await client.connect(transport);
15
+ const tools = await client.listTools();
16
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
17
+ const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
18
+ console.log(listed.content[0].text.slice(0, 600));
19
+ await client.close();
20
+ process.exit(0);
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
7
+
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
10
+
11
+ /**
12
+ * When the package is installed as a dependency its own directory is never a
13
+ * workspace anybody wants to drive Codex in - defaulting to it produces an
14
+ * entry that starts fine and then refuses every thread. Fall back to the
15
+ * directory the operator ran the installer from instead, and say so out loud.
16
+ */
17
+ const installedAsDependency = root.split(path.sep).includes("node_modules");
18
+ const defaultRoots = installedAsDependency ? process.cwd() : root;
19
+
20
+ const nodeBin = process.env.NODE_EXE ?? process.execPath;
21
+ const codexBin = resolveCodexBin(process.env.CODEX_EXE);
22
+
23
+ if (!fs.existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin}`);
24
+ if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
25
+ throw new Error(
26
+ `codex binary not found (resolved to "${codexBin}"). Set CODEX_EXE to the absolute path of the codex CLI.`,
27
+ );
28
+ }
29
+
30
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
31
+
32
+ const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
33
+ cfg.mcpServers = cfg.mcpServers ?? {};
34
+ cfg.mcpServers["codex-bridge"] = {
35
+ command: nodeBin,
36
+ args: [path.join(root, "src", "index.mjs")],
37
+ env: {
38
+ CODEX_BIN: codexBin,
39
+ CODEX_APP_SERVER_URL: process.env.CODEX_APP_SERVER_URL ?? "ws://127.0.0.1:8791",
40
+ CODEX_BRIDGE_ALLOWED_ROOTS: process.env.CODEX_BRIDGE_ALLOWED_ROOTS ?? defaultRoots,
41
+ CODEX_BRIDGE_APPROVAL: process.env.CODEX_BRIDGE_APPROVAL ?? "deny",
42
+ CODEX_BRIDGE_APPROVAL_POLICY: process.env.CODEX_BRIDGE_APPROVAL_POLICY ?? "on-request",
43
+ CODEX_BRIDGE_SANDBOX: process.env.CODEX_BRIDGE_SANDBOX ?? "workspace-write",
44
+ ...(process.env.CODEX_BRIDGE_MODEL ? { CODEX_BRIDGE_MODEL: process.env.CODEX_BRIDGE_MODEL } : {}),
45
+ ...(process.env.CODEX_BRIDGE_EFFORT ? { CODEX_BRIDGE_EFFORT: process.env.CODEX_BRIDGE_EFFORT } : {}),
46
+ },
47
+ };
48
+
49
+ if (fs.existsSync(cfgPath)) {
50
+ const backup = `${cfgPath}.bak-${new Date().toISOString().slice(0, 10)}-codexbridge`;
51
+ if (!fs.existsSync(backup)) fs.copyFileSync(cfgPath, backup);
52
+ }
53
+ fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
54
+
55
+ console.log(`platform: ${PLATFORM_LABEL}`);
56
+ console.log(`updated ${cfgPath}`);
57
+ console.log(JSON.stringify(cfg.mcpServers["codex-bridge"], null, 2));
58
+ const writtenRoots = cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_ALLOWED_ROOTS;
59
+ if (writtenRoots.split(path.delimiter).some((entry) => entry.split(path.sep).includes("node_modules"))) {
60
+ console.log(
61
+ "\nWARNING: CODEX_BRIDGE_ALLOWED_ROOTS points inside an install directory, which is not a workspace.\n" +
62
+ "The bridge will start but refuse every thread. Re-run with the projects you actually work in:\n" +
63
+ ` CODEX_BRIDGE_ALLOWED_ROOTS="${["<project-a>", "<project-b>"].join(path.delimiter)}" codex-mcp-bridge-install`,
64
+ );
65
+ }
66
+
67
+ console.log("\nRestart Claude Desktop to load the bridge.");