@minhspark/codex-mcp-bridge 1.12.0 → 1.12.2
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 +34 -0
- package/README.md +47 -31
- package/package.json +2 -2
- package/scripts/check-claude-bridge.mjs +24 -20
- package/scripts/check.mjs +13 -7
- package/scripts/install-claude-desktop.mjs +1 -0
- package/scripts/install-native-relay.mjs +12 -27
- package/scripts/smoke.mjs +38 -35
- package/scripts/sync-version.mjs +1 -0
- package/src/app-server-client.mjs +271 -65
- package/src/claude-bridge.mjs +6 -6
- package/src/index.mjs +115 -81
- package/src/native-relay-companion.mjs +113 -68
- package/src/native-relay.mjs +181 -37
- package/src/peer-protocol.mjs +129 -27
- package/src/platform.mjs +30 -4
- package/src/thread-delivery.mjs +24 -8
- package/src/turn.mjs +28 -10
package/src/index.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
5
7
|
|
|
6
8
|
import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
|
|
7
9
|
import {
|
|
@@ -21,7 +23,7 @@ import {
|
|
|
21
23
|
import { runTurn } from "./turn.mjs";
|
|
22
24
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
23
25
|
|
|
24
|
-
const VERSION = "1.12.
|
|
26
|
+
const VERSION = "1.12.2";
|
|
25
27
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -38,6 +40,7 @@ const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
|
|
|
38
40
|
? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
|
|
39
41
|
: IS_WINDOWS;
|
|
40
42
|
const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
43
|
+
const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
|
|
41
44
|
const security = new BridgeSecurityPolicy();
|
|
42
45
|
|
|
43
46
|
const client = new CodexAppServerClient({
|
|
@@ -114,6 +117,15 @@ async function createCodexThread({ cwd, model, name, prompt }) {
|
|
|
114
117
|
});
|
|
115
118
|
const thread = res?.thread ?? {};
|
|
116
119
|
if (!thread.id) throw new Error("Codex app-server created no thread id");
|
|
120
|
+
try {
|
|
121
|
+
security.assertCwd(thread.cwd);
|
|
122
|
+
if (!path.isAbsolute(thread.cwd) || path.relative(realpathSync(workspace.path), realpathSync(thread.cwd))) {
|
|
123
|
+
throw new Error("Codex app-server created the thread in a different workspace than requested");
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
await client.releaseThread(thread.id).catch(() => {});
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
117
129
|
const threadName = name || prompt ? threadNameFor({ cwd: thread.cwd ?? workspace.path, prompt, name }) : null;
|
|
118
130
|
if (threadName) {
|
|
119
131
|
await client.call("thread/name/set", { threadId: thread.id, name: threadName });
|
|
@@ -133,29 +145,22 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
|
|
|
133
145
|
const notes = [];
|
|
134
146
|
let canOpenAfterRelease = true;
|
|
135
147
|
const terminal = TERMINAL_TURN_STATUSES.has(result.status);
|
|
148
|
+
const releasable = RELEASE_TURN_STATUSES.has(result.status);
|
|
136
149
|
|
|
137
|
-
if (releaseAfterTurn &&
|
|
150
|
+
if (releaseAfterTurn && releasable) {
|
|
138
151
|
try {
|
|
139
|
-
const released = await client.
|
|
140
|
-
if (released.
|
|
141
|
-
|
|
142
|
-
canOpenAfterRelease = false;
|
|
143
|
-
notes.push(
|
|
144
|
-
`stop requested for app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}, but it is still listening`,
|
|
145
|
-
);
|
|
146
|
-
notes.push("WARNING: the app-server is still listening, so the desktop thread was not opened to avoid another lock");
|
|
147
|
-
} else {
|
|
148
|
-
notes.push(
|
|
149
|
-
`released app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}; Codex Desktop can write this thread`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
+
const released = await client.releaseThread(threadId);
|
|
153
|
+
if (released.released) {
|
|
154
|
+
notes.push(`released thread ${threadId}; other app-server threads remain active`);
|
|
152
155
|
} else {
|
|
153
156
|
canOpenAfterRelease = false;
|
|
154
|
-
notes.push(
|
|
157
|
+
notes.push(released.unsubscribed
|
|
158
|
+
? `unsubscribed from thread ${threadId}; desktop opening is deferred until the server unloads it`
|
|
159
|
+
: `could not release thread: ${released.reason ?? released.status}`);
|
|
155
160
|
}
|
|
156
161
|
} catch (err) {
|
|
157
162
|
canOpenAfterRelease = false;
|
|
158
|
-
notes.push(`could not release
|
|
163
|
+
notes.push(`could not release thread: ${err.message}`);
|
|
159
164
|
}
|
|
160
165
|
}
|
|
161
166
|
|
|
@@ -265,7 +270,7 @@ server.registerTool(
|
|
|
265
270
|
releaseAfterTurn: z
|
|
266
271
|
.boolean()
|
|
267
272
|
.optional()
|
|
268
|
-
.describe("
|
|
273
|
+
.describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
|
|
269
274
|
},
|
|
270
275
|
annotations: {
|
|
271
276
|
readOnlyHint: false,
|
|
@@ -356,7 +361,7 @@ server.registerTool(
|
|
|
356
361
|
releaseAfterTurn: z
|
|
357
362
|
.boolean()
|
|
358
363
|
.optional()
|
|
359
|
-
.describe("
|
|
364
|
+
.describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
|
|
360
365
|
},
|
|
361
366
|
annotations: {
|
|
362
367
|
readOnlyHint: false,
|
|
@@ -366,64 +371,66 @@ server.registerTool(
|
|
|
366
371
|
},
|
|
367
372
|
},
|
|
368
373
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Opening the thread in the app comes after both gates. It ran first
|
|
394
|
-
* once, which meant a thread this bridge was about to refuse still got
|
|
395
|
-
* raised on screen - a refusal that leaked which threads exist.
|
|
396
|
-
*/
|
|
397
|
-
if (shouldOpen && !shouldRelease) {
|
|
398
|
-
try {
|
|
399
|
-
notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
|
|
400
|
-
} catch (err) {
|
|
401
|
-
notes.push(`could not open the thread in the Codex app: ${err.message}`);
|
|
374
|
+
return client.withThread(threadId, async () => {
|
|
375
|
+
const notes = [];
|
|
376
|
+
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
377
|
+
const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
|
|
378
|
+
try {
|
|
379
|
+
const authorizedThread = await assertThreadAccess(threadId);
|
|
380
|
+
let resolvedCwd = null;
|
|
381
|
+
if (cwd) {
|
|
382
|
+
const workspace = resolveWorkspacePath(cwd);
|
|
383
|
+
security.assertCwd(workspace.path);
|
|
384
|
+
resolvedCwd = workspace.path;
|
|
385
|
+
if (workspace.note) notes.push(workspace.note);
|
|
386
|
+
} else if (authorizedThread?.cwd) {
|
|
387
|
+
const workspace = resolveWorkspacePath(authorizedThread.cwd);
|
|
388
|
+
resolvedCwd = workspace.path;
|
|
389
|
+
if (workspace.note) notes.push(workspace.note);
|
|
390
|
+
}
|
|
391
|
+
const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
|
|
392
|
+
const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
|
|
393
|
+
security.assertCwd(attachedThread?.cwd);
|
|
394
|
+
if (name) {
|
|
395
|
+
await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
|
|
396
|
+
notes.push(`session name: ${name.trim().slice(0, 200)}`);
|
|
402
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Opening the thread in the app comes after both gates. It ran first
|
|
400
|
+
* once, which meant a thread this bridge was about to refuse still got
|
|
401
|
+
* raised on screen - a refusal that leaked which threads exist.
|
|
402
|
+
*/
|
|
403
|
+
if (shouldOpen && !shouldRelease) {
|
|
404
|
+
try {
|
|
405
|
+
notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
notes.push(`could not open the thread in the Codex app: ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const result = await runTurn(client, {
|
|
411
|
+
threadId,
|
|
412
|
+
input: [{ type: "text", text: prompt }],
|
|
413
|
+
timeoutMs: (timeoutSec ?? 240) * 1000,
|
|
414
|
+
turnOverrides: {
|
|
415
|
+
...(resolvedCwd ? { cwd: resolvedCwd } : {}),
|
|
416
|
+
...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
|
|
417
|
+
...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const body = formatTurn(result);
|
|
421
|
+
const failed = result.status === "failed" || result.status === "disconnected";
|
|
422
|
+
notes.push(...(await finishDesktopHandoff({
|
|
423
|
+
threadId,
|
|
424
|
+
result,
|
|
425
|
+
openInApp: shouldOpen,
|
|
426
|
+
releaseAfterTurn: shouldRelease,
|
|
427
|
+
})));
|
|
428
|
+
const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
429
|
+
return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return failure(err);
|
|
403
432
|
}
|
|
404
|
-
|
|
405
|
-
threadId,
|
|
406
|
-
input: [{ type: "text", text: prompt }],
|
|
407
|
-
timeoutMs: (timeoutSec ?? 240) * 1000,
|
|
408
|
-
turnOverrides: {
|
|
409
|
-
...(resolvedCwd ? { cwd: resolvedCwd } : {}),
|
|
410
|
-
...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
|
|
411
|
-
...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
|
|
412
|
-
},
|
|
413
|
-
});
|
|
414
|
-
const body = formatTurn(result);
|
|
415
|
-
const failed = result.status === "failed" || result.status === "disconnected";
|
|
416
|
-
notes.push(...(await finishDesktopHandoff({
|
|
417
|
-
threadId,
|
|
418
|
-
result,
|
|
419
|
-
openInApp: shouldOpen,
|
|
420
|
-
releaseAfterTurn: shouldRelease,
|
|
421
|
-
})));
|
|
422
|
-
const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
423
|
-
return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
|
|
424
|
-
} catch (err) {
|
|
425
|
-
return failure(err);
|
|
426
|
-
}
|
|
433
|
+
});
|
|
427
434
|
},
|
|
428
435
|
);
|
|
429
436
|
|
|
@@ -457,16 +464,40 @@ server.registerTool(
|
|
|
457
464
|
}
|
|
458
465
|
if (searchTerm) params.searchTerm = searchTerm;
|
|
459
466
|
const method = loadedOnly ? "thread/loaded/list" : "thread/list";
|
|
460
|
-
const
|
|
461
|
-
const
|
|
462
|
-
|
|
467
|
+
const rows = [];
|
|
468
|
+
const seenIds = new Set();
|
|
469
|
+
const seenCursors = new Set();
|
|
470
|
+
let cursor;
|
|
471
|
+
do {
|
|
472
|
+
const res = await client.call(method, loadedOnly ? { limit: params.limit, ...(cursor ? { cursor } : {}) } : params);
|
|
473
|
+
let threads = res?.data ?? res?.threads ?? [];
|
|
474
|
+
if (loadedOnly) {
|
|
475
|
+
threads = await Promise.all(threads.map(async (threadId) => {
|
|
476
|
+
if (seenIds.has(threadId)) return null;
|
|
477
|
+
seenIds.add(threadId);
|
|
478
|
+
try {
|
|
479
|
+
const read = await client.call("thread/read", { threadId });
|
|
480
|
+
return read?.thread ?? null;
|
|
481
|
+
} catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
rows.push(...security.filterThreads(threads.flatMap((thread) => {
|
|
463
487
|
try {
|
|
464
|
-
|
|
488
|
+
const normalized = normalizeThreadCwd(thread, { strict: true });
|
|
489
|
+
if (loadedOnly && params.cwd && (!normalized?.cwd || path.relative(params.cwd.paths[0], normalized.cwd))) return [];
|
|
490
|
+
if (loadedOnly && searchTerm && !String(normalized?.name ?? normalized?.preview ?? "").toLowerCase().includes(searchTerm.toLowerCase())) return [];
|
|
491
|
+
return [normalized];
|
|
465
492
|
} catch {
|
|
466
493
|
return [];
|
|
467
494
|
}
|
|
468
|
-
})
|
|
469
|
-
|
|
495
|
+
})));
|
|
496
|
+
cursor = res?.nextCursor;
|
|
497
|
+
if (!loadedOnly || !cursor || seenCursors.has(cursor)) break;
|
|
498
|
+
seenCursors.add(cursor);
|
|
499
|
+
} while (rows.length < params.limit);
|
|
500
|
+
rows.splice(params.limit);
|
|
470
501
|
if (!rows.length) {
|
|
471
502
|
return textResult(
|
|
472
503
|
security.summary().allowedRoots.length
|
|
@@ -646,6 +677,9 @@ server.registerTool(
|
|
|
646
677
|
async () => {
|
|
647
678
|
try {
|
|
648
679
|
const result = await client.stopServer();
|
|
680
|
+
if (result.stillListening) {
|
|
681
|
+
return textResult("The app-server is still listening after the stop request; its thread writer locks are not confirmed released.", true);
|
|
682
|
+
}
|
|
649
683
|
return textResult(
|
|
650
684
|
result.stopped
|
|
651
685
|
? `Stopped the shared app-server (pid ${result.pids.join(", ")}). Its thread writer locks are released, so the Codex desktop app now owns ~/.codex and every thread it was holding.`
|
|
@@ -6,32 +6,18 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
-
import { z } from "zod";
|
|
10
9
|
|
|
11
10
|
import {
|
|
12
11
|
MAX_FRAME_BYTES,
|
|
13
12
|
NATIVE_DISPATCH_METHOD,
|
|
13
|
+
NativeToolsClient,
|
|
14
14
|
RELAY_PROTOCOL_VERSION,
|
|
15
|
-
nativeDispatchParams,
|
|
16
15
|
relaySocketPath,
|
|
17
16
|
resolveRelayThreadId,
|
|
18
17
|
} from "./native-relay.mjs";
|
|
19
|
-
import { PLATFORM_LABEL } from "./platform.mjs";
|
|
18
|
+
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
* The companion half of the Codex Desktop native relay.
|
|
23
|
-
*
|
|
24
|
-
* Codex Desktop launches this as one of its own MCP servers, so the connection
|
|
25
|
-
* it answers on belongs to the app's real app-server - the one already holding
|
|
26
|
-
* the writer lock of every thread the human has open. Asking that app-server to
|
|
27
|
-
* deliver a message is therefore not a second writer, and the thread stays open
|
|
28
|
-
* and owned by Codex Desktop throughout.
|
|
29
|
-
*
|
|
30
|
-
* Everything else is deliberately small: a private socket, one accepted shape
|
|
31
|
-
* (`{ targetThreadId, message }`), one dispatch, one acknowledgement.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
const VERSION = "1.12.0";
|
|
20
|
+
const VERSION = "1.12.2";
|
|
35
21
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
36
22
|
|
|
37
23
|
function errorResponse(code, message) {
|
|
@@ -56,6 +42,11 @@ export async function handleRelayRequest(
|
|
|
56
42
|
payload,
|
|
57
43
|
{ dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
|
|
58
44
|
) {
|
|
45
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
|
|
46
|
+
Object.keys(payload).some((key) => !["v", "targetThreadId", "message"].includes(key)) ||
|
|
47
|
+
(payload.v !== undefined && payload.v !== RELAY_PROTOCOL_VERSION)) {
|
|
48
|
+
return errorResponse("RELAY_BAD_REQUEST", "expected a relay request with targetThreadId and message");
|
|
49
|
+
}
|
|
59
50
|
const targetThreadId = typeof payload?.targetThreadId === "string" ? payload.targetThreadId.trim() : "";
|
|
60
51
|
const message = typeof payload?.message === "string" ? payload.message : "";
|
|
61
52
|
|
|
@@ -84,6 +75,10 @@ export async function handleRelayRequest(
|
|
|
84
75
|
|
|
85
76
|
try {
|
|
86
77
|
const result = await dispatch({ executorThreadId, targetThreadId, message });
|
|
78
|
+
if (result?.success !== true || result?.isError === true) {
|
|
79
|
+
const detail = typeof result?.error === "string" ? result.error : result?.error?.message;
|
|
80
|
+
return errorResponse("NATIVE_DISPATCH_FAILED", detail ?? "Codex Desktop did not confirm successful native dispatch");
|
|
81
|
+
}
|
|
87
82
|
return { ok: true, v: RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
|
|
88
83
|
} catch (err) {
|
|
89
84
|
return errorResponse(errorCode(err), err?.message ?? String(err));
|
|
@@ -91,20 +86,19 @@ export async function handleRelayRequest(
|
|
|
91
86
|
}
|
|
92
87
|
|
|
93
88
|
/**
|
|
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.
|
|
89
|
+
* Listens on a private local socket or Windows named pipe and answers one NDJSON line per request.
|
|
90
|
+
*
|
|
91
|
+
* POSIX sockets are mode 0600 inside the Codex home directory. Windows uses the
|
|
92
|
+
* Claude-compatible local named-pipe namespace instead of a filesystem mode.
|
|
101
93
|
*/
|
|
102
94
|
export class RelaySocketServer {
|
|
103
95
|
constructor({
|
|
104
96
|
socketPath,
|
|
105
97
|
dispatch,
|
|
106
98
|
resolveExecutor = resolveRelayThreadId,
|
|
107
|
-
restrictSocket = (target) =>
|
|
99
|
+
restrictSocket = (target) => {
|
|
100
|
+
if (!IS_WINDOWS) fs.chmodSync(target, 0o600);
|
|
101
|
+
},
|
|
108
102
|
log: logFn = () => {},
|
|
109
103
|
} = {}) {
|
|
110
104
|
this.socketPath = socketPath;
|
|
@@ -114,11 +108,13 @@ export class RelaySocketServer {
|
|
|
114
108
|
this.log = logFn;
|
|
115
109
|
this.server = null;
|
|
116
110
|
this.started = false;
|
|
111
|
+
this.connections = new Set();
|
|
112
|
+
this.processHandlers = new Map();
|
|
117
113
|
}
|
|
118
114
|
|
|
119
115
|
async start() {
|
|
120
116
|
if (this.started) return this.socketPath;
|
|
121
|
-
fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
|
|
117
|
+
if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
|
|
122
118
|
|
|
123
119
|
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
124
120
|
await this.#listen({ replaceStale: true });
|
|
@@ -140,12 +136,16 @@ export class RelaySocketServer {
|
|
|
140
136
|
this.started = true;
|
|
141
137
|
|
|
142
138
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
143
|
-
|
|
139
|
+
const handler = () => {
|
|
144
140
|
this.stop();
|
|
145
141
|
process.exit(0);
|
|
146
|
-
}
|
|
142
|
+
};
|
|
143
|
+
this.processHandlers.set(signal, handler);
|
|
144
|
+
process.on(signal, handler);
|
|
147
145
|
}
|
|
148
|
-
|
|
146
|
+
const onExit = () => this.stop();
|
|
147
|
+
this.processHandlers.set("exit", onExit);
|
|
148
|
+
process.on("exit", onExit);
|
|
149
149
|
|
|
150
150
|
this.log(`relay socket listening on ${this.socketPath}`);
|
|
151
151
|
return this.socketPath;
|
|
@@ -171,6 +171,13 @@ export class RelaySocketServer {
|
|
|
171
171
|
});
|
|
172
172
|
} catch (err) {
|
|
173
173
|
if (err.code !== "EADDRINUSE" || !replaceStale) throw err;
|
|
174
|
+
if (IS_WINDOWS) {
|
|
175
|
+
if (await this.#socketIsLive()) {
|
|
176
|
+
throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
177
|
+
}
|
|
178
|
+
await new Promise((resolve) => globalThis.setTimeout(resolve, 100));
|
|
179
|
+
return this.#listen({ replaceStale: false });
|
|
180
|
+
}
|
|
174
181
|
if (await this.#socketIsLive()) {
|
|
175
182
|
throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
176
183
|
}
|
|
@@ -195,22 +202,25 @@ export class RelaySocketServer {
|
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
#handleConnection(socket) {
|
|
198
|
-
let buffer =
|
|
205
|
+
let buffer = Buffer.alloc(0);
|
|
206
|
+
let handled = false;
|
|
207
|
+
this.connections.add(socket);
|
|
208
|
+
socket.on("close", () => this.connections.delete(socket));
|
|
209
|
+
socket.setTimeout(30000, () => socket.destroy());
|
|
199
210
|
socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
|
|
200
211
|
socket.on("data", (chunk) => {
|
|
201
|
-
|
|
202
|
-
|
|
212
|
+
if (handled) return;
|
|
213
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
214
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
215
|
+
handled = true;
|
|
203
216
|
this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
|
|
204
|
-
socket.destroy();
|
|
205
217
|
return;
|
|
206
218
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
void this.#handleLine(socket, line);
|
|
213
|
-
}
|
|
219
|
+
const index = buffer.indexOf(10);
|
|
220
|
+
if (index < 0) return;
|
|
221
|
+
handled = true;
|
|
222
|
+
void this.#handleLine(socket, buffer.subarray(0, index).toString("utf8"));
|
|
223
|
+
buffer = Buffer.alloc(0);
|
|
214
224
|
});
|
|
215
225
|
}
|
|
216
226
|
|
|
@@ -233,20 +243,76 @@ export class RelaySocketServer {
|
|
|
233
243
|
|
|
234
244
|
#reply(socket, response) {
|
|
235
245
|
if (socket.destroyed) return;
|
|
236
|
-
socket.
|
|
246
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
237
247
|
}
|
|
238
248
|
|
|
239
249
|
stop() {
|
|
250
|
+
for (const [event, handler] of this.processHandlers) process.off(event, handler);
|
|
251
|
+
this.processHandlers.clear();
|
|
252
|
+
for (const socket of this.connections) socket.destroy();
|
|
253
|
+
this.connections.clear();
|
|
240
254
|
try {
|
|
241
255
|
this.server?.close();
|
|
242
256
|
} catch {}
|
|
243
257
|
try {
|
|
244
|
-
if (this.started) fs.rmSync(this.socketPath, { force: true });
|
|
258
|
+
if (this.started && !IS_WINDOWS) fs.rmSync(this.socketPath, { force: true });
|
|
245
259
|
} catch {}
|
|
246
260
|
this.started = false;
|
|
247
261
|
}
|
|
248
262
|
}
|
|
249
263
|
|
|
264
|
+
export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () => {}, retryDelayMs = 250, maxRetryDelayMs = 30000 }) {
|
|
265
|
+
let stopped = false;
|
|
266
|
+
let timer = null;
|
|
267
|
+
let delayMs = retryDelayMs;
|
|
268
|
+
let resolveReady;
|
|
269
|
+
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
270
|
+
const attempt = async () => {
|
|
271
|
+
if (stopped) return;
|
|
272
|
+
try {
|
|
273
|
+
await nativeTools.connect();
|
|
274
|
+
if (stopped) {
|
|
275
|
+
nativeTools.close();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
await relay.start();
|
|
279
|
+
if (stopped) {
|
|
280
|
+
relay.stop();
|
|
281
|
+
nativeTools.close();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
resolveReady(true);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (stopped) return;
|
|
287
|
+
nativeTools.close();
|
|
288
|
+
if (!nativeTools.socketPath) {
|
|
289
|
+
logFn(`native relay unavailable (${err.message})`);
|
|
290
|
+
resolveReady(false);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
logFn(`native relay unavailable (${err.message}); retrying in ${delayMs}ms`);
|
|
294
|
+
timer = globalThis.setTimeout(() => {
|
|
295
|
+
timer = null;
|
|
296
|
+
void attempt();
|
|
297
|
+
}, delayMs);
|
|
298
|
+
timer.unref();
|
|
299
|
+
delayMs = Math.min(delayMs * 2, maxRetryDelayMs);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
void attempt();
|
|
303
|
+
return {
|
|
304
|
+
ready,
|
|
305
|
+
stop() {
|
|
306
|
+
if (stopped) return;
|
|
307
|
+
stopped = true;
|
|
308
|
+
globalThis.clearTimeout(timer);
|
|
309
|
+
nativeTools.close();
|
|
310
|
+
relay.stop();
|
|
311
|
+
resolveReady(false);
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
250
316
|
/**
|
|
251
317
|
* `import.meta.main` is Node 24 and up, and this project supports Node 22, so
|
|
252
318
|
* the entry point is detected by comparing the resolved argv path instead.
|
|
@@ -265,20 +331,8 @@ if (invokedDirectly) {
|
|
|
265
331
|
},
|
|
266
332
|
);
|
|
267
333
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
* launch this process, which is what keeps the app the single writer. Sent as
|
|
271
|
-
* a plain JSON-RPC request rather than through a typed helper because the
|
|
272
|
-
* method is an internal of the app, not part of the MCP specification.
|
|
273
|
-
*/
|
|
274
|
-
const dispatch = ({ executorThreadId, targetThreadId, message }) =>
|
|
275
|
-
mcp.server.request(
|
|
276
|
-
{
|
|
277
|
-
method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
278
|
-
params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
|
|
279
|
-
},
|
|
280
|
-
z.any(),
|
|
281
|
-
);
|
|
334
|
+
const nativeTools = new NativeToolsClient();
|
|
335
|
+
const dispatch = (args) => nativeTools.dispatch(args);
|
|
282
336
|
|
|
283
337
|
const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
|
|
284
338
|
|
|
@@ -313,6 +367,7 @@ if (invokedDirectly) {
|
|
|
313
367
|
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
|
|
314
368
|
`executor: ${executor}`,
|
|
315
369
|
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
370
|
+
`native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
|
|
316
371
|
].join("\n"),
|
|
317
372
|
},
|
|
318
373
|
],
|
|
@@ -320,18 +375,8 @@ if (invokedDirectly) {
|
|
|
320
375
|
},
|
|
321
376
|
);
|
|
322
377
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
* `initialize` handshake, so a process that dies before answering reads as a
|
|
326
|
-
* hang rather than an error - the same failure mode `claude-bridge` already
|
|
327
|
-
* guards its peer endpoint against.
|
|
328
|
-
*/
|
|
329
|
-
try {
|
|
330
|
-
await relay.start();
|
|
331
|
-
} catch (err) {
|
|
332
|
-
log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
|
|
333
|
-
}
|
|
334
|
-
|
|
378
|
+
const startup = startRelayWhenAvailable({ nativeTools, relay, log });
|
|
379
|
+
mcp.server.onclose = () => startup.stop();
|
|
335
380
|
await mcp.connect(new StdioServerTransport());
|
|
336
381
|
log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
|
|
337
382
|
}
|