@minhspark/codex-mcp-bridge 1.12.0 → 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.
@@ -60,10 +60,11 @@ function httpBase(wsUrl) {
60
60
  export function writerLockWarning(threadId) {
61
61
  return [
62
62
  "",
63
- `NOTE: this bridge still holds the writer lock on thread ${threadId}, because its app-server has the`,
64
- "thread loaded. Until that app-server stops, the Codex app will refuse to write to it and show",
65
- '"open in another application". Release it with stop_codex_app_server when the hand-off is done;',
66
- "the bridge starts a new app-server the next time it needs one.",
63
+ `NOTE: the app-server may still hold the writer lock on thread ${threadId}. The Codex app may`,
64
+ 'report "open in another application" until the thread unloads.',
65
+ "Automatic release unsubscribes only this connection; the server's idle unload delay and other",
66
+ "subscribers can keep the writer lock alive. stop_codex_app_server stops the shared server and",
67
+ "interrupts every active turn, so use it only when all work on that server can stop.",
67
68
  ].join("\n");
68
69
  }
69
70
 
@@ -98,7 +99,12 @@ export class CodexAppServerClient {
98
99
  this.threadListeners = new Map();
99
100
  this.disconnectListeners = new Set();
100
101
  this.attachedThreads = new Set();
102
+ this.unsubscribedThreads = new Set();
101
103
  this.threadCwds = new Map();
104
+ this.threadOperations = new Map();
105
+ this.attachingThreads = new Map();
106
+ this.activeTurns = new Map();
107
+ this.connectionEpoch = 0;
102
108
  }
103
109
 
104
110
  async isServerUp() {
@@ -139,11 +145,11 @@ export class CodexAppServerClient {
139
145
  * delegations.
140
146
  */
141
147
  async stopServer() {
148
+ this.close();
142
149
  if (!(await this.isServerUp())) return { stopped: false, reason: "no app-server was listening" };
143
150
  const port = appServerPort(this.url);
144
151
  const pids = listeningPids(port);
145
152
  if (!pids.length) return { stopped: false, reason: `nothing is listening on port ${port}` };
146
- this.ws?.close();
147
153
  for (const pid of pids) {
148
154
  try {
149
155
  if (process.platform === "win32") {
@@ -158,9 +164,6 @@ export class CodexAppServerClient {
158
164
  this.log(`could not stop pid ${pid}: ${err.message}`);
159
165
  }
160
166
  }
161
- this.ws = null;
162
- this.attachedThreads.clear();
163
- this.threadCwds.clear();
164
167
  for (let attempt = 0; attempt < 20; attempt += 1) {
165
168
  if (!(await this.isServerUp())) return { stopped: true, pids };
166
169
  await delay(100);
@@ -175,33 +178,67 @@ export class CodexAppServerClient {
175
178
  * first call instead of a failed tool call the user has to repeat by hand.
176
179
  */
177
180
  async connect() {
178
- if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
179
181
  if (this.connecting) return this.connecting;
182
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
183
+ if (this.ws) this.#disconnect(this.ws);
180
184
 
181
- this.connecting = (async () => {
185
+ const epoch = this.connectionEpoch;
186
+ const connecting = (async () => {
182
187
  let lastError = null;
183
188
  for (let attempt = 1; attempt <= CONNECT_ATTEMPTS; attempt += 1) {
184
189
  try {
185
- await this.#openConnection();
190
+ this.#assertConnectionEpoch(epoch);
191
+ await this.#openConnection(epoch);
186
192
  return;
187
193
  } catch (err) {
188
194
  lastError = err;
195
+ this.#assertConnectionEpoch(epoch);
189
196
  this.log(`connect attempt ${attempt}/${CONNECT_ATTEMPTS} failed: ${err.message}`);
190
197
  if (attempt < CONNECT_ATTEMPTS) await delay(CONNECT_RETRY_DELAY_MS);
191
198
  }
192
199
  }
193
200
  throw lastError;
194
201
  })();
202
+ this.connecting = connecting;
195
203
 
196
204
  try {
197
- await this.connecting;
205
+ await connecting;
198
206
  } finally {
199
- this.connecting = null;
207
+ if (this.connecting === connecting) this.connecting = null;
200
208
  }
201
209
  }
202
210
 
203
- async #openConnection() {
211
+ #assertConnectionEpoch(epoch) {
212
+ if (epoch !== this.connectionEpoch) {
213
+ throw new AppServerError("App-server connection attempt was cancelled", "CONNECTION_CLOSED");
214
+ }
215
+ }
216
+
217
+ #disconnect(ws, error = new AppServerError("Connection to Codex app-server closed", "CONNECTION_CLOSED")) {
218
+ if (!ws || this.ws !== ws) return;
219
+ this.log("app-server connection closed");
220
+ this.ws = null;
221
+ this.attachedThreads.clear();
222
+ this.unsubscribedThreads.clear();
223
+ this.threadCwds.clear();
224
+ this.attachingThreads.clear();
225
+ for (const active of this.activeTurns.values()) active.needsReconcile = true;
226
+ const pending = [...this.pending.values()];
227
+ this.pending.clear();
228
+ for (const entry of pending) entry.reject(error);
229
+ this.#notifyDisconnect();
230
+ }
231
+
232
+ close() {
233
+ this.connectionEpoch += 1;
234
+ const ws = this.ws;
235
+ this.#disconnect(ws);
236
+ ws?.close();
237
+ }
238
+
239
+ async #openConnection(epoch) {
204
240
  if (!(await this.isServerUp())) {
241
+ this.#assertConnectionEpoch(epoch);
205
242
  if (!this.autoStart) {
206
243
  throw new AppServerError(
207
244
  `No Codex app-server reachable at ${this.url}. Start one with: codex app-server --listen ${this.url}`,
@@ -213,48 +250,55 @@ export class CodexAppServerClient {
213
250
  }
214
251
  }
215
252
 
253
+ this.#assertConnectionEpoch(epoch);
216
254
  const ws = new WebSocket(this.url);
217
- await new Promise((resolve, reject) => {
218
- const timer = globalThis.setTimeout(
219
- () => reject(new AppServerError(`Timed out connecting to ${this.url}`)),
220
- 15000,
221
- );
222
- ws.onopen = () => {
223
- globalThis.clearTimeout(timer);
224
- resolve();
225
- };
226
- ws.onerror = (event) => {
227
- globalThis.clearTimeout(timer);
228
- reject(new AppServerError(`WebSocket error against ${this.url}: ${event?.message ?? "unknown"}`));
229
- };
230
- ws.onclose = () => {
231
- globalThis.clearTimeout(timer);
232
- reject(new AppServerError(`Connection to ${this.url} closed during the handshake`));
233
- };
234
- });
255
+ this.ws = ws;
256
+ try {
257
+ await new Promise((resolve, reject) => {
258
+ const timer = globalThis.setTimeout(
259
+ () => reject(new AppServerError(`Timed out connecting to ${this.url}`)),
260
+ 15000,
261
+ );
262
+ ws.onopen = () => {
263
+ globalThis.clearTimeout(timer);
264
+ resolve();
265
+ };
266
+ ws.onerror = (event) => {
267
+ globalThis.clearTimeout(timer);
268
+ reject(new AppServerError(`WebSocket error against ${this.url}: ${event?.message ?? "unknown"}`));
269
+ };
270
+ ws.onclose = () => {
271
+ globalThis.clearTimeout(timer);
272
+ reject(new AppServerError(`Connection to ${this.url} closed during the handshake`));
273
+ };
274
+ });
275
+ } catch (err) {
276
+ this.#disconnect(ws, err);
277
+ ws.close();
278
+ throw err;
279
+ }
235
280
 
236
- ws.onmessage = (event) => this.#handleMessage(event.data);
237
- ws.onclose = () => {
238
- if (this.ws !== ws) return;
239
- this.log("app-server connection closed");
240
- this.ws = null;
241
- this.attachedThreads.clear();
242
- this.threadCwds.clear();
243
- for (const [, entry] of this.pending) {
244
- entry.reject(new AppServerError("Connection to Codex app-server closed"));
245
- }
246
- this.pending.clear();
247
- this.#notifyDisconnect();
281
+ ws.onmessage = (event) => {
282
+ if (this.ws === ws) this.#handleMessage(event.data);
248
283
  };
284
+ ws.onclose = () => this.#disconnect(ws);
249
285
  ws.onerror = (event) => this.log(`websocket error: ${event?.message ?? "unknown"}`);
250
286
 
251
- this.ws = ws;
252
- const init = await this.request("initialize", {
253
- clientInfo: this.clientInfo,
254
- capabilities: { experimentalApi: true },
255
- });
256
- this.#send({ jsonrpc: "2.0", method: "initialized", params: {} });
257
- this.log(`connected to app-server (codexHome=${init?.codexHome ?? "?"})`);
287
+ try {
288
+ this.#assertConnectionEpoch(epoch);
289
+ this.ws = ws;
290
+ const init = await this.request("initialize", {
291
+ clientInfo: this.clientInfo,
292
+ capabilities: { experimentalApi: true },
293
+ });
294
+ this.#assertConnectionEpoch(epoch);
295
+ this.#send({ jsonrpc: "2.0", method: "initialized", params: {} });
296
+ this.log(`connected to app-server (codexHome=${init?.codexHome ?? "?"})`);
297
+ } catch (err) {
298
+ this.#disconnect(ws, err);
299
+ ws.close();
300
+ throw err;
301
+ }
258
302
  }
259
303
 
260
304
  #send(payload) {
@@ -272,6 +316,7 @@ export class CodexAppServerClient {
272
316
  this.log(`ignored non-JSON frame (${String(raw).slice(0, 80)})`);
273
317
  return;
274
318
  }
319
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
275
320
 
276
321
  if (msg.id !== undefined && msg.method === undefined) {
277
322
  const entry = this.pending.get(msg.id);
@@ -296,6 +341,24 @@ export class CodexAppServerClient {
296
341
  #dispatchNotification(msg) {
297
342
  const threadId = msg.params?.threadId;
298
343
  if (!threadId) return;
344
+ if (msg.method === "thread/closed") {
345
+ this.attachedThreads.delete(threadId);
346
+ this.unsubscribedThreads.delete(threadId);
347
+ this.threadCwds.delete(threadId);
348
+ this.activeTurns.delete(threadId);
349
+ }
350
+ const active = this.activeTurns.get(threadId);
351
+ if (active && msg.method === "turn/started" && msg.params?.turn?.id && !active.turnId) {
352
+ active.turnId = msg.params.turn.id;
353
+ }
354
+ if (active && msg.method === "turn/completed" && msg.params?.turn?.id &&
355
+ ["completed", "interrupted", "failed"].includes(msg.params.turn.status)) {
356
+ const completedId = msg.params.turn.id;
357
+ if (!active.turnId) active.completedIds.add(completedId);
358
+ if (active.turnId === completedId || (!active.turnId && !active.awaitingStartResponse && !active.needsReconcile)) {
359
+ this.activeTurns.delete(threadId);
360
+ }
361
+ }
299
362
  const listeners = this.threadListeners.get(threadId);
300
363
  if (!listeners) return;
301
364
  for (const listener of [...listeners]) {
@@ -377,11 +440,20 @@ export class CodexAppServerClient {
377
440
  }
378
441
 
379
442
  async request(method, params, { timeoutMs = 60000 } = {}) {
443
+ const threadId = method === "turn/start" ? params?.threadId : null;
444
+ let active = null;
445
+ if (threadId) {
446
+ if (this.activeTurns.has(threadId)) {
447
+ throw new AppServerError(`Thread ${threadId} already has a running or unconfirmed turn`, "THREAD_BUSY");
448
+ }
449
+ active = { turnId: null, completedIds: new Set(), awaitingStartResponse: true };
450
+ this.activeTurns.set(threadId, active);
451
+ }
380
452
  const id = ++this.nextId;
381
453
  const promise = new Promise((resolve, reject) => {
382
454
  const timer = globalThis.setTimeout(() => {
383
455
  this.pending.delete(id);
384
- reject(new AppServerError(`Request ${method} timed out after ${timeoutMs}ms`));
456
+ reject(new AppServerError(`Request ${method} timed out after ${timeoutMs}ms`, "REQUEST_TIMEOUT"));
385
457
  }, timeoutMs);
386
458
  this.pending.set(id, {
387
459
  resolve: (value) => {
@@ -393,9 +465,30 @@ export class CodexAppServerClient {
393
465
  reject(err);
394
466
  },
395
467
  });
468
+ try {
469
+ this.#send({ jsonrpc: "2.0", id, method, params: params ?? {} });
470
+ } catch (err) {
471
+ const entry = this.pending.get(id);
472
+ this.pending.delete(id);
473
+ entry.reject(err);
474
+ }
396
475
  });
397
- this.#send({ jsonrpc: "2.0", id, method, params: params ?? {} });
398
- return promise;
476
+ try {
477
+ const result = await promise;
478
+ if (active && this.activeTurns.get(threadId) === active) {
479
+ active.turnId = result?.turn?.id ?? null;
480
+ active.awaitingStartResponse = false;
481
+ if (active.completedIds.has(active.turnId) || ["completed", "interrupted", "failed"].includes(result?.turn?.status)) {
482
+ this.activeTurns.delete(threadId);
483
+ }
484
+ }
485
+ return result;
486
+ } catch (err) {
487
+ if (active && !["REQUEST_TIMEOUT", "CONNECTION_CLOSED"].includes(err.code) && this.activeTurns.get(threadId) === active) {
488
+ this.activeTurns.delete(threadId);
489
+ }
490
+ throw err;
491
+ }
399
492
  }
400
493
 
401
494
  async call(method, params, opts) {
@@ -437,23 +530,136 @@ export class CodexAppServerClient {
437
530
 
438
531
  async ensureThreadAttached(threadId, resumeParams = {}) {
439
532
  await this.connect();
440
- if (this.attachedThreads.has(threadId)) return { resumed: false, thread: this.threadCwds.get(threadId) };
441
- const result = await this.request("thread/resume", { threadId, ...resumeParams });
442
- this.attachedThreads.add(threadId);
443
- if (result?.thread?.cwd) this.threadCwds.set(threadId, result.thread);
444
- return { resumed: true, thread: result?.thread };
533
+ if (this.attachedThreads.has(threadId)) {
534
+ if (this.activeTurns.get(threadId)?.needsReconcile) await this.#reconcileThreadTurn(threadId);
535
+ return { resumed: false, thread: this.threadCwds.get(threadId) };
536
+ }
537
+ if (this.attachingThreads.has(threadId)) return this.attachingThreads.get(threadId);
538
+ const ws = this.ws;
539
+ const attaching = (async () => {
540
+ const result = await this.request("thread/resume", { ...resumeParams, threadId });
541
+ if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
542
+ throw new AppServerError("Connection closed while attaching the thread", "CONNECTION_CLOSED");
543
+ }
544
+ this.markAttached(threadId, result?.thread);
545
+ await this.#reconcileThreadTurn(threadId, result?.thread);
546
+ return { resumed: true, thread: result?.thread };
547
+ })();
548
+ this.attachingThreads.set(threadId, attaching);
549
+ try {
550
+ return await attaching;
551
+ } finally {
552
+ if (this.attachingThreads.get(threadId) === attaching) this.attachingThreads.delete(threadId);
553
+ }
554
+ }
555
+
556
+ async #reconcileThreadTurn(threadId, thread = null) {
557
+ const inspect = (snapshot) => {
558
+ if (!snapshot || (snapshot.id && snapshot.id !== threadId)) return false;
559
+ const status = typeof snapshot.status === "string" ? snapshot.status : snapshot.status?.type;
560
+ const turns = Array.isArray(snapshot.turns) ? snapshot.turns : [];
561
+ const running = turns.find((turn) => turn?.status === "inProgress");
562
+ const active = this.activeTurns.get(threadId);
563
+ if (running || status === "active") {
564
+ this.activeTurns.set(threadId, {
565
+ turnId: running?.id ?? null,
566
+ completedIds: new Set(),
567
+ awaitingStartResponse: false,
568
+ needsReconcile: false,
569
+ });
570
+ return true;
571
+ }
572
+ if (status === "idle" || status === "notLoaded" || (active?.turnId && turns.some((turn) =>
573
+ turn?.id === active.turnId && ["completed", "interrupted", "failed"].includes(turn.status)))) {
574
+ this.activeTurns.delete(threadId);
575
+ return true;
576
+ }
577
+ return false;
578
+ };
579
+ if (inspect(thread) || !this.activeTurns.has(threadId)) return;
580
+ const active = this.activeTurns.get(threadId);
581
+ active.needsReconcile = true;
582
+ const ws = this.ws;
583
+ const result = await this.request("thread/read", { threadId, includeTurns: true });
584
+ if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
585
+ throw new AppServerError("Connection closed while reconciling the thread", "CONNECTION_CLOSED");
586
+ }
587
+ if (!inspect(result?.thread)) {
588
+ throw new AppServerError(
589
+ `Cannot confirm whether thread ${threadId} still has its previous turn running; refusing to start another turn`,
590
+ "THREAD_STATE_UNCONFIRMED",
591
+ );
592
+ }
593
+ }
594
+
595
+ async withThread(threadId, action) {
596
+ const previous = this.threadOperations.get(threadId);
597
+ const operation = (previous ? previous.catch(() => {}) : Promise.resolve()).then(action);
598
+ this.threadOperations.set(threadId, operation);
599
+ try {
600
+ return await operation;
601
+ } finally {
602
+ if (this.threadOperations.get(threadId) === operation) this.threadOperations.delete(threadId);
603
+ }
604
+ }
605
+
606
+ async releaseThread(threadId, { timeoutMs = 1000 } = {}) {
607
+ const outcome = (status, unsubscribed, released, reason) => ({
608
+ threadId, status, unsubscribed, released, ...(reason ? { reason } : {}),
609
+ });
610
+ if (this.activeTurns.has(threadId) || this.threadListeners.get(threadId)?.size || this.attachingThreads.has(threadId)) {
611
+ return outcome("busy", false, false, "The thread has an active or unconfirmed turn or operation.");
612
+ }
613
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connecting) {
614
+ return outcome("disconnected", false, false, "No initialized connection is available to release this thread.");
615
+ }
616
+ let closed = false;
617
+ let disconnected = false;
618
+ let finishWait;
619
+ let timer;
620
+ const changed = new Promise((resolve) => { finishWait = resolve; });
621
+ const unsubscribe = this.subscribe(threadId, (msg) => {
622
+ if (msg.method !== "thread/closed") return;
623
+ closed = true;
624
+ finishWait();
625
+ });
626
+ const unsubscribeDisconnect = this.subscribeDisconnect(() => {
627
+ disconnected = true;
628
+ finishWait();
629
+ });
630
+ try {
631
+ const result = await this.request("thread/unsubscribe", { threadId }, { timeoutMs: 5000 });
632
+ if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(result?.status)) {
633
+ return outcome("invalidResponse", false, false, "The server did not confirm thread unsubscription.");
634
+ }
635
+ this.attachedThreads.delete(threadId);
636
+ this.threadCwds.delete(threadId);
637
+ if (result.status === "notLoaded" || closed) {
638
+ this.unsubscribedThreads.delete(threadId);
639
+ return outcome(result.status, true, true);
640
+ }
641
+ if (!disconnected) this.unsubscribedThreads.add(threadId);
642
+ const waitMs = Number.isFinite(timeoutMs) ? Math.max(0, Math.min(timeoutMs, 5000)) : 1000;
643
+ timer = globalThis.setTimeout(finishWait, waitMs);
644
+ await changed;
645
+ return outcome(result.status, true, closed, closed ? null : disconnected
646
+ ? "Subscription removed; the connection closed before thread unload was confirmed."
647
+ : "Subscription removed; thread unload is pending the server's idle delay or other subscribers.");
648
+ } catch (err) {
649
+ return outcome(err.code === -32601 ? "unsupported" : "failed", false, false, err.message);
650
+ } finally {
651
+ globalThis.clearTimeout(timer);
652
+ unsubscribe();
653
+ unsubscribeDisconnect();
654
+ }
445
655
  }
446
656
 
447
- /**
448
- * The app-server takes the per-thread writer lock when it loads a thread and
449
- * keeps it until it exits, so a thread this bridge has attached cannot be
450
- * written to from anywhere else - the desktop app included.
451
- */
452
657
  holdsThread(threadId) {
453
- return this.attachedThreads.has(threadId);
658
+ return this.attachedThreads.has(threadId) || this.unsubscribedThreads.has(threadId);
454
659
  }
455
660
 
456
661
  markAttached(threadId, thread = null) {
662
+ this.unsubscribedThreads.delete(threadId);
457
663
  this.attachedThreads.add(threadId);
458
664
  if (thread?.cwd) this.threadCwds.set(threadId, thread);
459
665
  }
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
8
8
  import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
9
9
  import { createThreadDelivery } from "./thread-delivery.mjs";
10
10
 
11
- const VERSION = "1.12.0";
11
+ const VERSION = "1.12.2";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
@@ -131,7 +131,9 @@ server.registerTool(
131
131
  title: "Send a message to a Claude session",
132
132
  description:
133
133
  "Deliver a message into a running Claude Code session. It appears in that session's chat exactly like " +
134
- "a message from a teammate, and Claude can reply. Set waitSec to 0 to fire and forget.",
134
+ "a message from a teammate, and Claude can reply. Set waitSec to 0 to fire and forget. " +
135
+ "A waited send is refused while earlier messages to that session still await replies; " +
136
+ "wait for those replies and read_claude_inbox before trying again.",
135
137
  inputSchema: {
136
138
  target: z.string().describe("Session name, pid or sessionId from list_claude_sessions"),
137
139
  message: z.string().describe("The message text to deliver"),
@@ -156,14 +158,12 @@ server.registerTool(
156
158
  const session = findClaudeSession(target);
157
159
  if (!session) return textResult(`No live Claude session matches "${target}".`, true);
158
160
 
159
- const since = Date.now();
160
- await peer.send(session.socket, message);
161
+ const wait = waitSec ?? 180;
162
+ const { reply } = await peer.sendAndWait(session.socket, message, { timeoutMs: wait * 1000 });
161
163
  const header = `delivered to ${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
162
164
 
163
- const wait = waitSec ?? 180;
164
165
  if (wait === 0) return textResult(`${header}\nnot waiting for a reply.`);
165
166
 
166
- const reply = await peer.waitForReply(session.socket, { timeoutMs: wait * 1000, since });
167
167
  if (!reply) {
168
168
  return textResult(
169
169
  `${header}\n\nNo reply within ${wait}s. Claude may still be working - check again with read_claude_inbox.`,