@minhspark/codex-mcp-bridge 1.12.1 → 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 +17 -0
- package/README.md +24 -8
- 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 +5 -20
- 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 +113 -81
- package/src/native-relay-companion.mjs +96 -57
- package/src/native-relay.mjs +168 -32
- package/src/peer-protocol.mjs +53 -5
- package/src/platform.mjs +10 -3
- package/src/thread-delivery.mjs +19 -17
- 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,7 +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"]);
|
|
41
|
-
const RELEASE_TURN_STATUSES =
|
|
43
|
+
const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
|
|
42
44
|
const security = new BridgeSecurityPolicy();
|
|
43
45
|
|
|
44
46
|
const client = new CodexAppServerClient({
|
|
@@ -115,6 +117,15 @@ async function createCodexThread({ cwd, model, name, prompt }) {
|
|
|
115
117
|
});
|
|
116
118
|
const thread = res?.thread ?? {};
|
|
117
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
|
+
}
|
|
118
129
|
const threadName = name || prompt ? threadNameFor({ cwd: thread.cwd ?? workspace.path, prompt, name }) : null;
|
|
119
130
|
if (threadName) {
|
|
120
131
|
await client.call("thread/name/set", { threadId: thread.id, name: threadName });
|
|
@@ -138,26 +149,18 @@ async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterT
|
|
|
138
149
|
|
|
139
150
|
if (releaseAfterTurn && releasable) {
|
|
140
151
|
try {
|
|
141
|
-
const released = await client.
|
|
142
|
-
if (released.
|
|
143
|
-
|
|
144
|
-
canOpenAfterRelease = false;
|
|
145
|
-
notes.push(
|
|
146
|
-
`stop requested for app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}, but it is still listening`,
|
|
147
|
-
);
|
|
148
|
-
notes.push("WARNING: the app-server is still listening, so the desktop thread was not opened to avoid another lock");
|
|
149
|
-
} else {
|
|
150
|
-
notes.push(
|
|
151
|
-
`released app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}; Codex Desktop can write this thread`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
152
|
+
const released = await client.releaseThread(threadId);
|
|
153
|
+
if (released.released) {
|
|
154
|
+
notes.push(`released thread ${threadId}; other app-server threads remain active`);
|
|
154
155
|
} else {
|
|
155
156
|
canOpenAfterRelease = false;
|
|
156
|
-
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}`);
|
|
157
160
|
}
|
|
158
161
|
} catch (err) {
|
|
159
162
|
canOpenAfterRelease = false;
|
|
160
|
-
notes.push(`could not release
|
|
163
|
+
notes.push(`could not release thread: ${err.message}`);
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
|
|
@@ -267,7 +270,7 @@ server.registerTool(
|
|
|
267
270
|
releaseAfterTurn: z
|
|
268
271
|
.boolean()
|
|
269
272
|
.optional()
|
|
270
|
-
.describe("
|
|
273
|
+
.describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
|
|
271
274
|
},
|
|
272
275
|
annotations: {
|
|
273
276
|
readOnlyHint: false,
|
|
@@ -358,7 +361,7 @@ server.registerTool(
|
|
|
358
361
|
releaseAfterTurn: z
|
|
359
362
|
.boolean()
|
|
360
363
|
.optional()
|
|
361
|
-
.describe("
|
|
364
|
+
.describe("Unsubscribe this thread after a terminal turn; open Desktop only after its unload is confirmed"),
|
|
362
365
|
},
|
|
363
366
|
annotations: {
|
|
364
367
|
readOnlyHint: false,
|
|
@@ -368,64 +371,66 @@ server.registerTool(
|
|
|
368
371
|
},
|
|
369
372
|
},
|
|
370
373
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Opening the thread in the app comes after both gates. It ran first
|
|
396
|
-
* once, which meant a thread this bridge was about to refuse still got
|
|
397
|
-
* raised on screen - a refusal that leaked which threads exist.
|
|
398
|
-
*/
|
|
399
|
-
if (shouldOpen && !shouldRelease) {
|
|
400
|
-
try {
|
|
401
|
-
notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
|
|
402
|
-
} catch (err) {
|
|
403
|
-
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)}`);
|
|
404
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);
|
|
405
432
|
}
|
|
406
|
-
|
|
407
|
-
threadId,
|
|
408
|
-
input: [{ type: "text", text: prompt }],
|
|
409
|
-
timeoutMs: (timeoutSec ?? 240) * 1000,
|
|
410
|
-
turnOverrides: {
|
|
411
|
-
...(resolvedCwd ? { cwd: resolvedCwd } : {}),
|
|
412
|
-
...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
|
|
413
|
-
...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
|
|
414
|
-
},
|
|
415
|
-
});
|
|
416
|
-
const body = formatTurn(result);
|
|
417
|
-
const failed = result.status === "failed" || result.status === "disconnected";
|
|
418
|
-
notes.push(...(await finishDesktopHandoff({
|
|
419
|
-
threadId,
|
|
420
|
-
result,
|
|
421
|
-
openInApp: shouldOpen,
|
|
422
|
-
releaseAfterTurn: shouldRelease,
|
|
423
|
-
})));
|
|
424
|
-
const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
425
|
-
return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
|
|
426
|
-
} catch (err) {
|
|
427
|
-
return failure(err);
|
|
428
|
-
}
|
|
433
|
+
});
|
|
429
434
|
},
|
|
430
435
|
);
|
|
431
436
|
|
|
@@ -459,16 +464,40 @@ server.registerTool(
|
|
|
459
464
|
}
|
|
460
465
|
if (searchTerm) params.searchTerm = searchTerm;
|
|
461
466
|
const method = loadedOnly ? "thread/loaded/list" : "thread/list";
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
|
|
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) => {
|
|
465
487
|
try {
|
|
466
|
-
|
|
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];
|
|
467
492
|
} catch {
|
|
468
493
|
return [];
|
|
469
494
|
}
|
|
470
|
-
})
|
|
471
|
-
|
|
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);
|
|
472
501
|
if (!rows.length) {
|
|
473
502
|
return textResult(
|
|
474
503
|
security.summary().allowedRoots.length
|
|
@@ -648,6 +677,9 @@ server.registerTool(
|
|
|
648
677
|
async () => {
|
|
649
678
|
try {
|
|
650
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
|
+
}
|
|
651
683
|
return textResult(
|
|
652
684
|
result.stopped
|
|
653
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
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.1";
|
|
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));
|
|
@@ -113,6 +108,8 @@ export class RelaySocketServer {
|
|
|
113
108
|
this.log = logFn;
|
|
114
109
|
this.server = null;
|
|
115
110
|
this.started = false;
|
|
111
|
+
this.connections = new Set();
|
|
112
|
+
this.processHandlers = new Map();
|
|
116
113
|
}
|
|
117
114
|
|
|
118
115
|
async start() {
|
|
@@ -139,12 +136,16 @@ export class RelaySocketServer {
|
|
|
139
136
|
this.started = true;
|
|
140
137
|
|
|
141
138
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
142
|
-
|
|
139
|
+
const handler = () => {
|
|
143
140
|
this.stop();
|
|
144
141
|
process.exit(0);
|
|
145
|
-
}
|
|
142
|
+
};
|
|
143
|
+
this.processHandlers.set(signal, handler);
|
|
144
|
+
process.on(signal, handler);
|
|
146
145
|
}
|
|
147
|
-
|
|
146
|
+
const onExit = () => this.stop();
|
|
147
|
+
this.processHandlers.set("exit", onExit);
|
|
148
|
+
process.on("exit", onExit);
|
|
148
149
|
|
|
149
150
|
this.log(`relay socket listening on ${this.socketPath}`);
|
|
150
151
|
return this.socketPath;
|
|
@@ -201,22 +202,25 @@ export class RelaySocketServer {
|
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
#handleConnection(socket) {
|
|
204
|
-
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());
|
|
205
210
|
socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
|
|
206
211
|
socket.on("data", (chunk) => {
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
if (handled) return;
|
|
213
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
214
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
215
|
+
handled = true;
|
|
209
216
|
this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
|
|
210
|
-
socket.destroy();
|
|
211
217
|
return;
|
|
212
218
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
void this.#handleLine(socket, line);
|
|
219
|
-
}
|
|
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);
|
|
220
224
|
});
|
|
221
225
|
}
|
|
222
226
|
|
|
@@ -239,10 +243,14 @@ export class RelaySocketServer {
|
|
|
239
243
|
|
|
240
244
|
#reply(socket, response) {
|
|
241
245
|
if (socket.destroyed) return;
|
|
242
|
-
socket.
|
|
246
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
243
247
|
}
|
|
244
248
|
|
|
245
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();
|
|
246
254
|
try {
|
|
247
255
|
this.server?.close();
|
|
248
256
|
} catch {}
|
|
@@ -253,6 +261,58 @@ export class RelaySocketServer {
|
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
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
|
+
|
|
256
316
|
/**
|
|
257
317
|
* `import.meta.main` is Node 24 and up, and this project supports Node 22, so
|
|
258
318
|
* the entry point is detected by comparing the resolved argv path instead.
|
|
@@ -271,20 +331,8 @@ if (invokedDirectly) {
|
|
|
271
331
|
},
|
|
272
332
|
);
|
|
273
333
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
* launch this process, which is what keeps the app the single writer. Sent as
|
|
277
|
-
* a plain JSON-RPC request rather than through a typed helper because the
|
|
278
|
-
* method is an internal of the app, not part of the MCP specification.
|
|
279
|
-
*/
|
|
280
|
-
const dispatch = ({ executorThreadId, targetThreadId, message }) =>
|
|
281
|
-
mcp.server.request(
|
|
282
|
-
{
|
|
283
|
-
method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
284
|
-
params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
|
|
285
|
-
},
|
|
286
|
-
z.any(),
|
|
287
|
-
);
|
|
334
|
+
const nativeTools = new NativeToolsClient();
|
|
335
|
+
const dispatch = (args) => nativeTools.dispatch(args);
|
|
288
336
|
|
|
289
337
|
const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
|
|
290
338
|
|
|
@@ -319,6 +367,7 @@ if (invokedDirectly) {
|
|
|
319
367
|
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
|
|
320
368
|
`executor: ${executor}`,
|
|
321
369
|
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
370
|
+
`native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
|
|
322
371
|
].join("\n"),
|
|
323
372
|
},
|
|
324
373
|
],
|
|
@@ -326,18 +375,8 @@ if (invokedDirectly) {
|
|
|
326
375
|
},
|
|
327
376
|
);
|
|
328
377
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
* `initialize` handshake, so a process that dies before answering reads as a
|
|
332
|
-
* hang rather than an error - the same failure mode `claude-bridge` already
|
|
333
|
-
* guards its peer endpoint against.
|
|
334
|
-
*/
|
|
335
|
-
try {
|
|
336
|
-
await relay.start();
|
|
337
|
-
} catch (err) {
|
|
338
|
-
log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
|
|
339
|
-
}
|
|
340
|
-
|
|
378
|
+
const startup = startRelayWhenAvailable({ nativeTools, relay, log });
|
|
379
|
+
mcp.server.onclose = () => startup.stop();
|
|
341
380
|
await mcp.connect(new StdioServerTransport());
|
|
342
381
|
log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
|
|
343
382
|
}
|