@raysonmeng/agentbridge 0.1.3 → 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.3",
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.3",
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.3",
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.3",
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
  }
@@ -9,8 +9,52 @@ import { spawn, execSync } from "child_process";
9
9
  import { createInterface } from "readline";
10
10
  import { EventEmitter } from "events";
11
11
  import { appendFileSync } from "fs";
12
+
13
+ // src/app-server-protocol.ts
14
+ var APP_SERVER_TRACKED_REQUEST_METHODS = [
15
+ "thread/start",
16
+ "thread/resume",
17
+ "turn/start"
18
+ ];
19
+ var APP_SERVER_SERVER_REQUEST_METHODS = [
20
+ "item/permissions/requestApproval",
21
+ "item/fileChange/requestApproval",
22
+ "item/commandExecution/requestApproval"
23
+ ];
24
+ var APP_SERVER_NOTIFICATION_METHODS = [
25
+ "turn/started",
26
+ "turn/completed",
27
+ "item/started",
28
+ "item/agentMessage/delta",
29
+ "item/completed"
30
+ ];
31
+ var TRACKED_REQUEST_METHOD_SET = new Set(APP_SERVER_TRACKED_REQUEST_METHODS);
32
+ var SERVER_REQUEST_METHOD_SET = new Set(APP_SERVER_SERVER_REQUEST_METHODS);
33
+ var NOTIFICATION_METHOD_SET = new Set(APP_SERVER_NOTIFICATION_METHODS);
34
+ function isObjectRecord(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function isTrackedAppServerRequestMethod(method) {
38
+ return typeof method === "string" && TRACKED_REQUEST_METHOD_SET.has(method);
39
+ }
40
+ function isAppServerRequestMessage(value) {
41
+ if (!isObjectRecord(value))
42
+ return false;
43
+ return (typeof value.id === "number" || typeof value.id === "string") && typeof value.method === "string";
44
+ }
45
+ function isAppServerNotification(value) {
46
+ if (!isObjectRecord(value))
47
+ return false;
48
+ return value.id === undefined && typeof value.method === "string" && NOTIFICATION_METHOD_SET.has(value.method);
49
+ }
50
+ function isAppServerResponseMessage(value) {
51
+ if (!isObjectRecord(value))
52
+ return false;
53
+ return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
54
+ }
55
+
56
+ // src/codex-adapter.ts
12
57
  var LOG_FILE = "/tmp/agentbridge.log";
13
- var TRACKED_REQUEST_METHODS = new Set(["thread/start", "thread/resume", "turn/start"]);
14
58
 
15
59
  class CodexAdapter extends EventEmitter {
16
60
  static RESPONSE_TRACKING_TTL_MS = 30000;
@@ -23,6 +67,8 @@ class CodexAdapter extends EventEmitter {
23
67
  appPort;
24
68
  proxyPort;
25
69
  tuiConnId = 0;
70
+ connIdCounter = 0;
71
+ secondaryConnections = new Map;
26
72
  agentMessageBuffers = new Map;
27
73
  pendingRequests = new Map;
28
74
  activeTurnIds = new Set;
@@ -30,11 +76,15 @@ class CodexAdapter extends EventEmitter {
30
76
  nextProxyId = 1e5;
31
77
  upstreamToClient = new Map;
32
78
  serverRequestToProxy = new Map;
33
- serverRequestTtlTimers = new Map;
34
79
  pendingServerRequests = [];
80
+ pendingServerResponses = new Map;
35
81
  staleProxyIds = new Map;
36
82
  bridgeRequestIds = new Map;
37
83
  intentionalDisconnect = false;
84
+ pendingTuiMessages = [];
85
+ reconnectingForNewSession = false;
86
+ replayingBufferedMessages = false;
87
+ appServerGeneration = 0;
38
88
  constructor(appPort = 4500, proxyPort = 4501) {
39
89
  super();
40
90
  this.appPort = appPort;
@@ -75,6 +125,12 @@ class CodexAdapter extends EventEmitter {
75
125
  }
76
126
  this.appServerWs?.close();
77
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
+ }
78
134
  this.proxyServer?.stop();
79
135
  this.proxyServer = null;
80
136
  this.clearResponseTrackingState();
@@ -135,16 +191,24 @@ class CodexAdapter extends EventEmitter {
135
191
  throw new Error("Codex app-server failed to become healthy");
136
192
  }
137
193
  connectToAppServer(isReconnect = false) {
194
+ const generation = ++this.appServerGeneration;
138
195
  return new Promise((resolve, reject) => {
139
196
  const appWs = new WebSocket(this.appServerUrl);
140
197
  appWs.onopen = () => {
198
+ if (this.appServerGeneration !== generation) {
199
+ appWs.close();
200
+ return;
201
+ }
141
202
  this.appServerWs = appWs;
142
203
  this.intentionalDisconnect = false;
143
204
  this.reconnectAttempts = 0;
144
- 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();
145
207
  resolve();
146
208
  };
147
209
  appWs.onmessage = (event) => {
210
+ if (this.appServerGeneration !== generation)
211
+ return;
148
212
  const data = typeof event.data === "string" ? event.data : event.data.toString();
149
213
  const forwarded = this.handleAppServerPayload(data);
150
214
  if (forwarded === null)
@@ -160,22 +224,58 @@ class CodexAdapter extends EventEmitter {
160
224
  }
161
225
  };
162
226
  appWs.onerror = () => {
227
+ if (this.appServerGeneration !== generation)
228
+ return;
163
229
  this.log("App-server connection error");
164
230
  if (!isReconnect)
165
231
  reject(new Error("Failed to connect to app-server"));
166
232
  };
167
233
  appWs.onclose = () => {
168
- this.log("App-server connection closed");
169
- this.appServerWs = null;
170
- this.clearResponseTrackingState();
171
- this.activeTurnIds.clear();
172
- this.turnInProgress = false;
173
- if (!this.intentionalDisconnect) {
174
- this.scheduleReconnect();
175
- }
234
+ if (this.appServerGeneration !== generation)
235
+ return;
236
+ this.handleAppServerClose();
176
237
  };
177
238
  });
178
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
+ }
179
279
  reconnectAttempts = 0;
180
280
  reconnectTimer = null;
181
281
  static MAX_RECONNECT_ATTEMPTS = 10;
@@ -201,6 +301,16 @@ class CodexAdapter extends EventEmitter {
201
301
  }
202
302
  }, delay);
203
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
+ }
204
314
  startProxy() {
205
315
  const self = this;
206
316
  this.proxyServer = Bun.serve({
@@ -208,40 +318,109 @@ class CodexAdapter extends EventEmitter {
208
318
  hostname: "127.0.0.1",
209
319
  fetch(req, server) {
210
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})`);
211
323
  if (url.pathname === "/healthz" || url.pathname === "/readyz") {
212
324
  return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
213
325
  }
214
326
  if (server.upgrade(req, { data: { connId: 0 } }))
215
327
  return;
328
+ self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
216
329
  return new Response("AgentBridge Codex Proxy");
217
330
  },
218
331
  websocket: {
219
332
  open: (ws) => self.onTuiConnect(ws),
220
- 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
+ },
221
337
  message: (ws, msg) => self.onTuiMessage(ws, msg)
222
338
  }
223
339
  });
224
340
  }
225
341
  onTuiConnect(ws) {
226
- this.tuiConnId++;
227
- 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;
228
351
  this.tuiWs = ws;
352
+ this.threadId = null;
229
353
  this.log(`TUI connected (conn #${this.tuiConnId})`);
230
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) {
231
399
  const remaining = [];
232
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
+ }
233
406
  const proxyId = this.nextProxyId++;
234
407
  try {
235
408
  const parsed = JSON.parse(buffered.raw);
236
409
  parsed.id = proxyId;
237
410
  ws.send(JSON.stringify(parsed));
238
411
  this.serverRequestToProxy.set(proxyId, {
412
+ raw: buffered.raw,
239
413
  serverId: buffered.serverId,
240
414
  connId: this.tuiConnId,
241
415
  method: buffered.method,
242
- timestamp: Date.now()
416
+ timestamp: Date.now(),
417
+ threadId: buffered.threadId
243
418
  });
244
- 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
+ }
245
424
  } catch (e) {
246
425
  this.log(`Failed to replay buffered server request: ${buffered.method} (server id=${buffered.serverId}): ${e.message}`);
247
426
  remaining.push(buffered);
@@ -249,11 +428,41 @@ class CodexAdapter extends EventEmitter {
249
428
  }
250
429
  this.pendingServerRequests = remaining;
251
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
+ }
252
445
  onTuiDisconnect(ws) {
253
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
+ }
254
458
  if (this.tuiWs === ws) {
255
459
  this.log(`TUI disconnected (conn #${connId})`);
256
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
+ }
257
466
  this.emit("tuiDisconnected", connId);
258
467
  } else {
259
468
  this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
@@ -263,6 +472,17 @@ class CodexAdapter extends EventEmitter {
263
472
  onTuiMessage(ws, msg) {
264
473
  const data = typeof msg === "string" ? msg : msg.toString();
265
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
+ }
266
486
  if (connId !== this.tuiConnId) {
267
487
  this.log(`Dropping message from stale TUI conn #${connId} (current is #${this.tuiConnId})`);
268
488
  return;
@@ -271,29 +491,51 @@ class CodexAdapter extends EventEmitter {
271
491
  const parsed = JSON.parse(data);
272
492
  if (parsed.id !== undefined && !parsed.method) {
273
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
+ }
274
498
  const pending = !isNaN(normalizedId) ? this.serverRequestToProxy.get(normalizedId) : undefined;
275
499
  if (pending !== undefined) {
276
500
  if (pending.connId !== connId) {
277
501
  this.log(`Dropping stale server request response (proxy id=${normalizedId}, expected conn #${pending.connId}, got #${connId})`);
278
502
  return;
279
503
  }
504
+ parsed.id = pending.serverId;
505
+ const forwardedResponse = JSON.stringify(parsed);
280
506
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
281
- this.log(`Cannot forward approval response: app-server disconnected (proxy id=${normalizedId})`);
507
+ this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, "app-server disconnected");
282
508
  return;
283
509
  }
284
- parsed.id = pending.serverId;
285
510
  try {
286
- this.appServerWs.send(JSON.stringify(parsed));
511
+ this.appServerWs.send(forwardedResponse);
287
512
  this.serverRequestToProxy.delete(normalizedId);
288
513
  this.log(`TUI \u2192 app-server: ${pending.method} response (proxy id=${normalizedId} \u2192 server id=${pending.serverId})`);
289
514
  } catch (e) {
290
- parsed.id = normalizedId;
291
- this.log(`Failed to forward approval response (proxy id=${normalizedId}): ${e.message}`);
515
+ this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, `send failed: ${e.message}`);
292
516
  }
293
517
  return;
294
518
  }
295
519
  }
296
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
+ }
297
539
  let forwarded = data;
298
540
  try {
299
541
  const parsed = JSON.parse(data);
@@ -320,16 +562,21 @@ class CodexAdapter extends EventEmitter {
320
562
  handleAppServerPayload(raw) {
321
563
  try {
322
564
  const parsed = JSON.parse(raw);
323
- if (parsed.id === undefined) {
324
- const forwarded = this.patchResponse(parsed, raw);
325
- this.interceptServerMessage(parsed);
565
+ if (isAppServerNotification(parsed) || typeof parsed === "object" && parsed !== null && !("id" in parsed)) {
566
+ const notificationLike = parsed;
567
+ const forwarded = this.patchResponse(notificationLike, raw);
568
+ this.interceptServerMessage(notificationLike);
326
569
  return forwarded;
327
570
  }
328
- if (parsed.method !== undefined) {
571
+ if (isAppServerRequestMessage(parsed)) {
329
572
  this.handleServerRequest(parsed, raw);
330
573
  return null;
331
574
  }
332
- return this.handleAppServerResponse(parsed, raw);
575
+ if (isAppServerResponseMessage(parsed)) {
576
+ return this.handleAppServerResponse(parsed, raw);
577
+ }
578
+ this.log(`Dropping unclassifiable app-server message: ${raw.slice(0, 100)}`);
579
+ return null;
333
580
  } catch {
334
581
  return raw;
335
582
  }
@@ -337,9 +584,10 @@ class CodexAdapter extends EventEmitter {
337
584
  handleServerRequest(parsed, raw) {
338
585
  const serverId = parsed.id;
339
586
  const method = parsed.method;
587
+ const threadId = this.extractThreadIdFromParams(parsed.params);
340
588
  if (!this.tuiWs) {
341
- this.pendingServerRequests.push({ raw, serverId, method });
342
- 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"})`);
343
591
  return;
344
592
  }
345
593
  const proxyId = this.nextProxyId++;
@@ -348,11 +596,24 @@ class CodexAdapter extends EventEmitter {
348
596
  this.tuiWs.send(JSON.stringify(parsed));
349
597
  } catch (e) {
350
598
  this.log(`Server request send failed, buffering: ${method} (server id=${serverId}): ${e.message}`);
351
- this.pendingServerRequests.push({ raw, serverId, method });
599
+ this.pendingServerRequests.push({ raw, serverId, method, threadId });
352
600
  return;
353
601
  }
354
- this.serverRequestToProxy.set(proxyId, { serverId, connId: this.tuiConnId, method, timestamp: Date.now() });
355
- 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;
356
617
  }
357
618
  normalizeNumericId(id) {
358
619
  if (typeof id === "number")
@@ -361,6 +622,30 @@ class CodexAdapter extends EventEmitter {
361
622
  return Number(id);
362
623
  return NaN;
363
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
+ }
364
649
  handleAppServerResponse(parsed, raw) {
365
650
  const responseId = parsed.id;
366
651
  const numericId = this.normalizeNumericId(responseId);
@@ -372,6 +657,7 @@ class CodexAdapter extends EventEmitter {
372
657
  return null;
373
658
  }
374
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})`);
375
661
  const forwarded = this.patchResponse(parsed, JSON.stringify(parsed));
376
662
  this.interceptServerMessage(parsed, mapping.connId);
377
663
  return forwarded;
@@ -392,7 +678,7 @@ class CodexAdapter extends EventEmitter {
392
678
  return null;
393
679
  }
394
680
  patchResponse(parsed, raw) {
395
- if (parsed.error && parsed.id !== undefined) {
681
+ if (isAppServerResponseMessage(parsed) && parsed.error && parsed.id !== undefined) {
396
682
  const errMsg = parsed.error.message ?? "";
397
683
  if (errMsg.includes("rate limits") || errMsg.includes("rateLimits")) {
398
684
  this.log(`Patching rateLimits error \u2192 mock success (id: ${parsed.id})`);
@@ -411,24 +697,14 @@ class CodexAdapter extends EventEmitter {
411
697
  }
412
698
  });
413
699
  }
414
- if (errMsg.includes("Already initialized")) {
415
- this.log(`Patching "Already initialized" error (id: ${parsed.id})`);
416
- return JSON.stringify({
417
- id: parsed.id,
418
- result: {
419
- userAgent: "agent_bridge/0.1.0",
420
- platformFamily: "unix",
421
- platformOs: "macos"
422
- }
423
- });
424
- }
425
700
  }
426
701
  return raw;
427
702
  }
428
703
  interceptServerMessage(msg, connId) {
429
704
  this.handleTrackedResponse(msg, connId);
430
- if (msg.method)
705
+ if ("method" in msg && typeof msg.method === "string" && isAppServerNotification(msg)) {
431
706
  this.handleServerNotification(msg);
707
+ }
432
708
  }
433
709
  handleServerNotification(msg) {
434
710
  const { method, params } = msg;
@@ -443,7 +719,10 @@ class CodexAdapter extends EventEmitter {
443
719
  break;
444
720
  }
445
721
  case "item/agentMessage/delta": {
446
- const buf = this.agentMessageBuffers.get(params?.itemId);
722
+ const itemId = params?.itemId;
723
+ if (typeof itemId !== "string")
724
+ break;
725
+ const buf = this.agentMessageBuffers.get(itemId);
447
726
  if (buf && params?.delta)
448
727
  buf.push(params.delta);
449
728
  break;
@@ -488,14 +767,15 @@ class CodexAdapter extends EventEmitter {
488
767
  return `${connId ?? this.tuiConnId}:${base}`;
489
768
  }
490
769
  trackPendingRequest(message, connId, _proxyId) {
491
- const method = message?.method;
492
- const key = this.pendingKey(message?.id, connId);
493
- this.log(`[track] method=${method} id=${message?.id} (type=${typeof message?.id}) key=${key}`);
494
- if (!key || !TRACKED_REQUEST_METHODS.has(method))
770
+ const rpcId = "id" in message ? message.id : undefined;
771
+ const method = "method" in message && typeof message.method === "string" ? message.method : undefined;
772
+ const key = this.pendingKey(rpcId, connId);
773
+ if (!key || !isTrackedAppServerRequestMethod(method))
495
774
  return;
496
775
  const pending = { method };
497
776
  if (method === "turn/start") {
498
- const threadId = message?.params?.threadId;
777
+ const params = "params" in message && typeof message.params === "object" && message.params !== null ? message.params : undefined;
778
+ const threadId = params?.threadId;
499
779
  if (typeof threadId === "string" && threadId.length > 0) {
500
780
  pending.threadId = threadId;
501
781
  }
@@ -527,12 +807,17 @@ class CodexAdapter extends EventEmitter {
527
807
  if (typeof threadId === "string" && threadId.length > 0) {
528
808
  this.setActiveThreadId(threadId, `thread/start response ${key}`);
529
809
  }
810
+ this.dropOrphanPendingRequests(`thread/start (new session)`);
530
811
  break;
531
812
  }
532
813
  case "thread/resume": {
533
814
  const threadId = message?.result?.thread?.id;
534
815
  if (typeof threadId === "string" && threadId.length > 0) {
535
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);
536
821
  }
537
822
  break;
538
823
  }
@@ -592,20 +877,22 @@ class CodexAdapter extends EventEmitter {
592
877
  this.upstreamToClient.delete(upId);
593
878
  this.trackStaleProxyId(upId);
594
879
  }
880
+ const requeuedServerRequests = [];
595
881
  for (const [proxyId, pending] of this.serverRequestToProxy.entries()) {
596
882
  if (pending.connId === connId) {
597
- this.clearTrackedId(this.serverRequestTtlTimers, proxyId);
598
- const timer = setTimeout(() => {
599
- this.serverRequestTtlTimers.delete(proxyId);
600
- if (this.serverRequestToProxy.get(proxyId)?.connId === connId) {
601
- this.serverRequestToProxy.delete(proxyId);
602
- this.log(`Expired stale server request mapping (proxy id=${proxyId}, method=${pending.method})`);
603
- }
604
- }, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
605
- timer.unref?.();
606
- 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"})`);
607
891
  }
608
892
  }
893
+ if (requeuedServerRequests.length === 0)
894
+ return;
895
+ this.pendingServerRequests.push(...requeuedServerRequests);
609
896
  }
610
897
  trackStaleProxyId(proxyId) {
611
898
  this.clearTrackedId(this.staleProxyIds, proxyId);
@@ -640,7 +927,7 @@ class CodexAdapter extends EventEmitter {
640
927
  store.delete(id);
641
928
  return true;
642
929
  }
643
- clearResponseTrackingState() {
930
+ clearTransientResponseTrackingState() {
644
931
  this.pendingRequests.clear();
645
932
  this.upstreamToClient.clear();
646
933
  for (const timer of this.staleProxyIds.values()) {
@@ -651,12 +938,26 @@ class CodexAdapter extends EventEmitter {
651
938
  clearTimeout(timer);
652
939
  }
653
940
  this.bridgeRequestIds.clear();
654
- for (const timer of this.serverRequestTtlTimers.values()) {
655
- clearTimeout(timer);
656
- }
657
- this.serverRequestTtlTimers.clear();
941
+ }
942
+ clearResponseTrackingState() {
943
+ this.clearTransientResponseTrackingState();
658
944
  this.serverRequestToProxy.clear();
659
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();
660
961
  }
661
962
  async checkPorts() {
662
963
  for (const port of [this.appPort, this.proxyPort]) {
@@ -1344,6 +1645,9 @@ class ConfigService {
1344
1645
  }
1345
1646
  }
1346
1647
 
1648
+ // src/control-protocol.ts
1649
+ var CLOSE_CODE_REPLACED = 4001;
1650
+
1347
1651
  // src/daemon.ts
1348
1652
  var stateDir = new StateDirResolver;
1349
1653
  stateDir.ensure();
@@ -1585,8 +1889,10 @@ function handleControlMessage(ws, raw) {
1585
1889
  }
1586
1890
  }
1587
1891
  function attachClaude(ws) {
1588
- if (attachedClaude && attachedClaude !== ws) {
1589
- 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;
1590
1896
  }
1591
1897
  clearPendingClaudeDisconnect("Claude frontend attached");
1592
1898
  attachedClaude = ws;