@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 +17 -0
- package/README.md +24 -24
- package/package.json +1 -1
- package/scripts/install-native-relay.mjs +8 -8
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +4 -2
- package/src/native-relay-companion.mjs +18 -12
- package/src/native-relay.mjs +13 -5
- package/src/peer-protocol.mjs +76 -22
- package/src/platform.mjs +20 -1
- package/src/thread-delivery.mjs +14 -0
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)
|
|
7
7
|
[](https://m8ven.ai/mcp/buidangminh23-codex-mcp-bridge-1ke8t1)
|
|
8
8
|
|
|
9
|
-
A **two-way** bridge between Claude and Codex: Claude pushes prompts into a **live Codex thread**, and Codex messages back into a **running Claude Code session**. Each side sees the other's sessions and follows the conversation inside its own app. Runs on **macOS, Windows and Linux** (the Codex → Claude direction
|
|
9
|
+
A **two-way** bridge between Claude and Codex: Claude pushes prompts into a **live Codex thread**, and Codex messages back into a **running Claude Code session**. Each side sees the other's sessions and follows the conversation inside its own app. Runs on **macOS, Windows and Linux** (the Codex → Claude direction uses unix sockets on macOS/Linux and named pipes on Windows).
|
|
10
10
|
|
|
11
11
|
This is not `codex exec`, which starts a fresh session every time. The bridge speaks JSON-RPC to the real Codex app-server, so the thread keeps its history, its `cwd`, its model and its rollout file — and a human can watch it run in the Codex desktop app instead of reading the transcript afterwards.
|
|
12
12
|
|
|
@@ -20,13 +20,13 @@ Claude Desktop ──────┤ stdio We
|
|
|
20
20
|
└────────────────────────────────────────────────────────────────────┘
|
|
21
21
|
|
|
22
22
|
┌──────────────── claude-bridge (runs inside Codex) ──────────────────┐
|
|
23
|
-
Codex ───────────────┤ stdio
|
|
23
|
+
Codex ───────────────┤ stdio unix socket / named pipe for the Claude session ├──> Claude Code session ──> message shows in Claude Desktop
|
|
24
24
|
└────────────────────────────────────────────────────────────────────┘
|
|
25
25
|
|
|
26
26
|
Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
claude-bridge ───────┤ unix socket
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
|
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
|
|
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.**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
499
|
-
| `CODEX_NATIVE_RELAY_SOCKET` | `$CODEX_HOME/native-relay.sock` | Override the companion
|
|
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,
|
|
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
|
|
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
|
@@ -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 [
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
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.
|
|
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...");
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
24
|
+
const VERSION = "1.12.1";
|
|
25
25
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -38,6 +38,7 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
|
|
|
38
38
|
? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
|
|
39
39
|
: IS_WINDOWS;
|
|
40
40
|
const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
41
|
+
const RELEASE_TURN_STATUSES = new Set([...TERMINAL_TURN_STATUSES, "disconnected"]);
|
|
41
42
|
const security = new BridgeSecurityPolicy();
|
|
42
43
|
|
|
43
44
|
const client = new CodexAppServerClient({
|
|
@@ -133,8 +134,9 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
|
|
|
133
134
|
const notes = [];
|
|
134
135
|
let canOpenAfterRelease = true;
|
|
135
136
|
const terminal = TERMINAL_TURN_STATUSES.has(result.status);
|
|
137
|
+
const releasable = RELEASE_TURN_STATUSES.has(result.status);
|
|
136
138
|
|
|
137
|
-
if (releaseAfterTurn &&
|
|
139
|
+
if (releaseAfterTurn && releasable) {
|
|
138
140
|
try {
|
|
139
141
|
const released = await client.stopServer();
|
|
140
142
|
if (released.stopped) {
|
|
@@ -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.
|
|
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
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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) =>
|
|
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
|
}
|
package/src/native-relay.mjs
CHANGED
|
@@ -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
|
|
161
|
+
reason: `the Codex Desktop native relay is unavailable on ${PLATFORM_LABEL}`,
|
|
154
162
|
};
|
|
155
163
|
}
|
|
156
|
-
if (!
|
|
164
|
+
if (!isRelayEndpoint(socketPath)) {
|
|
157
165
|
return {
|
|
158
166
|
enabled: false,
|
|
159
167
|
mode,
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
96
|
-
|
|
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) &&
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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"),
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -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
|
|