@minhspark/codex-mcp-bridge 1.22.15 → 1.22.16

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.15",
3
+ "version": "1.22.16",
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.16] - 2026-10-22
8
+
9
+ ### Fixed
10
+
11
+ - Start the Codex Desktop native relay on its own after Codex Desktop opens, instead of only when a send fails. In 1.22.14 the relay woke only inside a failing Desktop request, so `codex_bridge_status` (which has no calling task) could never wake it, and a wake that took longer than its 20-second window left the request failed with the relay-down guidance. Each bridge process now checks the relay socket every 30 seconds. When the relay is missing and Codex Desktop is running, it opens a recent Codex Desktop task in the background and waits up to 60 seconds for the relay. Codex Desktop is checked at most once a minute while it is closed.
12
+ - Coordinate wakes across bridge processes. Every Claude Code session runs its own bridge, so several of them could open tasks at the same moment. Wakes now share a state file (`~/.codex/bridge-relay-wake.json`) and a lock beside it. Only one process opens a task at a time; an on-demand request that finds the lock held waits for the relay instead of opening another task. Failed background wakes, including those that find no Codex Desktop task to open, back off from one minute up to 30 minutes and reset after a success. A stale lock is reclaimed.
13
+ - Fall back to the most recent Codex Desktop task of any project when neither the destination task nor a task of the sender's project is available, so a status call or a session in a new project can still wake the relay. The internal Native Relay executor task is still never opened.
14
+ - `CODEX_BRIDGE_RELAY_KEEPER=0` turns off the background check; `CODEX_BRIDGE_RELAY_WAKE=0` still turns off every wake.
15
+ - A request that arrives while a background wake is in progress waits only until its own deadline instead of the background wake's 60 seconds.
16
+ - `codex-sites-connector` runs `whoami.exe` and `icacls.exe` from `%SystemRoot%\System32` instead of looking them up on `PATH`. Started from Git Bash, it ran GNU `whoami`, could not read the Windows user SID and refused to protect its files.
17
+ - `npm test` runs the suite without the caller's `CLAUDE*`, `CODEX_*`, `ANTHROPIC_*` and `MCP_*` variables. Inside a Claude Code session they pointed tests at the live Claude Desktop data, messaging socket and bridge configuration, so tests that pass in CI failed locally.
18
+
19
+ ### Limitations
20
+
21
+ - Opening a task through `codex://threads/<id>` makes Codex Desktop show that task. The keeper opens only while the relay is missing and picks the most recent task, which is usually the one already on screen.
22
+ - The keeper runs only while a Claude client keeps a bridge process alive. With no Claude session open, the relay still starts when a task is opened in Codex Desktop.
23
+
7
24
  ## [1.22.15] - 2026-10-22
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.15",
3
+ "version": "1.22.16",
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",
@@ -37,6 +37,7 @@
37
37
  "files": [
38
38
  "src",
39
39
  "scripts",
40
+ "!scripts/run-tests.mjs",
40
41
  "skills",
41
42
  "assets",
42
43
  "plugin.json",
@@ -62,7 +63,7 @@
62
63
  "start": "node src/codex-bridge-launcher.mjs",
63
64
  "claude-bridge": "node src/claude-bridge-launcher.mjs",
64
65
  "app-server": "codex app-server --listen ws://127.0.0.1:8791",
65
- "test": "node --test",
66
+ "test": "node scripts/run-tests.mjs",
66
67
  "check": "node scripts/check.mjs",
67
68
  "check:claude": "node scripts/check-claude-bridge.mjs",
68
69
  "smoke": "node scripts/smoke.mjs",
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.15",
4
+ "version": "1.22.16",
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.15";
27
+ const VERSION = "1.22.16";
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"];
@@ -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.15";
33
+ const VERSION = "1.22.16";
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;
package/src/index.mjs CHANGED
@@ -37,11 +37,11 @@ import { captureProjectScope } from "./project-scope.mjs";
37
37
  import { DesktopReplyReceipts } from "./desktop-reply-receipts.mjs";
38
38
  import { registerDesktopReadTools } from "./desktop-read-tools.mjs";
39
39
  import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
40
- import { createRelayWake } from "./relay-wake.mjs";
40
+ import { createRelayWake, relayKeeperEnabled, relayWakeStateFile, startRelayKeeper } from "./relay-wake.mjs";
41
41
 
42
42
  exitForVersionRequest(import.meta.url);
43
43
 
44
- const VERSION = "1.22.15";
44
+ const VERSION = "1.22.16";
45
45
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
46
46
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
47
47
 
@@ -76,7 +76,8 @@ const desktopTasksEnabled = desktopTasksConfigured();
76
76
  const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
77
77
  const desktopOperation = new AsyncLocalStorage();
78
78
  const replyReceipts = new DesktopReplyReceipts();
79
- const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts, senderContext: () => desktopOperation.getStore()?.caller, wakeRelay: createRelayWake({ senderCwd: () => desktopOperation.getStore()?.caller?.cwd ?? null, log }) });
79
+ const relayWake = createRelayWake({ senderCwd: () => desktopOperation.getStore()?.caller?.cwd ?? null, stateFile: relayWakeStateFile(), log });
80
+ const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts, senderContext: () => desktopOperation.getStore()?.caller, wakeRelay: relayWake });
80
81
 
81
82
  async function assertDesktopOperation(context, { verifyProcess = false } = {}) {
82
83
  if (!context || context.diagnostic) return;
@@ -1094,6 +1095,7 @@ registerTool(
1094
1095
 
1095
1096
  const transport = new StdioServerTransport();
1096
1097
  await server.connect(transport);
1098
+ if (desktopTasksEnabled && relayKeeperEnabled()) startRelayKeeper({ wake: relayWake, socketPath: () => desktopTasks.relay.accountSocketPath, log });
1097
1099
  let shutdownPromise;
1098
1100
  const shutdown = () => shutdownPromise ??= (async () => {
1099
1101
  client?.close();
@@ -39,7 +39,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
39
39
 
40
40
  exitForVersionRequest(import.meta.url);
41
41
 
42
- const VERSION = "1.22.15";
42
+ const VERSION = "1.22.16";
43
43
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
44
44
 
45
45
  function errorResponse(code, message, sent, details) {
@@ -11,6 +11,10 @@ const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
11
11
  const ROLLOUT = /^rollout-.+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(?:\.zst)?$/i;
12
12
  export const RELAY_WAKE_WAIT_MS = 20000;
13
13
  export const RELAY_WAKE_COOLDOWN_MS = 60000;
14
+ export const RELAY_KEEPER_INTERVAL_MS = 30000;
15
+ export const RELAY_KEEPER_WAIT_MS = 60000;
16
+ export const RELAY_KEEPER_MAX_BACKOFF_MS = 30 * 60000;
17
+ export const DESKTOP_RECHECK_MS = 60000;
14
18
  const MAX_DAY_DIRECTORIES = 14;
15
19
  const MAX_CANDIDATES = 40;
16
20
 
@@ -42,8 +46,18 @@ function sameProject(senderCwd, taskCwd) {
42
46
  catch { return false; }
43
47
  }
44
48
 
45
- export async function recentDesktopTaskFor(cwd, { env = process.env, sessions = path.join(codexHome(env), "sessions"), exclude = [] } = {}) {
46
- if (typeof cwd !== "string" || !cwd) return null;
49
+ export function relayKeeperEnabled(env = process.env, platform = process.platform) {
50
+ const value = (env.CODEX_BRIDGE_RELAY_KEEPER ?? "").toLowerCase();
51
+ if (["0", "off", "false"].includes(value)) return false;
52
+ return relayWakeEnabled(env, platform);
53
+ }
54
+
55
+ export function relayWakeStateFile(env = process.env) {
56
+ return path.join(codexHome(env), "bridge-relay-wake.json");
57
+ }
58
+
59
+ export async function recentDesktopTaskFor(cwd, { env = process.env, sessions = path.join(codexHome(env), "sessions"), exclude = [], anyProject = false } = {}) {
60
+ if (!anyProject && (typeof cwd !== "string" || !cwd)) return null;
47
61
  const excluded = new Set(exclude.filter(Boolean));
48
62
  const candidates = [];
49
63
  for (const directory of dayDirectories(sessions)) {
@@ -63,7 +77,7 @@ export async function recentDesktopTaskFor(cwd, { env = process.env, sessions =
63
77
  try { record = await readRolloutFirstRecord(candidate.file, { maxBytes: 256 * 1024 }); } catch { continue; }
64
78
  const meta = record?.type === "session_meta" ? record.payload : null;
65
79
  if (meta?.id !== candidate.threadId || meta.originator !== "Codex Desktop" || typeof meta.cwd !== "string") continue;
66
- if (sameProject(cwd, meta.cwd)) return candidate.threadId;
80
+ if (anyProject || sameProject(cwd, meta.cwd)) return candidate.threadId;
67
81
  }
68
82
  return null;
69
83
  }
@@ -83,17 +97,59 @@ export function endpointAnswers(socketPath, { timeoutMs = 1000 } = {}) {
83
97
  });
84
98
  }
85
99
 
100
+ function readWakeState(file) {
101
+ if (!file) return {};
102
+ try {
103
+ const state = JSON.parse(fs.readFileSync(file, "utf8"));
104
+ return state && typeof state === "object" && !Array.isArray(state) ? state : {};
105
+ } catch { return {}; }
106
+ }
107
+
108
+ function writeWakeState(file, state) {
109
+ if (!file) return;
110
+ const temporary = `${file}.${process.pid}.tmp`;
111
+ try {
112
+ fs.writeFileSync(temporary, JSON.stringify(state));
113
+ fs.renameSync(temporary, file);
114
+ } catch { try { fs.rmSync(temporary, { force: true }); } catch {} }
115
+ }
116
+
117
+ function acquireWakeLock(file, now, staleMs) {
118
+ if (!file) return () => {};
119
+ const lock = `${file}.lock`;
120
+ const claim = () => {
121
+ const descriptor = fs.openSync(lock, "wx");
122
+ try { fs.writeSync(descriptor, JSON.stringify({ pid: process.pid, at: now() })); } finally { fs.closeSync(descriptor); }
123
+ return () => { try { fs.rmSync(lock, { force: true }); } catch {} };
124
+ };
125
+ try { return claim(); }
126
+ catch (error) { if (error.code !== "EEXIST") return null; }
127
+ let heldSince;
128
+ try { heldSince = JSON.parse(fs.readFileSync(lock, "utf8")).at; } catch { heldSince = 0; }
129
+ if (Number.isFinite(heldSince) && now() - heldSince < staleMs) return null;
130
+ try { fs.rmSync(lock, { force: true }); return claim(); } catch { return null; }
131
+ }
132
+
133
+ export function keeperBackoffMs(failures, { baseMs = RELAY_WAKE_COOLDOWN_MS, maxMs = RELAY_KEEPER_MAX_BACKOFF_MS } = {}) {
134
+ const count = Number.isInteger(failures) && failures > 0 ? Math.min(failures, 16) : 0;
135
+ return count === 0 ? 0 : Math.min(baseMs * 2 ** (count - 1), maxMs);
136
+ }
137
+
86
138
  export function createRelayWake({
87
139
  env = process.env,
88
140
  enabled = relayWakeEnabled(env),
89
141
  desktopRunning = () => isCodexDesktopRunning(),
90
142
  senderCwd = () => null,
91
143
  findTask = (cwd, exclude) => recentDesktopTaskFor(cwd, { env, exclude }),
144
+ findAnyTask = (exclude) => recentDesktopTaskFor(null, { env, exclude, anyProject: true }),
92
145
  executorThreadId = () => { try { return resolveRelayThreadId(env).threadId; } catch { return null; } },
93
146
  open = (threadId) => openThreadInCodexApp(threadId, { activate: false }),
94
147
  answers = endpointAnswers,
95
148
  waitMs = RELAY_WAKE_WAIT_MS,
149
+ backgroundWaitMs = RELAY_KEEPER_WAIT_MS,
96
150
  cooldownMs = RELAY_WAKE_COOLDOWN_MS,
151
+ desktopRecheckMs = DESKTOP_RECHECK_MS,
152
+ stateFile = null,
97
153
  pollMs = 250,
98
154
  now = Date.now,
99
155
  sleep = (ms) => new Promise((resolve) => globalThis.setTimeout(resolve, ms)),
@@ -101,29 +157,100 @@ export function createRelayWake({
101
157
  } = {}) {
102
158
  let inFlight = null;
103
159
  let failedAt = -Infinity;
104
- const attempt = async ({ socketPath, threadId, deadline }) => {
105
- if (!enabled || now() - failedAt < cooldownMs || !await desktopRunning()) return null;
106
- const executor = executorThreadId();
107
- const target = typeof threadId === "string" && THREAD_ID.test(threadId) && threadId !== executor
108
- ? threadId : await findTask(senderCwd(), [executor]);
109
- if (!target) return null;
110
- try { await open(target); }
111
- catch (error) {
112
- failedAt = now();
113
- log(`could not open Codex task ${target} to start the native relay: ${error.message}`);
114
- return null;
115
- }
116
- const until = Math.min(now() + waitMs, deadline ?? Infinity);
160
+ const waitFor = async (socketPath, until) => {
117
161
  while (now() < until) {
118
- if (await answers(socketPath)) {
162
+ if (await answers(socketPath)) return true;
163
+ await sleep(pollMs);
164
+ }
165
+ return false;
166
+ };
167
+ const attempt = async ({ socketPath, threadId, deadline, background = false }) => {
168
+ if (!enabled) return null;
169
+ const state = readWakeState(stateFile);
170
+ if (background) {
171
+ if (state.desktopRunning === false && now() - (state.desktopCheckedAt ?? -Infinity) < desktopRecheckMs) return null;
172
+ if (now() - (state.attemptAt ?? -Infinity) < Math.max(keeperBackoffMs(state.failures), cooldownMs)) return null;
173
+ } else if (now() - failedAt < cooldownMs) return null;
174
+ const running = await desktopRunning();
175
+ if (stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), desktopCheckedAt: now(), desktopRunning: Boolean(running) });
176
+ if (!running) return null;
177
+ const wait = background ? backgroundWaitMs : waitMs;
178
+ const release = acquireWakeLock(stateFile, now, wait + 15000);
179
+ if (!release) {
180
+ if (background) return null;
181
+ return await waitFor(socketPath, Math.min(now() + wait, deadline ?? Infinity)) ? { threadId: null } : null;
182
+ }
183
+ try {
184
+ const executor = executorThreadId();
185
+ const target = typeof threadId === "string" && THREAD_ID.test(threadId) && threadId !== executor
186
+ ? threadId : (await findTask(senderCwd(), [executor])) ?? (await findAnyTask([executor]));
187
+ const startedAt = now();
188
+ const failures = Number.isInteger(state.failures) ? state.failures : 0;
189
+ if (!target) {
190
+ if (background && stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), attemptAt: startedAt, failures: failures + 1 });
191
+ return null;
192
+ }
193
+ const fail = (message) => {
194
+ failedAt = now();
195
+ if (stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), attemptAt: startedAt, failures: failures + 1 });
196
+ log(message);
197
+ return null;
198
+ };
199
+ try { await open(target); }
200
+ catch (error) { return fail(`could not open Codex task ${target} to start the native relay: ${error.message}`); }
201
+ if (await waitFor(socketPath, Math.min(now() + wait, deadline ?? Infinity))) {
202
+ if (stateFile) writeWakeState(stateFile, { ...readWakeState(stateFile), attemptAt: startedAt, failures: 0 });
119
203
  log(`started the Codex Desktop native relay by opening task ${target} in the background`);
120
204
  return { threadId: target };
121
205
  }
122
- await sleep(pollMs);
206
+ return fail(`opened Codex task ${target}, but the native relay did not start within ${wait} ms`);
207
+ } finally {
208
+ release();
123
209
  }
124
- failedAt = now();
125
- log(`opened Codex task ${target}, but the native relay did not start within ${waitMs} ms`);
126
- return null;
127
210
  };
128
- return (request) => inFlight ??= attempt(request).finally(() => { inFlight = null; });
211
+ return (request) => {
212
+ if (!inFlight) return inFlight = attempt(request).finally(() => { inFlight = null; });
213
+ if (request.background || !request.socketPath) return inFlight;
214
+ const until = Math.min(now() + waitMs, request.deadline ?? Infinity);
215
+ return Promise.race([inFlight, waitFor(request.socketPath, until).then((ready) => ready ? { threadId: null } : null)]);
216
+ };
217
+ }
218
+
219
+ export function startRelayKeeper({
220
+ wake,
221
+ socketPath,
222
+ answers = endpointAnswers,
223
+ intervalMs = RELAY_KEEPER_INTERVAL_MS,
224
+ initialDelayMs = 3000,
225
+ timers = globalThis,
226
+ log = () => {},
227
+ }) {
228
+ let busy = false;
229
+ let stopped = false;
230
+ const tick = async () => {
231
+ if (busy || stopped) return null;
232
+ busy = true;
233
+ try {
234
+ const target = socketPath();
235
+ if (await answers(target)) return null;
236
+ return await wake({ socketPath: target, background: true });
237
+ } catch (error) {
238
+ log(`relay keeper check failed: ${error.message}`);
239
+ return null;
240
+ } finally {
241
+ busy = false;
242
+ }
243
+ };
244
+ const first = timers.setTimeout(tick, initialDelayMs);
245
+ const repeat = timers.setInterval(tick, intervalMs);
246
+ first?.unref?.();
247
+ repeat?.unref?.();
248
+ return {
249
+ tick,
250
+ stop() {
251
+ stopped = true;
252
+ timers.clearTimeout(first);
253
+ timers.clearInterval(repeat);
254
+ },
255
+ };
129
256
  }
@@ -86,10 +86,11 @@ export function defaultSitesConfigPath(env = process.env) {
86
86
  async function protectPrivatePath(target, directory = false) {
87
87
  if (process.platform !== "win32") { await fs.chmod(target, directory ? 0o700 : 0o600); return; }
88
88
  const run = promisify(execFile);
89
- const { stdout } = await run("whoami.exe", ["/user", "/fo", "csv", "/nh"], { windowsHide: true });
89
+ const system32 = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32");
90
+ const { stdout } = await run(path.win32.join(system32, "whoami.exe"), ["/user", "/fo", "csv", "/nh"], { windowsHide: true });
90
91
  const sid = stdout.match(/S-1-\d+(?:-\d+)+/)?.[0];
91
92
  if (!sid) throw new Error("Current Windows user SID could not be verified");
92
- await run("icacls.exe", [target, "/inheritance:r", "/grant:r", `*${sid}:${directory ? "(OI)(CI)" : ""}(F)`], { windowsHide: true });
93
+ await run(path.win32.join(system32, "icacls.exe"), [target, "/inheritance:r", "/grant:r", `*${sid}:${directory ? "(OI)(CI)" : ""}(F)`], { windowsHide: true });
93
94
  }
94
95
 
95
96
  export async function pairSitesConnector({ siteUrl, allowedRoots, configFile = defaultSitesConfigPath(), readAccounts = readBridgeAccounts, fetchImpl = fetch, allowLoopback = false }) {