@minhspark/codex-mcp-bridge 1.13.9 → 1.15.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 +47 -0
- package/README.md +53 -24
- package/package.json +6 -6
- package/scripts/check.mjs +6 -1
- package/scripts/install-claude-desktop.mjs +22 -7
- package/scripts/install-codex-mcp.mjs +8 -5
- package/scripts/install-native-relay.mjs +15 -9
- package/src/bridge-account-context.mjs +77 -0
- package/src/claude-bridge-launcher.mjs +3 -0
- package/src/claude-bridge.mjs +91 -28
- package/src/claude-desktop-context.mjs +11 -6
- package/src/claude-sender-context.mjs +206 -0
- package/src/claude-session-router.mjs +92 -0
- package/src/codex-account-context.mjs +116 -0
- package/src/codex-bridge-launcher.mjs +3 -0
- package/src/codex-mcp-registration.mjs +14 -9
- package/src/desktop-account-context.mjs +140 -0
- package/src/desktop-task-receipts.mjs +3 -1
- package/src/index.mjs +91 -9
- package/src/mcp-supervisor.mjs +326 -0
- package/src/native-relay-companion.mjs +173 -34
- package/src/native-relay-launcher.mjs +3 -0
- package/src/native-relay.mjs +234 -50
- package/src/peer-protocol.mjs +120 -17
- package/src/platform.mjs +4 -1
- package/src/release-snapshot.mjs +198 -0
- package/src/reload-control.mjs +102 -0
- package/src/reply-forwarder.mjs +43 -0
- package/src/thread-delivery.mjs +27 -11
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { realpathSync } from "node:fs";
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
8
|
|
|
8
9
|
import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
|
|
9
10
|
import {
|
|
@@ -26,10 +27,13 @@ import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.m
|
|
|
26
27
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
27
28
|
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
28
29
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
30
|
+
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
31
|
+
import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
|
|
32
|
+
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
29
33
|
|
|
30
34
|
exitForVersionRequest(import.meta.url);
|
|
31
35
|
|
|
32
|
-
const VERSION = "1.
|
|
36
|
+
const VERSION = "1.15.1";
|
|
33
37
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -50,13 +54,61 @@ const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
|
|
|
50
54
|
const security = new BridgeSecurityPolicy();
|
|
51
55
|
const desktopTasksEnabled = desktopTasksConfigured();
|
|
52
56
|
const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
|
|
53
|
-
const
|
|
57
|
+
const desktopOperation = new AsyncLocalStorage();
|
|
58
|
+
const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts });
|
|
59
|
+
|
|
60
|
+
async function assertDesktopOperation(context, { verifyProcess = false } = {}) {
|
|
61
|
+
if (!context || context.diagnostic) return;
|
|
62
|
+
runtime.assertCurrent();
|
|
63
|
+
const accounts = readBridgeAccounts();
|
|
64
|
+
assertAccountIdentity(context.accounts, accounts);
|
|
65
|
+
await assertClaudeSenderContext(context.caller, {
|
|
66
|
+
account: accounts.claude,
|
|
67
|
+
...(!verifyProcess ? { readAncestry: async () => context.caller.lineage } : {}),
|
|
68
|
+
});
|
|
69
|
+
assertAccountIdentity(context.accounts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function beforeDesktopRequest({ operation, args, phase }) {
|
|
73
|
+
const context = desktopOperation.getStore();
|
|
74
|
+
if (context?.diagnostic) return;
|
|
75
|
+
if (!context) throw new Error("Desktop operations require a verified calling Code session.");
|
|
76
|
+
const mutating = ["create_thread", "send_message_to_thread", "set_thread_title", "navigate_to_codex_page"].includes(operation);
|
|
77
|
+
await assertDesktopOperation(context, { verifyProcess: mutating && phase === "write" });
|
|
78
|
+
if (mutating) {
|
|
79
|
+
context.dispatched = true;
|
|
80
|
+
if (args.threadId) context.threadId = args.threadId;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withheldDesktopResult(error, context) {
|
|
85
|
+
if (context.dispatched) reload.defer("A Desktop operation was dispatched without a fully verified outcome");
|
|
86
|
+
const reason = (error?.message ?? "The calling session or accounts could not be reverified.").replace(/\s*No message was sent\./g, "");
|
|
87
|
+
return {
|
|
88
|
+
...textResult(`${reason} ${context.dispatched ? "A Desktop operation may already have been dispatched. Its original destination and any creation receipt were preserved." : "No Desktop mutation was dispatched."} Reply content was withheld. Inspect the original task and do not resend automatically.`, true),
|
|
89
|
+
structuredContent: { accountContext: context.accounts, operation: { state: context.dispatched ? "dispatched_outcome_unverified" : "blocked", threadId: context.threadId ?? null } },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
54
92
|
|
|
55
93
|
const client = desktopTasksEnabled ? null : new CodexAppServerClient({
|
|
56
94
|
clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
|
|
57
95
|
log,
|
|
58
96
|
});
|
|
59
97
|
|
|
98
|
+
const reload = createReloadControl({
|
|
99
|
+
entry: "index.mjs",
|
|
100
|
+
inspect: () => desktopTasks.threadOperations.size ? "Desktop task operations are still active" : clientReloadReason(client),
|
|
101
|
+
quiesce: () => { client?.close(); },
|
|
102
|
+
exportState: () => ({ desktopTasksEnabled, ownedThreadIds: [...security.ownedThreadIds] }),
|
|
103
|
+
restore: (state) => {
|
|
104
|
+
assertRoutingReload(state.desktopTasksEnabled, desktopTasksEnabled);
|
|
105
|
+
if (!Array.isArray(state.ownedThreadIds)
|
|
106
|
+
|| state.ownedThreadIds.some((id) => typeof id !== "string" || !id.trim())
|
|
107
|
+
|| new Set(state.ownedThreadIds).size !== state.ownedThreadIds.length) throw new Error("Invalid or incompatible Codex worker reload state");
|
|
108
|
+
security.ownedThreadIds = new Set(state.ownedThreadIds);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
60
112
|
const textResult = (text, isError = false) => ({
|
|
61
113
|
content: [{ type: "text", text }],
|
|
62
114
|
...(isError ? { isError: true } : {}),
|
|
@@ -116,7 +168,7 @@ function threadNameFor({ cwd, prompt, name }) {
|
|
|
116
168
|
}
|
|
117
169
|
|
|
118
170
|
async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
119
|
-
const deadline = Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
171
|
+
const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
120
172
|
const workspace = resolveWorkspacePath(cwd);
|
|
121
173
|
const created = await desktopTasks.create({
|
|
122
174
|
cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
|
|
@@ -293,16 +345,39 @@ const server = new McpServer(
|
|
|
293
345
|
function registerTool(name, definition, handler) {
|
|
294
346
|
server.registerTool(name, definition, async (...args) => {
|
|
295
347
|
try {
|
|
348
|
+
return await reload.run(async () => {
|
|
296
349
|
if (!definition.annotations?.readOnlyHint) runtime.assertCurrent();
|
|
297
350
|
if (name === "codex_bridge_status") {
|
|
298
351
|
const state = runtime.status();
|
|
299
352
|
if (!state.current) return { ...failure(new Error(`${state.reason}; reconnect this MCP server in the existing task.`)), structuredContent: { runtime: state } };
|
|
300
|
-
const
|
|
353
|
+
const accounts = desktopTasksEnabled ? readBridgeAccounts() : null;
|
|
354
|
+
const result = await desktopOperation.run({ diagnostic: true, accounts: accountIdentity(accounts) }, () => handler(...args));
|
|
301
355
|
result.content.push({ type: "text", text: `runtime pid: ${state.pid}\nloaded source: ${state.revision}\nruntime state: current` });
|
|
302
356
|
result.structuredContent = { ...result.structuredContent, runtime: state };
|
|
357
|
+
if (desktopTasksEnabled) {
|
|
358
|
+
result.structuredContent.accounts = { claude: publicAccountState(accounts.claude), codex: publicAccountState(accounts.codex) };
|
|
359
|
+
}
|
|
303
360
|
return result;
|
|
304
361
|
}
|
|
362
|
+
if (desktopTasksEnabled) {
|
|
363
|
+
const deadline = Date.now() + Math.min((args[0]?.timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
364
|
+
const accounts = readBridgeAccounts();
|
|
365
|
+
const identity = requireBridgeAccounts(accounts);
|
|
366
|
+
const caller = requireClaudeSenderContext(await readClaudeSenderContext({ account: accounts.claude }));
|
|
367
|
+
const context = { accounts: identity, caller, dispatched: false, deadline };
|
|
368
|
+
return await desktopOperation.run(context, async () => {
|
|
369
|
+
try {
|
|
370
|
+
await assertDesktopOperation(context);
|
|
371
|
+
const result = await handler(...args);
|
|
372
|
+
await assertDesktopOperation(context);
|
|
373
|
+
return result;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return withheldDesktopResult(error, context);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
305
379
|
return await handler(...args);
|
|
380
|
+
});
|
|
306
381
|
} catch (err) {
|
|
307
382
|
return failure(err);
|
|
308
383
|
}
|
|
@@ -406,7 +481,10 @@ registerTool(
|
|
|
406
481
|
title: "Send a prompt to a Codex thread",
|
|
407
482
|
description:
|
|
408
483
|
"Send a prompt as a new user turn inside an existing Codex thread and wait for Codex to answer. " +
|
|
409
|
-
"The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId."
|
|
484
|
+
"The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId. " +
|
|
485
|
+
"Desktop-owned tasks must use Desktop native delivery; an open task is a valid destination. " +
|
|
486
|
+
"If legacy delivery reports an active writer, inspect codex_bridge_status and repair the native relay/configuration. " +
|
|
487
|
+
"Do not close the task, create a replacement, or ask the user to copy the message manually.",
|
|
410
488
|
inputSchema: {
|
|
411
489
|
threadId: z.string().describe("Codex thread id (UUID) - get it from list_codex_threads"),
|
|
412
490
|
prompt: z.string().describe("The message to send to Codex, exactly as a user would type it"),
|
|
@@ -441,7 +519,7 @@ registerTool(
|
|
|
441
519
|
},
|
|
442
520
|
},
|
|
443
521
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
444
|
-
const deadline = desktopTasksEnabled ? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
|
|
522
|
+
const deadline = desktopTasksEnabled ? desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
|
|
445
523
|
return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
|
|
446
524
|
const notes = [];
|
|
447
525
|
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
@@ -670,7 +748,7 @@ registerTool(
|
|
|
670
748
|
async ({ threadId, limit }) => {
|
|
671
749
|
try {
|
|
672
750
|
if (desktopTasksEnabled) {
|
|
673
|
-
const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
|
|
751
|
+
const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
|
|
674
752
|
await desktopTasks.inspect(threadId, undefined, { deadline });
|
|
675
753
|
const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
|
|
676
754
|
return textResult(JSON.stringify(response, null, 2));
|
|
@@ -758,9 +836,12 @@ registerTool(
|
|
|
758
836
|
async ({ threadId, background }) => {
|
|
759
837
|
try {
|
|
760
838
|
if (desktopTasksEnabled) {
|
|
761
|
-
const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
|
|
839
|
+
const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
|
|
762
840
|
await desktopTasks.inspect(threadId, undefined, { deadline });
|
|
763
|
-
if (background)
|
|
841
|
+
if (background) {
|
|
842
|
+
await beforeDesktopRequest({ operation: "navigate_to_codex_page", args: { threadId }, phase: "write" });
|
|
843
|
+
await openThreadInCodexApp(threadId, { activate: false });
|
|
844
|
+
}
|
|
764
845
|
else await desktopTasks.open(threadId, { deadline });
|
|
765
846
|
return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
|
|
766
847
|
}
|
|
@@ -894,4 +975,5 @@ registerTool(
|
|
|
894
975
|
|
|
895
976
|
const transport = new StdioServerTransport();
|
|
896
977
|
await server.connect(transport);
|
|
978
|
+
reload.listen();
|
|
897
979
|
log(desktopTasksEnabled ? `ready on ${PLATFORM_LABEL} (Codex Desktop only; external app-server disabled)` : `ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
|
+
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
8
|
+
|
|
9
|
+
const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
|
|
10
|
+
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const MAX_QUEUE = 256;
|
|
12
|
+
const hasId = (message) => Object.hasOwn(message, "id");
|
|
13
|
+
const idKey = (id) => JSON.stringify(id);
|
|
14
|
+
const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
|
|
15
|
+
|
|
16
|
+
function lines(stream, receive, failed) {
|
|
17
|
+
let buffer = Buffer.alloc(0);
|
|
18
|
+
stream.on("data", (chunk) => {
|
|
19
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
20
|
+
if (buffer.length > MAX_FRAME_BYTES) { failed(new Error("MCP frame exceeds the buffer limit")); return; }
|
|
21
|
+
let index;
|
|
22
|
+
while ((index = buffer.indexOf(10)) !== -1) {
|
|
23
|
+
const frame = buffer.subarray(0, index).toString("utf8").trim();
|
|
24
|
+
buffer = buffer.subarray(index + 1);
|
|
25
|
+
if (!frame) continue;
|
|
26
|
+
try { receive(JSON.parse(frame)); }
|
|
27
|
+
catch (error) { failed(error); }
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class Worker {
|
|
33
|
+
constructor(release, entry, env, handlers) {
|
|
34
|
+
this.release = release;
|
|
35
|
+
this.handlers = handlers;
|
|
36
|
+
this.rpcPending = new Map();
|
|
37
|
+
this.controlPending = new Map();
|
|
38
|
+
this.clientPending = new Map();
|
|
39
|
+
this.sequence = 0;
|
|
40
|
+
this.retiring = false;
|
|
41
|
+
this.closed = false;
|
|
42
|
+
this.process = fork(path.join(release.directory, "src", entry), [], {
|
|
43
|
+
execPath: process.execPath, execArgv: [], cwd: process.cwd(), windowsHide: true,
|
|
44
|
+
env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
|
|
46
|
+
});
|
|
47
|
+
this.process.stderr.on("data", (data) => process.stderr.write(data));
|
|
48
|
+
this.process.stdin.on("error", (error) => this.fail(error));
|
|
49
|
+
this.process.on("error", (error) => this.fail(error));
|
|
50
|
+
this.process.on("exit", (code, signal) => this.fail(new Error(`Bridge worker exited (${signal ?? code})`)));
|
|
51
|
+
this.process.on("message", (message) => {
|
|
52
|
+
if (message?.type !== "bridge:control-result") return;
|
|
53
|
+
const pending = this.controlPending.get(message.id);
|
|
54
|
+
if (!pending) return;
|
|
55
|
+
this.controlPending.delete(message.id);
|
|
56
|
+
clearTimeout(pending.timer);
|
|
57
|
+
if (message.error) pending.reject(new Error(message.error));
|
|
58
|
+
else pending.resolve(message.result);
|
|
59
|
+
});
|
|
60
|
+
lines(this.process.stdout, (message) => {
|
|
61
|
+
if (!message || message.jsonrpc !== "2.0") throw new Error("Invalid worker JSON-RPC message");
|
|
62
|
+
if (!message.method && hasId(message) && this.rpcPending.has(message.id)) {
|
|
63
|
+
const pending = this.rpcPending.get(message.id);
|
|
64
|
+
this.rpcPending.delete(message.id);
|
|
65
|
+
clearTimeout(pending.timer);
|
|
66
|
+
if (message.error) pending.reject(new Error(message.error.message));
|
|
67
|
+
else pending.resolve(message.result);
|
|
68
|
+
} else handlers.message(this, message);
|
|
69
|
+
}, (error) => this.fail(error));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
write(message) {
|
|
73
|
+
if (this.closed) throw new Error("The bridge worker is unavailable");
|
|
74
|
+
this.process.stdin.write(`${JSON.stringify(message)}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
rpc(method, params, timeout = 15000) {
|
|
78
|
+
const id = `supervisor:${++this.sequence}`;
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const timer = setTimeout(() => { this.rpcPending.delete(id); reject(new Error(`Candidate ${method} timed out`)); }, timeout);
|
|
81
|
+
this.rpcPending.set(id, { resolve, reject, timer });
|
|
82
|
+
this.write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
control(action, data, timeout = 15000) {
|
|
87
|
+
const id = ++this.sequence;
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const timer = setTimeout(() => { this.controlPending.delete(id); reject(new Error(`Worker ${action} timed out`)); }, timeout);
|
|
90
|
+
this.controlPending.set(id, { resolve, reject, timer });
|
|
91
|
+
this.process.send({ type: "bridge:control", id, action, data }, (error) => {
|
|
92
|
+
if (!error) return;
|
|
93
|
+
const pending = this.controlPending.get(id);
|
|
94
|
+
if (pending) { clearTimeout(timer); this.controlPending.delete(id); reject(error); }
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fail(error) {
|
|
100
|
+
if (this.closed) return;
|
|
101
|
+
this.closed = true;
|
|
102
|
+
for (const pending of [...this.rpcPending.values(), ...this.controlPending.values()]) {
|
|
103
|
+
clearTimeout(pending.timer);
|
|
104
|
+
pending.reject(error);
|
|
105
|
+
}
|
|
106
|
+
this.rpcPending.clear();
|
|
107
|
+
this.controlPending.clear();
|
|
108
|
+
this.handlers.exit(this, error);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async stop() {
|
|
112
|
+
this.retiring = true;
|
|
113
|
+
if (this.process.exitCode !== null || this.process.signalCode) return;
|
|
114
|
+
await new Promise((resolve) => {
|
|
115
|
+
const timer = setTimeout(() => { this.process.kill(); resolve(); }, 1000);
|
|
116
|
+
this.process.once("exit", () => { clearTimeout(timer); resolve(); });
|
|
117
|
+
this.process.stdin.end();
|
|
118
|
+
if (this.process.connected) this.process.disconnect();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runSupervisor(entry, options = {}) {
|
|
124
|
+
if (!ENTRIES.has(entry)) throw new Error("Expected index.mjs, claude-bridge.mjs, or native-relay-companion.mjs");
|
|
125
|
+
const root = fs.realpathSync.native(options.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
126
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
127
|
+
process.stdout.write(`${JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version}\n`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
|
|
131
|
+
const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
132
|
+
const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: root };
|
|
133
|
+
const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
|
|
134
|
+
if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
|
|
135
|
+
const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
|
|
136
|
+
const pipe = await resolveNativeToolsPipePath();
|
|
137
|
+
if (pipe) env.CODEX_APP_TOOLS_PIPE_PATH = pipe;
|
|
138
|
+
}
|
|
139
|
+
const pollMs = Math.max(100, Number(env.CODEX_BRIDGE_RELOAD_POLL_MS) || 1000);
|
|
140
|
+
const settleMs = Math.max(100, Number(env.CODEX_BRIDGE_RELOAD_SETTLE_MS) || 1500);
|
|
141
|
+
let active;
|
|
142
|
+
let initializeParams;
|
|
143
|
+
let initializeResult;
|
|
144
|
+
let ready = false;
|
|
145
|
+
let switching = false;
|
|
146
|
+
let stopping = false;
|
|
147
|
+
let failure = null;
|
|
148
|
+
let sequence = 0;
|
|
149
|
+
let observed = null;
|
|
150
|
+
let observedRouting = null;
|
|
151
|
+
let observedAt = 0;
|
|
152
|
+
let retryAt = 0;
|
|
153
|
+
let reason = null;
|
|
154
|
+
let reloads = 0;
|
|
155
|
+
const queue = [];
|
|
156
|
+
const requests = new Map();
|
|
157
|
+
const reverse = new Map();
|
|
158
|
+
const workers = new Set();
|
|
159
|
+
const diagnostics = () => ({ enabled: true, supervisorPid: process.pid, workerPid: active?.process.pid ?? null,
|
|
160
|
+
revision: active?.release.revision ?? null, availableRevision: observed,
|
|
161
|
+
routingConfiguration: active?.routingConfiguration ?? null, availableRoutingConfiguration: observedRouting,
|
|
162
|
+
pending: Boolean(observed && (observed !== active?.release.revision || observedRouting !== active?.routingConfiguration)),
|
|
163
|
+
state: failure ? "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads });
|
|
164
|
+
|
|
165
|
+
const handlers = {
|
|
166
|
+
message(worker, message) {
|
|
167
|
+
if (message.method && hasId(message)) {
|
|
168
|
+
const id = `bridge-server:${++sequence}`;
|
|
169
|
+
reverse.set(idKey(id), { worker, id: message.id });
|
|
170
|
+
output({ ...message, id });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (message.method) {
|
|
174
|
+
if (worker === active) output(message);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const pending = worker.clientPending.get(message.id);
|
|
178
|
+
if (!pending) return;
|
|
179
|
+
worker.clientPending.delete(message.id);
|
|
180
|
+
requests.delete(idKey(pending.id));
|
|
181
|
+
const respond = async () => {
|
|
182
|
+
if (pending.method === "initialize" && !message.error) {
|
|
183
|
+
initializeResult = message.result;
|
|
184
|
+
await worker.control("activate");
|
|
185
|
+
ready = true;
|
|
186
|
+
}
|
|
187
|
+
if (message.result && ["codex_bridge_status", "claude_bridge_status", "native_relay_status"].includes(pending.tool)) {
|
|
188
|
+
message.result.structuredContent = { ...message.result.structuredContent, autoReload: diagnostics() };
|
|
189
|
+
message.result.content = [...(message.result.content ?? []), { type: "text", text: `automatic reload: ${diagnostics().state}\nsupervisor pid: ${process.pid}\nworker pid: ${worker.process.pid}\nreload count: ${reloads}${reason ? `\nreload detail: ${reason}` : ""}` }];
|
|
190
|
+
}
|
|
191
|
+
output({ ...message, id: pending.id });
|
|
192
|
+
};
|
|
193
|
+
void respond().catch((error) => { failure = error.message; output(errorMessage(pending.id, error.message)); });
|
|
194
|
+
},
|
|
195
|
+
exit(worker, error) {
|
|
196
|
+
for (const [id, pending] of worker.clientPending) {
|
|
197
|
+
requests.delete(idKey(pending.id));
|
|
198
|
+
output(errorMessage(pending.id, `${error.message}. The operation may already have been dispatched; it was not retried. Retain the original task and receipts.`));
|
|
199
|
+
worker.clientPending.delete(id);
|
|
200
|
+
}
|
|
201
|
+
for (const [id, pending] of reverse) if (pending.worker === worker) reverse.delete(id);
|
|
202
|
+
if (worker === active && !worker.retiring && !stopping) { failure = `${error.message}; automatic replay is disabled because delivery state may be unknown`; log(failure); }
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const spawn = (release, routing) => {
|
|
206
|
+
if (stopping) throw new Error("The MCP client disconnected before activation");
|
|
207
|
+
const worker = new Worker(release, entry, env, handlers);
|
|
208
|
+
worker.routingConfiguration = routing;
|
|
209
|
+
workers.add(worker);
|
|
210
|
+
return worker;
|
|
211
|
+
};
|
|
212
|
+
const check = async () => {
|
|
213
|
+
if (switching || !ready || stopping || failure) return;
|
|
214
|
+
let revision;
|
|
215
|
+
let routing;
|
|
216
|
+
try { revision = sourceRevision(root); routing = routingConfiguration(); }
|
|
217
|
+
catch (error) { reason = `Installation is not ready: ${error.message}`; return; }
|
|
218
|
+
if (observed !== revision || observedRouting !== routing) { observed = revision; observedRouting = routing; observedAt = Date.now(); retryAt = 0; }
|
|
219
|
+
if (revision === active.release.revision && routing === active.routingConfiguration) { reason = null; return; }
|
|
220
|
+
if (Date.now() - observedAt < settleMs || Date.now() < retryAt) { reason ??= "Waiting for a complete, stable installation"; return; }
|
|
221
|
+
if (active.clientPending.size || reverse.size) { reason = "A request is still running; its worker is retained"; return; }
|
|
222
|
+
switching = true;
|
|
223
|
+
let candidate;
|
|
224
|
+
let quiesced = false;
|
|
225
|
+
const previous = active;
|
|
226
|
+
try {
|
|
227
|
+
const state = await previous.control("inspect");
|
|
228
|
+
if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
|
|
229
|
+
const release = createReleaseSnapshot(root, { expectedRevision: revision });
|
|
230
|
+
candidate = spawn(release, routing);
|
|
231
|
+
const initialized = await candidate.rpc("initialize", initializeParams);
|
|
232
|
+
if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
|
|
233
|
+
throw new Error("The new MCP protocol or capabilities require an explicit client reconnect");
|
|
234
|
+
}
|
|
235
|
+
candidate.write({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
236
|
+
const catalog = await candidate.rpc("tools/list", {});
|
|
237
|
+
if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The candidate tool catalog is incomplete");
|
|
238
|
+
const oldCatalog = await previous.rpc("tools/list", {});
|
|
239
|
+
if (sourceRevision(root) !== revision) throw new Error("The installation changed before activation");
|
|
240
|
+
if (routingConfiguration() !== routing) throw new Error("The routing configuration changed before activation");
|
|
241
|
+
quiesced = true;
|
|
242
|
+
const exported = await previous.control("quiesce");
|
|
243
|
+
await candidate.control("restore", exported.state);
|
|
244
|
+
await candidate.control("activate");
|
|
245
|
+
if (stopping) throw new Error("The MCP client disconnected during activation");
|
|
246
|
+
active = candidate;
|
|
247
|
+
candidate = null;
|
|
248
|
+
reloads++;
|
|
249
|
+
reason = null;
|
|
250
|
+
if (JSON.stringify(catalog) !== JSON.stringify(oldCatalog)) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
251
|
+
await previous.stop();
|
|
252
|
+
workers.delete(previous);
|
|
253
|
+
log(`activated ${entry} revision ${revision.slice(0, 12)}; client connection retained`);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
reason = `Update deferred: ${error.message}`;
|
|
256
|
+
retryAt = Date.now() + Math.max(pollMs, 5000);
|
|
257
|
+
if (candidate) { await candidate.stop(); workers.delete(candidate); }
|
|
258
|
+
if (quiesced && active === previous && !previous.closed) {
|
|
259
|
+
try { await previous.control("resume"); }
|
|
260
|
+
catch (resumeError) { failure = `Previous runtime could not resume: ${resumeError.message}`; }
|
|
261
|
+
}
|
|
262
|
+
log(reason);
|
|
263
|
+
} finally {
|
|
264
|
+
switching = false;
|
|
265
|
+
for (const message of queue.splice(0)) dispatch(message);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const dispatch = (message) => {
|
|
269
|
+
if (stopping) return;
|
|
270
|
+
if (!message.method && hasId(message)) {
|
|
271
|
+
const pending = reverse.get(idKey(message.id));
|
|
272
|
+
if (pending) { reverse.delete(idKey(message.id)); pending.worker.write({ ...message, id: pending.id }); }
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (message.method === "notifications/cancelled") {
|
|
276
|
+
const pending = requests.get(idKey(message.params?.requestId));
|
|
277
|
+
if (pending) pending.worker.write({ ...message, params: { ...message.params, requestId: pending.workerId } });
|
|
278
|
+
else {
|
|
279
|
+
const index = queue.findIndex((queued) => hasId(queued) && idKey(queued.id) === idKey(message.params?.requestId));
|
|
280
|
+
if (index !== -1) { const [cancelled] = queue.splice(index, 1); output({ jsonrpc: "2.0", id: cancelled.id, error: { code: -32800, message: "Request cancelled before dispatch" } }); }
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (failure) { if (hasId(message)) output(errorMessage(message.id, failure)); return; }
|
|
285
|
+
if (switching || !active) {
|
|
286
|
+
const bytes = queue.reduce((size, queued) => size + Buffer.byteLength(JSON.stringify(queued)), 0);
|
|
287
|
+
if (queue.length >= MAX_QUEUE || bytes + Buffer.byteLength(JSON.stringify(message)) > MAX_FRAME_BYTES) { if (hasId(message)) output(errorMessage(message.id, "Reload request queue is full; no operation was dispatched")); return; }
|
|
288
|
+
queue.push(message);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (message.method === "initialize") initializeParams = message.params;
|
|
292
|
+
if (hasId(message)) {
|
|
293
|
+
if (requests.has(idKey(message.id))) { output(errorMessage(message.id, "Duplicate active JSON-RPC request ID")); return; }
|
|
294
|
+
const id = `client:${++sequence}`;
|
|
295
|
+
active.clientPending.set(id, { id: message.id, method: message.method, tool: message.params?.name });
|
|
296
|
+
requests.set(idKey(message.id), { worker: active, workerId: id });
|
|
297
|
+
active.write({ ...message, id });
|
|
298
|
+
} else active.write(message);
|
|
299
|
+
};
|
|
300
|
+
const shutdown = async () => {
|
|
301
|
+
if (stopping) return;
|
|
302
|
+
stopping = true;
|
|
303
|
+
clearInterval(timer);
|
|
304
|
+
await Promise.allSettled([...workers].map((worker) => worker.stop()));
|
|
305
|
+
process.stdin.pause();
|
|
306
|
+
};
|
|
307
|
+
const preparationStarted = performance.now();
|
|
308
|
+
log(`preparing ${entry} runtime`);
|
|
309
|
+
const initial = createReleaseSnapshot(root);
|
|
310
|
+
log(`prepared ${entry} runtime in ${Math.round(performance.now() - preparationStarted)} ms`);
|
|
311
|
+
observed = initial.revision;
|
|
312
|
+
observedRouting = routingConfiguration();
|
|
313
|
+
active = spawn(initial, observedRouting);
|
|
314
|
+
const timer = setInterval(() => { void check(); }, pollMs);
|
|
315
|
+
lines(process.stdin, (message) => {
|
|
316
|
+
if (!message || message.jsonrpc !== "2.0" || (!message.method && !hasId(message))) throw new Error("Invalid client JSON-RPC message");
|
|
317
|
+
dispatch(message);
|
|
318
|
+
}, (error) => { log(error.message); void shutdown(); });
|
|
319
|
+
process.stdin.on("end", () => { void shutdown(); });
|
|
320
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void shutdown(); });
|
|
321
|
+
log(`supervising ${entry}; installed updates activate after pending work completes`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
325
|
+
await runSupervisor(process.argv[2]);
|
|
326
|
+
}
|