@raysonmeng/agentbridge 0.1.4 → 0.1.5

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.
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "name": "agentbridge",
14
14
  "description": "Bridge Claude Code and Codex through a shared daemon, push channel delivery, and reply/get_messages tools.",
15
- "version": "0.1.4",
15
+ "version": "0.1.5",
16
16
  "author": {
17
17
  "name": "AgentBridge Contributors",
18
18
  "email": "raysonmeng@qq.com"
package/dist/cli.js CHANGED
@@ -271,7 +271,7 @@ var exports_dev = {};
271
271
  __export(exports_dev, {
272
272
  runDev: () => runDev
273
273
  });
274
- import { execFileSync as execFileSync3 } from "child_process";
274
+ import { execFileSync as execFileSync3, spawnSync } from "child_process";
275
275
  import { resolve } from "path";
276
276
  import { existsSync as existsSync3, cpSync, rmSync } from "fs";
277
277
  import { homedir } from "os";
@@ -282,6 +282,28 @@ async function runDev() {
282
282
  const marketplacePath = resolve(projectRoot, ".claude-plugin", "marketplace.json");
283
283
  const pluginDir = resolve(projectRoot, "plugins", "agentbridge");
284
284
  const pluginManifest = resolve(pluginDir, ".claude-plugin", "plugin.json");
285
+ console.log("Building CLI from source...");
286
+ const cliBuild = spawnSync("bun", ["run", "build:cli"], {
287
+ cwd: projectRoot,
288
+ stdio: "inherit"
289
+ });
290
+ if (cliBuild.status !== 0) {
291
+ console.error(" ERROR: CLI build failed. Fix build errors and try again.");
292
+ process.exit(1);
293
+ }
294
+ console.log(` \u2713 CLI built successfully
295
+ `);
296
+ console.log("Building plugin from source...");
297
+ const buildResult = spawnSync("bun", ["run", "build:plugin"], {
298
+ cwd: projectRoot,
299
+ stdio: "inherit"
300
+ });
301
+ if (buildResult.status !== 0) {
302
+ console.error(" ERROR: Plugin build failed. Fix build errors and try again.");
303
+ process.exit(1);
304
+ }
305
+ console.log(` \u2713 Plugin built successfully
306
+ `);
285
307
  if (!existsSync3(pluginManifest)) {
286
308
  console.error(` ERROR: Plugin manifest not found at ${pluginManifest}`);
287
309
  console.error(" Run 'bun run build:plugin' first, or check your working tree.");
@@ -1012,7 +1034,7 @@ var init_kill = __esm(() => {
1012
1034
  var require_package = __commonJS((exports, module) => {
1013
1035
  module.exports = {
1014
1036
  name: "@raysonmeng/agentbridge",
1015
- version: "0.1.4",
1037
+ version: "0.1.5",
1016
1038
  description: "Bridge between Claude Code and Codex \u2014 bidirectional agent communication via MCP Channel + JSON-RPC",
1017
1039
  type: "module",
1018
1040
  bin: {
@@ -1031,13 +1053,14 @@ var require_package = __commonJS((exports, module) => {
1031
1053
  start: "bun run src/bridge.ts",
1032
1054
  "build:cli": "mkdir -p dist && bun build src/cli.ts --outfile dist/cli.js --target bun && chmod +x dist/cli.js",
1033
1055
  "build:plugin": "mkdir -p plugins/agentbridge/server && bun build src/bridge.ts --outfile plugins/agentbridge/server/bridge-server.js --target bun && bun build src/daemon.ts --outfile plugins/agentbridge/server/daemon.js --target bun",
1056
+ "verify:plugin-sync": "node scripts/verify-plugin-sync.cjs",
1034
1057
  postinstall: "node scripts/postinstall.cjs",
1035
1058
  prepublishOnly: "bun run build:cli && bun run build:plugin",
1036
1059
  "validate:plugin": "claude plugin validate plugins/agentbridge && claude plugin validate .claude-plugin/marketplace.json",
1037
1060
  test: "bun test src",
1038
1061
  typecheck: "tsc --noEmit",
1039
1062
  "validate:plugin-versions": "bun scripts/check-plugin-versions.js",
1040
- check: "tsc --noEmit && bun test src && bun run build:plugin && bun scripts/check-plugin-versions.js"
1063
+ check: "tsc --noEmit && bun test src && bun run verify:plugin-sync && bun scripts/check-plugin-versions.js"
1041
1064
  },
1042
1065
  repository: {
1043
1066
  type: "git",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raysonmeng/agentbridge",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Bridge between Claude Code and Codex — bidirectional agent communication via MCP Channel + JSON-RPC",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,13 +19,14 @@
19
19
  "start": "bun run src/bridge.ts",
20
20
  "build:cli": "mkdir -p dist && bun build src/cli.ts --outfile dist/cli.js --target bun && chmod +x dist/cli.js",
21
21
  "build:plugin": "mkdir -p plugins/agentbridge/server && bun build src/bridge.ts --outfile plugins/agentbridge/server/bridge-server.js --target bun && bun build src/daemon.ts --outfile plugins/agentbridge/server/daemon.js --target bun",
22
+ "verify:plugin-sync": "node scripts/verify-plugin-sync.cjs",
22
23
  "postinstall": "node scripts/postinstall.cjs",
23
24
  "prepublishOnly": "bun run build:cli && bun run build:plugin",
24
25
  "validate:plugin": "claude plugin validate plugins/agentbridge && claude plugin validate .claude-plugin/marketplace.json",
25
26
  "test": "bun test src",
26
27
  "typecheck": "tsc --noEmit",
27
28
  "validate:plugin-versions": "bun scripts/check-plugin-versions.js",
28
- "check": "tsc --noEmit && bun test src && bun run build:plugin && bun scripts/check-plugin-versions.js"
29
+ "check": "tsc --noEmit && bun test src && bun run verify:plugin-sync && bun scripts/check-plugin-versions.js"
29
30
  },
30
31
  "repository": {
31
32
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentbridge",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Bridge Claude Code and Codex with a shared daemon, push channel delivery, and bidirectional reply tooling.",
5
5
  "author": {
6
6
  "name": "AgentBridge Contributors",
@@ -13749,8 +13749,8 @@ class ClaudeAdapter extends EventEmitter {
13749
13749
  this.resolvedMode = this.configuredMode;
13750
13750
  this.log(`Delivery mode set by AGENTBRIDGE_MODE: ${this.resolvedMode}`);
13751
13751
  } else {
13752
- this.resolvedMode = "push";
13753
- this.log("Delivery mode defaulting to push (set AGENTBRIDGE_MODE=pull for API key mode)");
13752
+ this.resolvedMode = "pull";
13753
+ this.log("Delivery mode defaulting to pull (set AGENTBRIDGE_MODE=push to opt into channel delivery)");
13754
13754
  }
13755
13755
  }
13756
13756
  async pushNotification(message) {
@@ -13781,6 +13781,7 @@ class ClaudeAdapter extends EventEmitter {
13781
13781
  this.log(`Pushed notification: ${msgId}`);
13782
13782
  } catch (e) {
13783
13783
  this.log(`Push notification failed: ${e.message}`);
13784
+ this.queueForPull(message);
13784
13785
  }
13785
13786
  }
13786
13787
  queueForPull(message) {
@@ -13929,6 +13930,11 @@ ${formatted}`
13929
13930
 
13930
13931
  // src/daemon-client.ts
13931
13932
  import { EventEmitter as EventEmitter2 } from "events";
13933
+
13934
+ // src/control-protocol.ts
13935
+ var CLOSE_CODE_REPLACED = 4001;
13936
+
13937
+ // src/daemon-client.ts
13932
13938
  var nextSocketId = 0;
13933
13939
 
13934
13940
  class DaemonClient extends EventEmitter2 {
@@ -14047,7 +14053,11 @@ class DaemonClient extends EventEmitter2 {
14047
14053
  if (isCurrent) {
14048
14054
  this.ws = null;
14049
14055
  this.rejectPendingReplies("AgentBridge daemon disconnected.");
14050
- this.emit("disconnect");
14056
+ if (event.code === CLOSE_CODE_REPLACED) {
14057
+ this.emit("rejected");
14058
+ } else {
14059
+ this.emit("disconnect");
14060
+ }
14051
14061
  }
14052
14062
  };
14053
14063
  ws.onerror = () => {};
@@ -14499,6 +14509,16 @@ class ConfigService {
14499
14509
  }
14500
14510
  }
14501
14511
 
14512
+ // src/bridge-disabled-state.ts
14513
+ function disabledReplyError(reason) {
14514
+ switch (reason) {
14515
+ case "rejected":
14516
+ return "AgentBridge rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run `agentbridge kill` to reset.";
14517
+ case "killed":
14518
+ return "AgentBridge is disabled by `agentbridge kill`. Restart Claude Code (`agentbridge claude`), switch to a new conversation, or run `/resume` to reconnect.";
14519
+ }
14520
+ }
14521
+
14502
14522
  // src/bridge.ts
14503
14523
  var stateDir = new StateDirResolver;
14504
14524
  var configService = new ConfigService;
@@ -14510,6 +14530,7 @@ var claude = new ClaudeAdapter;
14510
14530
  var daemonClient = new DaemonClient(CONTROL_WS_URL);
14511
14531
  var shuttingDown = false;
14512
14532
  var daemonDisabled = false;
14533
+ var daemonDisabledReason = null;
14513
14534
  var RECONNECT_NOTIFY_COOLDOWN_MS = 30000;
14514
14535
  var DISABLED_RECOVERY_INTERVAL_MS = 5000;
14515
14536
  var lastDisconnectNotifyTs = 0;
@@ -14523,7 +14544,7 @@ claude.setReplySender(async (msg, requireReply) => {
14523
14544
  if (daemonDisabled) {
14524
14545
  return {
14525
14546
  success: false,
14526
- error: "AgentBridge is disabled by `agentbridge kill`. Restart Claude Code (`agentbridge claude`), switch to a new conversation, or run `/resume` to reconnect."
14547
+ error: disabledReplyError(daemonDisabledReason ?? "killed")
14527
14548
  };
14528
14549
  }
14529
14550
  return daemonClient.sendReply(msg, requireReply);
@@ -14548,6 +14569,15 @@ daemonClient.on("disconnect", () => {
14548
14569
  }
14549
14570
  reconnectToDaemon();
14550
14571
  });
14572
+ daemonClient.on("rejected", async () => {
14573
+ if (shuttingDown || daemonDisabled)
14574
+ return;
14575
+ log("Daemon rejected this session (close code 4001) \u2014 another Claude session is already connected");
14576
+ daemonDisabled = true;
14577
+ daemonDisabledReason = "rejected";
14578
+ await claude.pushNotification(systemMessage("system_bridge_replaced", "\u26A0\uFE0F AgentBridge daemon rejected this session \u2014 another Claude Code session is already connected. Close the other session first, or run `agentbridge kill` to reset. AgentBridge \u5B88\u62A4\u8FDB\u7A0B\u62D2\u7EDD\u4E86\u6B64\u4F1A\u8BDD\u2014\u2014\u53E6\u4E00\u4E2A Claude Code \u4F1A\u8BDD\u5DF2\u5728\u8FDE\u63A5\u4E2D\u3002\u8BF7\u5148\u5173\u95ED\u53E6\u4E00\u4E2A\u4F1A\u8BDD\uFF0C\u6216\u8FD0\u884C `agentbridge kill` \u91CD\u7F6E\u3002"));
14579
+ await daemonClient.disconnect();
14580
+ });
14551
14581
  claude.on("ready", async () => {
14552
14582
  log(`MCP server ready (delivery mode: ${claude.getDeliveryMode()}) \u2014 ensuring AgentBridge daemon...`);
14553
14583
  if (daemonLifecycle.wasKilled()) {
@@ -14565,6 +14595,7 @@ async function connectToDaemon(isReconnect = false) {
14565
14595
  await daemonLifecycle.ensureRunning();
14566
14596
  await daemonClient.connect();
14567
14597
  daemonClient.attachClaude();
14598
+ daemonDisabledReason = null;
14568
14599
  if (!isReconnect) {
14569
14600
  claude.pushNotification(systemMessage("system_bridge_ready", "\u2705 AgentBridge bridge is ready. Daemon connected. Start Codex in another terminal with: agentbridge codex"));
14570
14601
  }
@@ -14578,6 +14609,7 @@ async function enterDisabledState(logMessage, notificationContent) {
14578
14609
  if (daemonDisabled)
14579
14610
  return;
14580
14611
  daemonDisabled = true;
14612
+ daemonDisabledReason = "killed";
14581
14613
  log(logMessage);
14582
14614
  await claude.pushNotification(systemMessage("system_bridge_disabled", notificationContent));
14583
14615
  await daemonClient.disconnect();
@@ -14666,11 +14698,13 @@ async function pollDisabledRecovery() {
14666
14698
  await daemonClient.connect();
14667
14699
  daemonClient.attachClaude();
14668
14700
  daemonDisabled = false;
14701
+ daemonDisabledReason = null;
14669
14702
  stopDisabledRecoveryPoller();
14670
14703
  claude.pushNotification(systemMessage("system_bridge_recovered", "\u2705 AgentBridge recovered after the killed sentinel was cleared. Daemon reconnected."));
14671
14704
  } catch (err) {
14672
14705
  log(`Disabled-state direct reconnect failed: ${err.message}`);
14673
14706
  daemonDisabled = false;
14707
+ daemonDisabledReason = null;
14674
14708
  stopDisabledRecoveryPoller();
14675
14709
  reconnectToDaemon();
14676
14710
  }
@@ -67,6 +67,8 @@ class CodexAdapter extends EventEmitter {
67
67
  appPort;
68
68
  proxyPort;
69
69
  tuiConnId = 0;
70
+ connIdCounter = 0;
71
+ secondaryConnections = new Map;
70
72
  agentMessageBuffers = new Map;
71
73
  pendingRequests = new Map;
72
74
  activeTurnIds = new Set;
@@ -74,11 +76,15 @@ class CodexAdapter extends EventEmitter {
74
76
  nextProxyId = 1e5;
75
77
  upstreamToClient = new Map;
76
78
  serverRequestToProxy = new Map;
77
- serverRequestTtlTimers = new Map;
78
79
  pendingServerRequests = [];
80
+ pendingServerResponses = new Map;
79
81
  staleProxyIds = new Map;
80
82
  bridgeRequestIds = new Map;
81
83
  intentionalDisconnect = false;
84
+ pendingTuiMessages = [];
85
+ reconnectingForNewSession = false;
86
+ replayingBufferedMessages = false;
87
+ appServerGeneration = 0;
82
88
  constructor(appPort = 4500, proxyPort = 4501) {
83
89
  super();
84
90
  this.appPort = appPort;
@@ -119,6 +125,12 @@ class CodexAdapter extends EventEmitter {
119
125
  }
120
126
  this.appServerWs?.close();
121
127
  this.appServerWs = null;
128
+ for (const [id, sec] of this.secondaryConnections) {
129
+ try {
130
+ sec.appServerWs?.close();
131
+ } catch {}
132
+ this.secondaryConnections.delete(id);
133
+ }
122
134
  this.proxyServer?.stop();
123
135
  this.proxyServer = null;
124
136
  this.clearResponseTrackingState();
@@ -179,16 +191,24 @@ class CodexAdapter extends EventEmitter {
179
191
  throw new Error("Codex app-server failed to become healthy");
180
192
  }
181
193
  connectToAppServer(isReconnect = false) {
194
+ const generation = ++this.appServerGeneration;
182
195
  return new Promise((resolve, reject) => {
183
196
  const appWs = new WebSocket(this.appServerUrl);
184
197
  appWs.onopen = () => {
198
+ if (this.appServerGeneration !== generation) {
199
+ appWs.close();
200
+ return;
201
+ }
185
202
  this.appServerWs = appWs;
186
203
  this.intentionalDisconnect = false;
187
204
  this.reconnectAttempts = 0;
188
- this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server (persistent)");
205
+ this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server");
206
+ this.flushPendingServerResponses();
189
207
  resolve();
190
208
  };
191
209
  appWs.onmessage = (event) => {
210
+ if (this.appServerGeneration !== generation)
211
+ return;
192
212
  const data = typeof event.data === "string" ? event.data : event.data.toString();
193
213
  const forwarded = this.handleAppServerPayload(data);
194
214
  if (forwarded === null)
@@ -204,22 +224,58 @@ class CodexAdapter extends EventEmitter {
204
224
  }
205
225
  };
206
226
  appWs.onerror = () => {
227
+ if (this.appServerGeneration !== generation)
228
+ return;
207
229
  this.log("App-server connection error");
208
230
  if (!isReconnect)
209
231
  reject(new Error("Failed to connect to app-server"));
210
232
  };
211
233
  appWs.onclose = () => {
212
- this.log("App-server connection closed");
213
- this.appServerWs = null;
214
- this.clearResponseTrackingState();
215
- this.activeTurnIds.clear();
216
- this.turnInProgress = false;
217
- if (!this.intentionalDisconnect) {
218
- this.scheduleReconnect();
219
- }
234
+ if (this.appServerGeneration !== generation)
235
+ return;
236
+ this.handleAppServerClose();
220
237
  };
221
238
  });
222
239
  }
240
+ async reconnectAppServerForNewSession(tuiWs) {
241
+ this.appServerGeneration++;
242
+ this.intentionalDisconnect = true;
243
+ if (this.reconnectTimer) {
244
+ clearTimeout(this.reconnectTimer);
245
+ this.reconnectTimer = null;
246
+ }
247
+ const oldWs = this.appServerWs;
248
+ this.appServerWs = null;
249
+ if (oldWs) {
250
+ try {
251
+ oldWs.close();
252
+ } catch {}
253
+ }
254
+ this.clearResponseTrackingStateForAppServerReconnect();
255
+ this.activeTurnIds.clear();
256
+ this.turnInProgress = false;
257
+ try {
258
+ await this.connectToAppServer(false);
259
+ this.log("App-server reconnected for new TUI session \u2014 replaying buffered messages");
260
+ const messages = this.pendingTuiMessages;
261
+ this.pendingTuiMessages = [];
262
+ this.reconnectingForNewSession = false;
263
+ this.replayingBufferedMessages = true;
264
+ try {
265
+ for (const msg of messages) {
266
+ this.onTuiMessage(tuiWs, msg);
267
+ }
268
+ } finally {
269
+ this.replayingBufferedMessages = false;
270
+ }
271
+ } catch (err) {
272
+ this.log(`Failed to reconnect app-server for new session: ${err.message}`);
273
+ this.pendingTuiMessages = [];
274
+ this.reconnectingForNewSession = false;
275
+ this.intentionalDisconnect = false;
276
+ this.scheduleReconnect();
277
+ }
278
+ }
223
279
  reconnectAttempts = 0;
224
280
  reconnectTimer = null;
225
281
  static MAX_RECONNECT_ATTEMPTS = 10;
@@ -245,6 +301,16 @@ class CodexAdapter extends EventEmitter {
245
301
  }
246
302
  }, delay);
247
303
  }
304
+ handleAppServerClose() {
305
+ this.log("App-server connection closed");
306
+ this.appServerWs = null;
307
+ this.clearResponseTrackingState();
308
+ this.activeTurnIds.clear();
309
+ this.turnInProgress = false;
310
+ if (!this.intentionalDisconnect) {
311
+ this.scheduleReconnect();
312
+ }
313
+ }
248
314
  startProxy() {
249
315
  const self = this;
250
316
  this.proxyServer = Bun.serve({
@@ -252,40 +318,109 @@ class CodexAdapter extends EventEmitter {
252
318
  hostname: "127.0.0.1",
253
319
  fetch(req, server) {
254
320
  const url = new URL(req.url);
321
+ const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
322
+ self.log(`HTTP ${req.method} ${url.pathname} (upgrade=${isUpgrade})`);
255
323
  if (url.pathname === "/healthz" || url.pathname === "/readyz") {
256
324
  return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
257
325
  }
258
326
  if (server.upgrade(req, { data: { connId: 0 } }))
259
327
  return;
328
+ self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
260
329
  return new Response("AgentBridge Codex Proxy");
261
330
  },
262
331
  websocket: {
263
332
  open: (ws) => self.onTuiConnect(ws),
264
- close: (ws) => self.onTuiDisconnect(ws),
333
+ close: (ws, code, reason) => {
334
+ self.log(`WebSocket close event: conn #${ws.data.connId}, code=${code}, reason=${reason || "none"}`);
335
+ self.onTuiDisconnect(ws);
336
+ },
265
337
  message: (ws, msg) => self.onTuiMessage(ws, msg)
266
338
  }
267
339
  });
268
340
  }
269
341
  onTuiConnect(ws) {
270
- this.tuiConnId++;
271
- ws.data.connId = this.tuiConnId;
342
+ const connId = ++this.connIdCounter;
343
+ ws.data.connId = connId;
344
+ if (this.tuiWs) {
345
+ this.log(`Secondary TUI connected (conn #${connId}, primary is #${this.tuiConnId})`);
346
+ this.setupSecondaryConnection(ws, connId);
347
+ return;
348
+ }
349
+ const previousConnId = this.tuiConnId > 0 ? this.tuiConnId : null;
350
+ this.tuiConnId = connId;
272
351
  this.tuiWs = ws;
352
+ this.threadId = null;
273
353
  this.log(`TUI connected (conn #${this.tuiConnId})`);
274
354
  this.emit("tuiConnected", this.tuiConnId);
355
+ if (previousConnId !== null) {
356
+ this.retireConnectionState(previousConnId);
357
+ }
358
+ }
359
+ setupSecondaryConnection(ws, connId) {
360
+ const appWs = new WebSocket(this.appServerUrl);
361
+ const entry = { tuiWs: ws, appServerWs: appWs, buffer: [] };
362
+ this.secondaryConnections.set(connId, entry);
363
+ appWs.onopen = () => {
364
+ if (!this.secondaryConnections.has(connId)) {
365
+ appWs.close();
366
+ return;
367
+ }
368
+ this.log(`Secondary conn #${connId}: app-server WS connected, flushing ${entry.buffer.length} buffered messages`);
369
+ for (const msg of entry.buffer) {
370
+ try {
371
+ appWs.send(msg);
372
+ } catch {}
373
+ }
374
+ entry.buffer = [];
375
+ };
376
+ appWs.onmessage = (event) => {
377
+ if (!this.secondaryConnections.has(connId))
378
+ return;
379
+ const data = typeof event.data === "string" ? event.data : event.data.toString();
380
+ try {
381
+ ws.send(data);
382
+ } catch {}
383
+ };
384
+ appWs.onerror = () => {
385
+ this.log(`Secondary conn #${connId}: app-server WS error`);
386
+ };
387
+ appWs.onclose = () => {
388
+ this.log(`Secondary conn #${connId}: app-server WS closed`);
389
+ const sec = this.secondaryConnections.get(connId);
390
+ if (sec) {
391
+ this.secondaryConnections.delete(connId);
392
+ try {
393
+ sec.tuiWs.close();
394
+ } catch {}
395
+ }
396
+ };
397
+ }
398
+ replayPendingForThread(resumedThreadId, ws) {
275
399
  const remaining = [];
276
400
  for (const buffered of this.pendingServerRequests) {
401
+ const belongsToThread = buffered.threadId === null || buffered.threadId === resumedThreadId;
402
+ if (!belongsToThread) {
403
+ remaining.push(buffered);
404
+ continue;
405
+ }
277
406
  const proxyId = this.nextProxyId++;
278
407
  try {
279
408
  const parsed = JSON.parse(buffered.raw);
280
409
  parsed.id = proxyId;
281
410
  ws.send(JSON.stringify(parsed));
282
411
  this.serverRequestToProxy.set(proxyId, {
412
+ raw: buffered.raw,
283
413
  serverId: buffered.serverId,
284
414
  connId: this.tuiConnId,
285
415
  method: buffered.method,
286
- timestamp: Date.now()
416
+ timestamp: Date.now(),
417
+ threadId: buffered.threadId
287
418
  });
288
- this.log(`Replayed buffered server request: ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId})`);
419
+ if (buffered.threadId === null) {
420
+ this.log(`WARNING: Replaying pending server request with unknown threadId (experimental fallback, may surface orphan UI on wrong thread): ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId})`);
421
+ } else {
422
+ this.log(`Replayed buffered server request on thread/resume: ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId}, threadId=${buffered.threadId})`);
423
+ }
289
424
  } catch (e) {
290
425
  this.log(`Failed to replay buffered server request: ${buffered.method} (server id=${buffered.serverId}): ${e.message}`);
291
426
  remaining.push(buffered);
@@ -293,11 +428,41 @@ class CodexAdapter extends EventEmitter {
293
428
  }
294
429
  this.pendingServerRequests = remaining;
295
430
  }
431
+ dropOrphanPendingRequests(reason, matchThreadId = null) {
432
+ if (this.pendingServerRequests.length === 0)
433
+ return;
434
+ const remaining = [];
435
+ for (const buffered of this.pendingServerRequests) {
436
+ const shouldDrop = matchThreadId === null ? true : buffered.threadId !== null && buffered.threadId !== matchThreadId;
437
+ if (shouldDrop) {
438
+ this.log(`Dropped orphan pending server request: ${buffered.method} (server id=${buffered.serverId}, threadId=${buffered.threadId ?? "unknown"}, reason=${reason})`);
439
+ continue;
440
+ }
441
+ remaining.push(buffered);
442
+ }
443
+ this.pendingServerRequests = remaining;
444
+ }
296
445
  onTuiDisconnect(ws) {
297
446
  const connId = ws.data.connId;
447
+ const secondary = this.secondaryConnections.get(connId);
448
+ if (secondary) {
449
+ this.log(`Secondary TUI disconnected (conn #${connId})`);
450
+ this.secondaryConnections.delete(connId);
451
+ if (secondary.appServerWs) {
452
+ try {
453
+ secondary.appServerWs.close();
454
+ } catch {}
455
+ }
456
+ return;
457
+ }
298
458
  if (this.tuiWs === ws) {
299
459
  this.log(`TUI disconnected (conn #${connId})`);
300
460
  this.tuiWs = null;
461
+ if (this.reconnectingForNewSession) {
462
+ this.log("Clearing pending TUI message buffer (TUI disconnected during app-server reconnect)");
463
+ this.pendingTuiMessages = [];
464
+ this.reconnectingForNewSession = false;
465
+ }
301
466
  this.emit("tuiDisconnected", connId);
302
467
  } else {
303
468
  this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
@@ -307,6 +472,17 @@ class CodexAdapter extends EventEmitter {
307
472
  onTuiMessage(ws, msg) {
308
473
  const data = typeof msg === "string" ? msg : msg.toString();
309
474
  const connId = ws.data.connId;
475
+ const secondary = this.secondaryConnections.get(connId);
476
+ if (secondary) {
477
+ if (secondary.appServerWs && secondary.appServerWs.readyState === WebSocket.OPEN) {
478
+ try {
479
+ secondary.appServerWs.send(data);
480
+ } catch {}
481
+ } else {
482
+ secondary.buffer.push(data);
483
+ }
484
+ return;
485
+ }
310
486
  if (connId !== this.tuiConnId) {
311
487
  this.log(`Dropping message from stale TUI conn #${connId} (current is #${this.tuiConnId})`);
312
488
  return;
@@ -315,29 +491,51 @@ class CodexAdapter extends EventEmitter {
315
491
  const parsed = JSON.parse(data);
316
492
  if (parsed.id !== undefined && !parsed.method) {
317
493
  const normalizedId = this.normalizeNumericId(parsed.id);
494
+ if (!isNaN(normalizedId) && this.pendingServerResponses.has(normalizedId)) {
495
+ this.log(`Ignoring duplicate approval response while app-server reconnect is pending (proxy id=${normalizedId})`);
496
+ return;
497
+ }
318
498
  const pending = !isNaN(normalizedId) ? this.serverRequestToProxy.get(normalizedId) : undefined;
319
499
  if (pending !== undefined) {
320
500
  if (pending.connId !== connId) {
321
501
  this.log(`Dropping stale server request response (proxy id=${normalizedId}, expected conn #${pending.connId}, got #${connId})`);
322
502
  return;
323
503
  }
504
+ parsed.id = pending.serverId;
505
+ const forwardedResponse = JSON.stringify(parsed);
324
506
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
325
- this.log(`Cannot forward approval response: app-server disconnected (proxy id=${normalizedId})`);
507
+ this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, "app-server disconnected");
326
508
  return;
327
509
  }
328
- parsed.id = pending.serverId;
329
510
  try {
330
- this.appServerWs.send(JSON.stringify(parsed));
511
+ this.appServerWs.send(forwardedResponse);
331
512
  this.serverRequestToProxy.delete(normalizedId);
332
513
  this.log(`TUI \u2192 app-server: ${pending.method} response (proxy id=${normalizedId} \u2192 server id=${pending.serverId})`);
333
514
  } catch (e) {
334
- parsed.id = normalizedId;
335
- this.log(`Failed to forward approval response (proxy id=${normalizedId}): ${e.message}`);
515
+ this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, `send failed: ${e.message}`);
336
516
  }
337
517
  return;
338
518
  }
339
519
  }
340
520
  } catch {}
521
+ let detectedMethod;
522
+ try {
523
+ const parsed = JSON.parse(data);
524
+ detectedMethod = typeof parsed.method === "string" ? parsed.method : undefined;
525
+ } catch {}
526
+ if (!this.replayingBufferedMessages) {
527
+ if (detectedMethod === "initialize") {
528
+ this.log("Detected initialize \u2014 reconnecting app-server for fresh session");
529
+ this.reconnectingForNewSession = true;
530
+ this.pendingTuiMessages = [data];
531
+ this.reconnectAppServerForNewSession(ws);
532
+ return;
533
+ }
534
+ if (this.reconnectingForNewSession) {
535
+ this.pendingTuiMessages.push(data);
536
+ return;
537
+ }
538
+ }
341
539
  let forwarded = data;
342
540
  try {
343
541
  const parsed = JSON.parse(data);
@@ -386,9 +584,10 @@ class CodexAdapter extends EventEmitter {
386
584
  handleServerRequest(parsed, raw) {
387
585
  const serverId = parsed.id;
388
586
  const method = parsed.method;
587
+ const threadId = this.extractThreadIdFromParams(parsed.params);
389
588
  if (!this.tuiWs) {
390
- this.pendingServerRequests.push({ raw, serverId, method });
391
- this.log(`Server request buffered (no TUI): ${method} (server id=${serverId})`);
589
+ this.pendingServerRequests.push({ raw, serverId, method, threadId });
590
+ this.log(`Server request buffered (no TUI): ${method} (server id=${serverId}, threadId=${threadId ?? "unknown"})`);
392
591
  return;
393
592
  }
394
593
  const proxyId = this.nextProxyId++;
@@ -397,11 +596,24 @@ class CodexAdapter extends EventEmitter {
397
596
  this.tuiWs.send(JSON.stringify(parsed));
398
597
  } catch (e) {
399
598
  this.log(`Server request send failed, buffering: ${method} (server id=${serverId}): ${e.message}`);
400
- this.pendingServerRequests.push({ raw, serverId, method });
599
+ this.pendingServerRequests.push({ raw, serverId, method, threadId });
401
600
  return;
402
601
  }
403
- this.serverRequestToProxy.set(proxyId, { serverId, connId: this.tuiConnId, method, timestamp: Date.now() });
404
- this.log(`Server request: ${method} (server id=${serverId} \u2192 proxy id=${proxyId}, conn #${this.tuiConnId})`);
602
+ this.serverRequestToProxy.set(proxyId, {
603
+ raw,
604
+ serverId,
605
+ connId: this.tuiConnId,
606
+ method,
607
+ timestamp: Date.now(),
608
+ threadId
609
+ });
610
+ this.log(`Server request: ${method} (server id=${serverId} \u2192 proxy id=${proxyId}, conn #${this.tuiConnId}, threadId=${threadId ?? "unknown"})`);
611
+ }
612
+ extractThreadIdFromParams(params) {
613
+ if (typeof params !== "object" || params === null)
614
+ return null;
615
+ const tid = params.threadId;
616
+ return typeof tid === "string" && tid.length > 0 ? tid : null;
405
617
  }
406
618
  normalizeNumericId(id) {
407
619
  if (typeof id === "number")
@@ -410,6 +622,30 @@ class CodexAdapter extends EventEmitter {
410
622
  return Number(id);
411
623
  return NaN;
412
624
  }
625
+ bufferPendingServerResponse(proxyId, pending, forwardedResponse, reason) {
626
+ this.pendingServerResponses.set(proxyId, {
627
+ raw: forwardedResponse,
628
+ serverId: pending.serverId,
629
+ method: pending.method,
630
+ timestamp: Date.now()
631
+ });
632
+ this.serverRequestToProxy.delete(proxyId);
633
+ this.log(`Buffered approval response until app-server reconnect (${reason}) (proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
634
+ }
635
+ flushPendingServerResponses() {
636
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
637
+ return;
638
+ for (const [proxyId, pending] of this.pendingServerResponses.entries()) {
639
+ try {
640
+ this.appServerWs.send(pending.raw);
641
+ this.pendingServerResponses.delete(proxyId);
642
+ this.log(`Flushed buffered approval response after app-server reconnect (proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
643
+ } catch (e) {
644
+ this.log(`Failed to flush buffered approval response (proxy id=${proxyId}): ${e.message}`);
645
+ break;
646
+ }
647
+ }
648
+ }
413
649
  handleAppServerResponse(parsed, raw) {
414
650
  const responseId = parsed.id;
415
651
  const numericId = this.normalizeNumericId(responseId);
@@ -421,6 +657,7 @@ class CodexAdapter extends EventEmitter {
421
657
  return null;
422
658
  }
423
659
  parsed.id = mapping.clientId;
660
+ this.log(`app-server \u2192 TUI: response (proxy id=${numericId} \u2192 client id=${String(mapping.clientId)}, conn #${mapping.connId})`);
424
661
  const forwarded = this.patchResponse(parsed, JSON.stringify(parsed));
425
662
  this.interceptServerMessage(parsed, mapping.connId);
426
663
  return forwarded;
@@ -460,17 +697,6 @@ class CodexAdapter extends EventEmitter {
460
697
  }
461
698
  });
462
699
  }
463
- if (errMsg.includes("Already initialized")) {
464
- this.log(`Patching "Already initialized" error (id: ${parsed.id})`);
465
- return JSON.stringify({
466
- id: parsed.id,
467
- result: {
468
- userAgent: "agent_bridge/0.1.0",
469
- platformFamily: "unix",
470
- platformOs: "macos"
471
- }
472
- });
473
- }
474
700
  }
475
701
  return raw;
476
702
  }
@@ -544,7 +770,6 @@ class CodexAdapter extends EventEmitter {
544
770
  const rpcId = "id" in message ? message.id : undefined;
545
771
  const method = "method" in message && typeof message.method === "string" ? message.method : undefined;
546
772
  const key = this.pendingKey(rpcId, connId);
547
- this.log(`[track] method=${method} id=${rpcId} (type=${typeof rpcId}) key=${key}`);
548
773
  if (!key || !isTrackedAppServerRequestMethod(method))
549
774
  return;
550
775
  const pending = { method };
@@ -582,12 +807,17 @@ class CodexAdapter extends EventEmitter {
582
807
  if (typeof threadId === "string" && threadId.length > 0) {
583
808
  this.setActiveThreadId(threadId, `thread/start response ${key}`);
584
809
  }
810
+ this.dropOrphanPendingRequests(`thread/start (new session)`);
585
811
  break;
586
812
  }
587
813
  case "thread/resume": {
588
814
  const threadId = message?.result?.thread?.id;
589
815
  if (typeof threadId === "string" && threadId.length > 0) {
590
816
  this.setActiveThreadId(threadId, `thread/resume response ${key}`);
817
+ if (this.tuiWs) {
818
+ this.replayPendingForThread(threadId, this.tuiWs);
819
+ }
820
+ this.dropOrphanPendingRequests(`thread/resume to ${threadId}`, threadId);
591
821
  }
592
822
  break;
593
823
  }
@@ -647,20 +877,22 @@ class CodexAdapter extends EventEmitter {
647
877
  this.upstreamToClient.delete(upId);
648
878
  this.trackStaleProxyId(upId);
649
879
  }
880
+ const requeuedServerRequests = [];
650
881
  for (const [proxyId, pending] of this.serverRequestToProxy.entries()) {
651
882
  if (pending.connId === connId) {
652
- this.clearTrackedId(this.serverRequestTtlTimers, proxyId);
653
- const timer = setTimeout(() => {
654
- this.serverRequestTtlTimers.delete(proxyId);
655
- if (this.serverRequestToProxy.get(proxyId)?.connId === connId) {
656
- this.serverRequestToProxy.delete(proxyId);
657
- this.log(`Expired stale server request mapping (proxy id=${proxyId}, method=${pending.method})`);
658
- }
659
- }, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
660
- timer.unref?.();
661
- this.serverRequestTtlTimers.set(proxyId, timer);
883
+ this.serverRequestToProxy.delete(proxyId);
884
+ requeuedServerRequests.push({
885
+ raw: pending.raw,
886
+ serverId: pending.serverId,
887
+ method: pending.method,
888
+ threadId: pending.threadId
889
+ });
890
+ this.log(`Requeued in-flight server request after TUI disconnect (proxy id=${proxyId}, server id=${pending.serverId}, method=${pending.method}, threadId=${pending.threadId ?? "unknown"})`);
662
891
  }
663
892
  }
893
+ if (requeuedServerRequests.length === 0)
894
+ return;
895
+ this.pendingServerRequests.push(...requeuedServerRequests);
664
896
  }
665
897
  trackStaleProxyId(proxyId) {
666
898
  this.clearTrackedId(this.staleProxyIds, proxyId);
@@ -695,7 +927,7 @@ class CodexAdapter extends EventEmitter {
695
927
  store.delete(id);
696
928
  return true;
697
929
  }
698
- clearResponseTrackingState() {
930
+ clearTransientResponseTrackingState() {
699
931
  this.pendingRequests.clear();
700
932
  this.upstreamToClient.clear();
701
933
  for (const timer of this.staleProxyIds.values()) {
@@ -706,12 +938,26 @@ class CodexAdapter extends EventEmitter {
706
938
  clearTimeout(timer);
707
939
  }
708
940
  this.bridgeRequestIds.clear();
709
- for (const timer of this.serverRequestTtlTimers.values()) {
710
- clearTimeout(timer);
711
- }
712
- this.serverRequestTtlTimers.clear();
941
+ }
942
+ clearResponseTrackingState() {
943
+ this.clearTransientResponseTrackingState();
713
944
  this.serverRequestToProxy.clear();
714
945
  this.pendingServerRequests = [];
946
+ this.pendingServerResponses.clear();
947
+ }
948
+ clearResponseTrackingStateForAppServerReconnect() {
949
+ this.clearTransientResponseTrackingState();
950
+ for (const pending of this.serverRequestToProxy.values()) {
951
+ this.pendingServerRequests.push({
952
+ raw: pending.raw,
953
+ serverId: pending.serverId,
954
+ method: pending.method,
955
+ threadId: pending.threadId
956
+ });
957
+ this.log(`Requeued in-flight server request on app-server reconnect (server id=${pending.serverId}, method=${pending.method}, threadId=${pending.threadId ?? "unknown"})`);
958
+ }
959
+ this.serverRequestToProxy.clear();
960
+ this.pendingServerResponses.clear();
715
961
  }
716
962
  async checkPorts() {
717
963
  for (const port of [this.appPort, this.proxyPort]) {
@@ -1399,6 +1645,9 @@ class ConfigService {
1399
1645
  }
1400
1646
  }
1401
1647
 
1648
+ // src/control-protocol.ts
1649
+ var CLOSE_CODE_REPLACED = 4001;
1650
+
1402
1651
  // src/daemon.ts
1403
1652
  var stateDir = new StateDirResolver;
1404
1653
  stateDir.ensure();
@@ -1640,8 +1889,10 @@ function handleControlMessage(ws, raw) {
1640
1889
  }
1641
1890
  }
1642
1891
  function attachClaude(ws) {
1643
- if (attachedClaude && attachedClaude !== ws) {
1644
- attachedClaude.close(4001, "replaced by a newer Claude session");
1892
+ if (attachedClaude && attachedClaude !== ws && attachedClaude.readyState !== WebSocket.CLOSED) {
1893
+ log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 another session (#${attachedClaude.data.clientId}) is already attached (readyState=${attachedClaude.readyState})`);
1894
+ ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
1895
+ return;
1645
1896
  }
1646
1897
  clearPendingClaudeDisconnect("Claude frontend attached");
1647
1898
  attachedClaude = ws;