@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.10",
3
+ "version": "1.22.11",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.10",
3
+ "version": "1.22.11",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
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.10",
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.10";
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"];
@@ -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.10";
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.10";
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
 
@@ -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(6000)) return;
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.10";
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("RELAY_RELOADING", "The native relay is reloading; no message was sent", false))}\n`);
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
- const inspect = () => {
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
  }
@@ -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();
@@ -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
+ }
@@ -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
- if (this.active || this.queue.length || this.timer !== null) return "Reply forwarding is still active or queued";
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
  }