@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.
- package/CHANGELOG.md +76 -0
- package/README.md +100 -11
- package/package.json +7 -3
- package/scripts/install-native-relay.mjs +100 -0
- package/scripts/sync-version.mjs +5 -1
- package/src/claude-bridge.mjs +19 -16
- package/src/index.mjs +4 -2
- package/src/native-relay-companion.mjs +343 -0
- package/src/native-relay.mjs +327 -0
- package/src/peer-protocol.mjs +76 -22
- package/src/platform.mjs +20 -1
- package/src/thread-delivery.mjs +85 -0
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"),
|
|
@@ -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
|
+
}
|