@minhspark/codex-mcp-bridge 1.22.10 → 1.22.11
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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +10 -2
- package/src/index.mjs +1 -1
- package/src/mcp-supervisor.mjs +6 -3
- package/src/native-relay-companion.mjs +25 -4
- package/src/peer-protocol.mjs +8 -0
- package/src/reload-control.mjs +35 -0
- package/src/reply-forwarder.mjs +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.11] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Stop `claude-bridge` and native relay companion workers from outliving their MCP client. The supervisor ends a worker's stdin and IPC channel and waits before killing it, but MCP clients such as the SDK stdio transport kill the supervisor about 4 seconds after closing it. `index.mjs` already exited on stdin end; the other two workers did not, so they kept running with parent PID 1 and kept their relay and peer sockets. Each `npm test` run left two such processes, and the same could happen in Codex Desktop or Claude Desktop whenever an MCP client closed.
|
|
12
|
+
- Both workers now exit on their own when stdin ends or the supervisor channel disconnects. They first stop taking new work and wait up to 35 seconds for work already accepted: the companion refuses new relay connections with `RELAY_STOPPING` (`sent: false`) and finishes accepted connections, active requests and pending Desktop dispatches; `claude-bridge` unregisters its peer endpoint and finishes open peer connections, in-flight sends and queued reply forwards. A late activation from the supervisor cannot reopen the relay during this drain, and losing the supervisor's output pipes no longer crashes the worker mid-drain. The supervisor waits for that drain before forcing a stop when its client closes; retiring a quiesced worker after a reload keeps its 6-second limit.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- Every MCP supervisor test now records the supervisor and worker PIDs it sees and fails if any outlives its client, killing leftovers so a failure cannot accumulate processes. On v1.22.10, 4 tests fail: the real `claude-bridge` and companion upgrade tests leave their workers behind, and a new test that kills the supervisor finds both workers still running.
|
|
17
|
+
- New tests cover the drain: an accepted relay delivery finishes before exit, a late connection is refused with `RELAY_STOPPING`, a late activation is rejected, a worker whose parent is killed during a drain still exits 0 after its work finishes (it crashes on v1.22.10's output handling), and the peer endpoint stops accepting while keeping open connections.
|
|
18
|
+
- Two full `npm test` runs on macOS left no process from a `mcp-supervisor-test-` directory; the run on v1.22.10 left two.
|
|
19
|
+
|
|
20
|
+
### Limitations
|
|
21
|
+
|
|
22
|
+
- On Windows the supervisor's workers belong to its job object and end with it, so they never outlived their client there, and a drain cannot continue after the client kills the supervisor. The supervisor-kill test passes on Windows for that reason rather than through the drain.
|
|
23
|
+
|
|
7
24
|
## [1.22.10] - 2026-10-20
|
|
8
25
|
|
|
9
26
|
### Fixed
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "codex-mcp-bridge",
|
|
4
|
-
"version": "1.22.
|
|
4
|
+
"version": "1.22.11",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -24,7 +24,7 @@ import { stableInstallPath } from "../src/install-paths.mjs";
|
|
|
24
24
|
|
|
25
25
|
exitForVersionRequest(import.meta.url);
|
|
26
26
|
|
|
27
|
-
const VERSION = "1.22.
|
|
27
|
+
const VERSION = "1.22.11";
|
|
28
28
|
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
29
29
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
30
30
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
|
|
|
18
18
|
import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
|
|
19
19
|
import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
|
|
20
20
|
import { ReplyForwarder } from "./reply-forwarder.mjs";
|
|
21
|
-
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
21
|
+
import { assertRoutingReload, clientReloadReason, createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
|
|
22
22
|
import { readClaudeAccountContext } from "./desktop-account-context.mjs";
|
|
23
23
|
import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
|
|
24
24
|
import { recoverClaudeDesktopSession, resolveClaudeDesktopSession, sameClaudeDesktopRecipient } from "./claude-session-router.mjs";
|
|
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
|
|
|
30
30
|
|
|
31
31
|
exitForVersionRequest(import.meta.url);
|
|
32
32
|
|
|
33
|
-
const VERSION = "1.22.
|
|
33
|
+
const VERSION = "1.22.11";
|
|
34
34
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
35
35
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
36
36
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -811,5 +811,13 @@ try {
|
|
|
811
811
|
|
|
812
812
|
const transport = new StdioServerTransport();
|
|
813
813
|
await server.connect(transport);
|
|
814
|
+
exitWhenClientCloses({
|
|
815
|
+
prepare: () => peer.closeIntake(),
|
|
816
|
+
busy: () => peer.connections.size ? "Peer socket connections are still open"
|
|
817
|
+
: peer.requestQueues.size ? "Claude messages are still being sent"
|
|
818
|
+
: replyForwarder.forwardingReason(),
|
|
819
|
+
stop: async () => { replyForwarder.close(); peer.stop(); codex.close(); await peer.closed; },
|
|
820
|
+
log,
|
|
821
|
+
});
|
|
814
822
|
reload.listen();
|
|
815
823
|
log(`ready on ${PLATFORM_LABEL} as peer "${peer.name}" (${peer.started ? peer.socketPath : "peer endpoint down"})`);
|
package/src/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
|
|
|
40
40
|
|
|
41
41
|
exitForVersionRequest(import.meta.url);
|
|
42
42
|
|
|
43
|
-
const VERSION = "1.22.
|
|
43
|
+
const VERSION = "1.22.11";
|
|
44
44
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
45
45
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
46
46
|
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -6,11 +6,14 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
7
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
8
8
|
import { isMainModule, stableInstallPath } from "./install-paths.mjs";
|
|
9
|
+
import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mjs";
|
|
9
10
|
|
|
10
11
|
const WORKER_NODE = stableInstallPath(process.execPath);
|
|
11
12
|
const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
|
|
12
13
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
13
14
|
const MAX_QUEUE = 256;
|
|
15
|
+
const RETIRE_GRACE_MS = 6000;
|
|
16
|
+
const CLIENT_CLOSE_GRACE_MS = CLIENT_CLOSE_DRAIN_MS + CLIENT_CLOSE_STOP_MS + 1000;
|
|
14
17
|
const hasId = (message) => Object.hasOwn(message, "id");
|
|
15
18
|
const idKey = (id) => JSON.stringify(id);
|
|
16
19
|
const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
|
|
@@ -111,7 +114,7 @@ class Worker {
|
|
|
111
114
|
this.handlers.exit(this, error);
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
async stop() {
|
|
117
|
+
async stop(graceMs = RETIRE_GRACE_MS) {
|
|
115
118
|
this.retiring = true;
|
|
116
119
|
if (this.stopping) return this.stopping;
|
|
117
120
|
this.stopping = (async () => {
|
|
@@ -123,7 +126,7 @@ class Worker {
|
|
|
123
126
|
};
|
|
124
127
|
this.process.stdin.end();
|
|
125
128
|
if (this.process.connected) this.process.disconnect();
|
|
126
|
-
if (await wait(
|
|
129
|
+
if (await wait(graceMs)) return;
|
|
127
130
|
if (this.process.exitCode === null && this.process.signalCode === null) {
|
|
128
131
|
this.process.kill("SIGKILL");
|
|
129
132
|
}
|
|
@@ -324,7 +327,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
324
327
|
if (stopping) return;
|
|
325
328
|
stopping = true;
|
|
326
329
|
clearInterval(timer);
|
|
327
|
-
await Promise.allSettled([...workers].map((worker) => worker.stop()));
|
|
330
|
+
await Promise.allSettled([...workers].map((worker) => worker.stop(CLIENT_CLOSE_GRACE_MS)));
|
|
328
331
|
process.stdin.pause();
|
|
329
332
|
};
|
|
330
333
|
const preparationStarted = performance.now();
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
29
29
|
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
30
30
|
import { assertAccountIdentity } from "./bridge-account-context.mjs";
|
|
31
|
-
import { createReloadControl } from "./reload-control.mjs";
|
|
31
|
+
import { createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
|
|
32
32
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
33
33
|
import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
|
|
34
34
|
import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
|
|
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
38
38
|
|
|
39
39
|
exitForVersionRequest(import.meta.url);
|
|
40
40
|
|
|
41
|
-
const VERSION = "1.22.
|
|
41
|
+
const VERSION = "1.22.11";
|
|
42
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
43
43
|
|
|
44
44
|
function errorResponse(code, message, sent, details) {
|
|
@@ -46,6 +46,9 @@ function errorResponse(code, message, sent, details) {
|
|
|
46
46
|
return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...size, ...(sent === false ? { sent: false } : {}) } };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const RELAY_RELOADING_REFUSAL = Object.freeze({ code: "RELAY_RELOADING", message: "The native relay is reloading; no message was sent" });
|
|
50
|
+
const RELAY_STOPPING_REFUSAL = Object.freeze({ code: "RELAY_STOPPING", message: "The native relay is shutting down because its MCP client closed; no message was sent" });
|
|
51
|
+
|
|
49
52
|
function boundRequest(payload) {
|
|
50
53
|
return payload?.v === ACCOUNT_RELAY_PROTOCOL_VERSION;
|
|
51
54
|
}
|
|
@@ -411,6 +414,7 @@ export class RelaySocketServer {
|
|
|
411
414
|
this.connections = new Set();
|
|
412
415
|
this.handlers = new Set();
|
|
413
416
|
this.accepting = true;
|
|
417
|
+
this.refusal = RELAY_RELOADING_REFUSAL;
|
|
414
418
|
this.closed = Promise.resolve();
|
|
415
419
|
this.processHandlers = new Map();
|
|
416
420
|
this.aclReady = !IS_WINDOWS || !strict;
|
|
@@ -558,7 +562,7 @@ export class RelaySocketServer {
|
|
|
558
562
|
}
|
|
559
563
|
if (!this.accepting) {
|
|
560
564
|
socket.on("error", () => {});
|
|
561
|
-
socket.end(`${JSON.stringify(errorResponse(
|
|
565
|
+
socket.end(`${JSON.stringify(errorResponse(this.refusal.code, this.refusal.message, false))}\n`);
|
|
562
566
|
return;
|
|
563
567
|
}
|
|
564
568
|
let buffer = Buffer.alloc(0);
|
|
@@ -712,10 +716,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
712
716
|
const servers = [...new Set(relays)];
|
|
713
717
|
let startups = [];
|
|
714
718
|
let ownedSockets = [];
|
|
715
|
-
|
|
719
|
+
let stopping = false;
|
|
720
|
+
const deliveryReason = () => {
|
|
716
721
|
if (servers.some((relay) => relay.handlers.size)) return "Native relay requests are still active";
|
|
717
722
|
if (servers.some((relay) => relay.connections.size)) return "Native relay clients are still connected";
|
|
718
723
|
if (nativeTools.pending.size) return "Native Desktop dispatches are still pending";
|
|
724
|
+
return null;
|
|
725
|
+
};
|
|
726
|
+
const inspect = () => {
|
|
727
|
+
const reason = deliveryReason();
|
|
728
|
+
if (reason) return reason;
|
|
719
729
|
if (nativeTools.connecting || startups.some((startup) => startup.busy)) return "Native relay startup is still pending";
|
|
720
730
|
return null;
|
|
721
731
|
};
|
|
@@ -729,6 +739,8 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
729
739
|
nativeTools.close();
|
|
730
740
|
};
|
|
731
741
|
const activate = async () => {
|
|
742
|
+
if (stopping) throw new Error("The native relay is shutting down because its MCP client closed");
|
|
743
|
+
for (const relay of servers) relay.refusal = RELAY_RELOADING_REFUSAL;
|
|
732
744
|
if (startups.length) {
|
|
733
745
|
for (const relay of servers) relay.accepting = true;
|
|
734
746
|
return;
|
|
@@ -745,8 +757,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
745
757
|
};
|
|
746
758
|
return {
|
|
747
759
|
inspect,
|
|
760
|
+
deliveryReason,
|
|
748
761
|
activate,
|
|
749
762
|
stop,
|
|
763
|
+
refuseNewDeliveries() {
|
|
764
|
+
stopping = true;
|
|
765
|
+
for (const relay of servers) {
|
|
766
|
+
relay.refusal = RELAY_STOPPING_REFUSAL;
|
|
767
|
+
relay.accepting = false;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
750
770
|
async quiesce() {
|
|
751
771
|
for (const relay of servers) relay.accepting = false;
|
|
752
772
|
const reason = inspect();
|
|
@@ -861,6 +881,7 @@ if (invokedDirectly) {
|
|
|
861
881
|
if (!reload.staged) void lifecycle.activate().catch((error) => log(`native relay startup failed: ${error.message}`));
|
|
862
882
|
mcp.server.onclose = () => { void lifecycle.stop(); };
|
|
863
883
|
await mcp.connect(new StdioServerTransport());
|
|
884
|
+
exitWhenClientCloses({ prepare: () => lifecycle.refuseNewDeliveries(), busy: lifecycle.deliveryReason, stop: lifecycle.stop, log });
|
|
864
885
|
reload.listen();
|
|
865
886
|
log(`ready on ${PLATFORM_LABEL} (${strictDesktop ? accountRelay.socketPath : relay.started ? relay.socketPath : "socket down"})`);
|
|
866
887
|
}
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -616,6 +616,7 @@ export class PeerEndpoint {
|
|
|
616
616
|
this.connections = new Set();
|
|
617
617
|
this.activityRevision = 0;
|
|
618
618
|
this.reloadPaused = false;
|
|
619
|
+
this.intakeClosed = false;
|
|
619
620
|
this.processHandlersRegistered = false;
|
|
620
621
|
this.strict = hardenedBridgeEnabled(env);
|
|
621
622
|
this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
|
|
@@ -655,6 +656,7 @@ export class PeerEndpoint {
|
|
|
655
656
|
|
|
656
657
|
start() {
|
|
657
658
|
if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
|
|
659
|
+
if (this.intakeClosed) throw new Error("The peer endpoint is shutting down because its MCP client closed");
|
|
658
660
|
if (this.started) return;
|
|
659
661
|
if (this.startPromise) return this.startPromise;
|
|
660
662
|
const starting = this.#startOnce();
|
|
@@ -1150,6 +1152,12 @@ export class PeerEndpoint {
|
|
|
1150
1152
|
if (revision !== this.activityRevision || this.reloadReason()) throw new Error("Peer activity raced with reload; the endpoint remains quiesced");
|
|
1151
1153
|
}
|
|
1152
1154
|
|
|
1155
|
+
closeIntake() {
|
|
1156
|
+
this.intakeClosed = true;
|
|
1157
|
+
this.#removeRegistration();
|
|
1158
|
+
if (this.server?.listening) this.server.close();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1153
1161
|
async resume() {
|
|
1154
1162
|
this.reloadPaused = false;
|
|
1155
1163
|
await this.start();
|
package/src/reload-control.mjs
CHANGED
|
@@ -100,3 +100,38 @@ export function createReloadControl({ entry, inspect = () => null, quiesce = ()
|
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
export const CLIENT_CLOSE_DRAIN_MS = 35000;
|
|
105
|
+
export const CLIENT_CLOSE_STOP_MS = 5000;
|
|
106
|
+
|
|
107
|
+
export function exitWhenClientCloses({ prepare = () => {}, busy = () => null, stop = () => {}, log = () => {}, drainMs = CLIENT_CLOSE_DRAIN_MS, stopMs = CLIENT_CLOSE_STOP_MS, pollMs = 50, stdin = process.stdin, channel = process, outputs = [process.stdout, process.stderr], exit = (code) => process.exit(code) } = {}) {
|
|
108
|
+
let closing = null;
|
|
109
|
+
const bounded = (promise, ms) => {
|
|
110
|
+
let timer;
|
|
111
|
+
return Promise.race([Promise.resolve(promise).then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })])
|
|
112
|
+
.finally(() => clearTimeout(timer));
|
|
113
|
+
};
|
|
114
|
+
const close = (why) => closing ??= (async () => {
|
|
115
|
+
log(`MCP client closed (${why}); draining before exit`);
|
|
116
|
+
let code = 0;
|
|
117
|
+
try {
|
|
118
|
+
await prepare();
|
|
119
|
+
const deadline = Date.now() + drainMs;
|
|
120
|
+
let reason = busy();
|
|
121
|
+
while (reason && Date.now() < deadline) {
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
123
|
+
reason = busy();
|
|
124
|
+
}
|
|
125
|
+
if (reason) log(`exiting after ${drainMs} ms with work still open: ${reason}`);
|
|
126
|
+
if (!await bounded(stop(), stopMs)) log(`listener shutdown exceeded ${stopMs} ms`);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
code = 1;
|
|
129
|
+
log(`shutdown failed: ${error?.message ?? String(error)}`);
|
|
130
|
+
}
|
|
131
|
+
exit(code);
|
|
132
|
+
})();
|
|
133
|
+
for (const output of outputs) output?.on("error", (error) => { void close(`output failed: ${error?.code ?? error?.message}`); });
|
|
134
|
+
stdin?.once("end", () => { void close("stdin ended"); });
|
|
135
|
+
channel?.once("disconnect", () => { void close("supervisor channel disconnected"); });
|
|
136
|
+
return { close, get closing() { return closing !== null; } };
|
|
137
|
+
}
|
package/src/reply-forwarder.mjs
CHANGED
|
@@ -106,8 +106,13 @@ export class ReplyForwarder {
|
|
|
106
106
|
return { ...counts, attempts, maxPerSession: this.maxPerSession, closed: this.closed };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
forwardingReason() {
|
|
110
|
+
return this.active || this.queue.length || this.timer !== null ? "Reply forwarding is still active or queued" : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
109
113
|
reloadReason() {
|
|
110
|
-
|
|
114
|
+
const forwarding = this.forwardingReason();
|
|
115
|
+
if (forwarding) return forwarding;
|
|
111
116
|
if ([...this.records.values()].some(({ receipt }) => ["queued", "sending", "unknown"].includes(receipt.status))) {
|
|
112
117
|
return "A reply forwarding outcome remains unconfirmed";
|
|
113
118
|
}
|