@minhspark/codex-mcp-bridge 1.11.3 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"),
@@ -0,0 +1,85 @@
1
+ import { NativeDesktopRelay } from "./native-relay.mjs";
2
+ import { runTurn } from "./turn.mjs";
3
+
4
+ /**
5
+ * Which backend puts a message into a Codex thread.
6
+ *
7
+ * There are two, and they are not interchangeable. The app-server path resumes
8
+ * the thread through a second app-server, which takes the per-thread writer
9
+ * lock - correct for a thread nobody else has open, and guaranteed to fail with
10
+ * `thread <id> already has an active writer` for a thread Codex Desktop is
11
+ * showing. The native path asks Codex Desktop's own app-server to deliver the
12
+ * message, so the app stays the single writer and the thread stays open.
13
+ *
14
+ * Naming the choice here rather than branching inside the relay keeps
15
+ * `claude-bridge` unaware of either mechanism: it asks for delivery and is told
16
+ * which backend did it.
17
+ */
18
+ export const NATIVE_BACKEND = "codex-desktop-native";
19
+ export const APP_SERVER_BACKEND = "app-server";
20
+ const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed", "disconnected"]);
21
+
22
+ export function createThreadDelivery({
23
+ codex,
24
+ relay = new NativeDesktopRelay(),
25
+ log = () => {},
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",
31
+ } = {}) {
32
+ let reportedUnavailable = null;
33
+
34
+ /**
35
+ * Falling back is right when the companion never answered - an absent relay
36
+ * says nothing about the target thread, and the older path is exactly as good
37
+ * as it was before this backend existed. It is wrong once the companion has
38
+ * answered: Codex has already refused, and retrying through a second
39
+ * app-server only spawns a process that contends for the ~/.codex state and
40
+ * then fails on the writer lock the native path exists to avoid.
41
+ */
42
+ async function deliver(threadId, text) {
43
+ const status = relay.status();
44
+ if (status.enabled) {
45
+ try {
46
+ const ack = await relay.sendMessage(threadId, text);
47
+ reportedUnavailable = null;
48
+ return { backend: NATIVE_BACKEND, threadId, ack };
49
+ } catch (err) {
50
+ if (err.reachedCompanion) throw err;
51
+ log(`native relay unreachable (${err.message}); falling back to the app-server path`);
52
+ }
53
+ } else if (status.reason !== reportedUnavailable) {
54
+ reportedUnavailable = status.reason;
55
+ log(`native relay not in use: ${status.reason}`);
56
+ }
57
+
58
+ if (!codex) throw new Error("No Codex app-server client is configured to deliver this message");
59
+ await codex.ensureThreadAttached(threadId);
60
+ const turn = await runTurn(codex, {
61
+ threadId,
62
+ input: [{ type: "text", text }],
63
+ timeoutMs,
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
+ }
74
+ return { backend: APP_SERVER_BACKEND, threadId, turn };
75
+ }
76
+
77
+ function describe() {
78
+ const status = relay.status();
79
+ return status.enabled
80
+ ? `${NATIVE_BACKEND} via ${status.socketPath}`
81
+ : `${APP_SERVER_BACKEND} (${status.reason})`;
82
+ }
83
+
84
+ return { deliver, describe };
85
+ }