@raysonmeng/agentbridge 0.1.4 → 0.1.6

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.
@@ -10,6 +10,58 @@ import { createInterface } from "readline";
10
10
  import { EventEmitter } from "events";
11
11
  import { appendFileSync } from "fs";
12
12
 
13
+ // src/state-dir.ts
14
+ import { mkdirSync, existsSync } from "fs";
15
+ import { join } from "path";
16
+ import { homedir, platform } from "os";
17
+
18
+ class StateDirResolver {
19
+ stateDir;
20
+ constructor(envOverride) {
21
+ const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
22
+ if (override) {
23
+ this.stateDir = override;
24
+ } else if (platform() === "darwin") {
25
+ this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
26
+ } else {
27
+ const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
28
+ this.stateDir = join(xdgState, "agentbridge");
29
+ }
30
+ }
31
+ ensure() {
32
+ if (!existsSync(this.stateDir)) {
33
+ mkdirSync(this.stateDir, { recursive: true });
34
+ }
35
+ }
36
+ get dir() {
37
+ return this.stateDir;
38
+ }
39
+ get pidFile() {
40
+ return join(this.stateDir, "daemon.pid");
41
+ }
42
+ get tuiPidFile() {
43
+ return join(this.stateDir, "codex-tui.pid");
44
+ }
45
+ get lockFile() {
46
+ return join(this.stateDir, "daemon.lock");
47
+ }
48
+ get statusFile() {
49
+ return join(this.stateDir, "status.json");
50
+ }
51
+ get portsFile() {
52
+ return join(this.stateDir, "ports.json");
53
+ }
54
+ get logFile() {
55
+ return join(this.stateDir, "agentbridge.log");
56
+ }
57
+ get codexWrapperLogFile() {
58
+ return join(this.stateDir, "codex-wrapper.log");
59
+ }
60
+ get killedFile() {
61
+ return join(this.stateDir, "killed");
62
+ }
63
+ }
64
+
13
65
  // src/app-server-protocol.ts
14
66
  var APP_SERVER_TRACKED_REQUEST_METHODS = [
15
67
  "thread/start",
@@ -54,8 +106,6 @@ function isAppServerResponseMessage(value) {
54
106
  }
55
107
 
56
108
  // src/codex-adapter.ts
57
- var LOG_FILE = "/tmp/agentbridge.log";
58
-
59
109
  class CodexAdapter extends EventEmitter {
60
110
  static RESPONSE_TRACKING_TTL_MS = 30000;
61
111
  proc = null;
@@ -66,7 +116,10 @@ class CodexAdapter extends EventEmitter {
66
116
  nextInjectionId = -1;
67
117
  appPort;
68
118
  proxyPort;
119
+ logFile;
69
120
  tuiConnId = 0;
121
+ connIdCounter = 0;
122
+ secondaryConnections = new Map;
70
123
  agentMessageBuffers = new Map;
71
124
  pendingRequests = new Map;
72
125
  activeTurnIds = new Set;
@@ -74,15 +127,29 @@ class CodexAdapter extends EventEmitter {
74
127
  nextProxyId = 1e5;
75
128
  upstreamToClient = new Map;
76
129
  serverRequestToProxy = new Map;
77
- serverRequestTtlTimers = new Map;
78
130
  pendingServerRequests = [];
131
+ pendingServerResponses = new Map;
79
132
  staleProxyIds = new Map;
80
133
  bridgeRequestIds = new Map;
81
134
  intentionalDisconnect = false;
82
- constructor(appPort = 4500, proxyPort = 4501) {
135
+ pendingTuiMessages = [];
136
+ reconnectingForNewSession = false;
137
+ replayingBufferedMessages = false;
138
+ appServerGeneration = 0;
139
+ outageQueue = [];
140
+ outageTimer = null;
141
+ static OUTAGE_QUEUE_MAX = 64;
142
+ static OUTAGE_TIMEOUT_MS = 5000;
143
+ lastInitializeRaw = null;
144
+ lastInitializedRaw = null;
145
+ sessionRestoreInProgress = false;
146
+ replayPending = new Map;
147
+ static SESSION_REPLAY_TIMEOUT_MS = 5000;
148
+ constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) {
83
149
  super();
84
150
  this.appPort = appPort;
85
151
  this.proxyPort = proxyPort;
152
+ this.logFile = logFile;
86
153
  }
87
154
  get appServerUrl() {
88
155
  return `ws://127.0.0.1:${this.appPort}`;
@@ -117,8 +184,16 @@ class CodexAdapter extends EventEmitter {
117
184
  clearTimeout(this.reconnectTimer);
118
185
  this.reconnectTimer = null;
119
186
  }
187
+ this.outageQueue = [];
188
+ this.clearOutageTimer();
120
189
  this.appServerWs?.close();
121
190
  this.appServerWs = null;
191
+ for (const [id, sec] of this.secondaryConnections) {
192
+ try {
193
+ sec.appServerWs?.close();
194
+ } catch {}
195
+ this.secondaryConnections.delete(id);
196
+ }
122
197
  this.proxyServer?.stop();
123
198
  this.proxyServer = null;
124
199
  this.clearResponseTrackingState();
@@ -179,16 +254,32 @@ class CodexAdapter extends EventEmitter {
179
254
  throw new Error("Codex app-server failed to become healthy");
180
255
  }
181
256
  connectToAppServer(isReconnect = false) {
257
+ const generation = ++this.appServerGeneration;
182
258
  return new Promise((resolve, reject) => {
183
259
  const appWs = new WebSocket(this.appServerUrl);
184
260
  appWs.onopen = () => {
261
+ if (this.appServerGeneration !== generation) {
262
+ appWs.close();
263
+ return;
264
+ }
185
265
  this.appServerWs = appWs;
186
266
  this.intentionalDisconnect = false;
187
267
  this.reconnectAttempts = 0;
188
- this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server (persistent)");
268
+ this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server");
269
+ this.flushPendingServerResponses();
270
+ if (isReconnect) {
271
+ this.handleSessionRestoreAfterReconnect().finally(() => this.drainOutageQueue()).catch((e) => {
272
+ const m = e instanceof Error ? e.message : String(e);
273
+ this.log(`session restore unexpected error: ${m}`);
274
+ });
275
+ } else {
276
+ this.drainOutageQueue();
277
+ }
189
278
  resolve();
190
279
  };
191
280
  appWs.onmessage = (event) => {
281
+ if (this.appServerGeneration !== generation)
282
+ return;
192
283
  const data = typeof event.data === "string" ? event.data : event.data.toString();
193
284
  const forwarded = this.handleAppServerPayload(data);
194
285
  if (forwarded === null)
@@ -204,22 +295,58 @@ class CodexAdapter extends EventEmitter {
204
295
  }
205
296
  };
206
297
  appWs.onerror = () => {
298
+ if (this.appServerGeneration !== generation)
299
+ return;
207
300
  this.log("App-server connection error");
208
301
  if (!isReconnect)
209
302
  reject(new Error("Failed to connect to app-server"));
210
303
  };
211
304
  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
- }
305
+ if (this.appServerGeneration !== generation)
306
+ return;
307
+ this.handleAppServerClose();
220
308
  };
221
309
  });
222
310
  }
311
+ async reconnectAppServerForNewSession(tuiWs) {
312
+ this.appServerGeneration++;
313
+ this.intentionalDisconnect = true;
314
+ if (this.reconnectTimer) {
315
+ clearTimeout(this.reconnectTimer);
316
+ this.reconnectTimer = null;
317
+ }
318
+ const oldWs = this.appServerWs;
319
+ this.appServerWs = null;
320
+ if (oldWs) {
321
+ try {
322
+ oldWs.close();
323
+ } catch {}
324
+ }
325
+ this.clearResponseTrackingStateForAppServerReconnect();
326
+ this.activeTurnIds.clear();
327
+ this.turnInProgress = false;
328
+ try {
329
+ await this.connectToAppServer(false);
330
+ this.log("App-server reconnected for new TUI session \u2014 replaying buffered messages");
331
+ const messages = this.pendingTuiMessages;
332
+ this.pendingTuiMessages = [];
333
+ this.reconnectingForNewSession = false;
334
+ this.replayingBufferedMessages = true;
335
+ try {
336
+ for (const msg of messages) {
337
+ this.onTuiMessage(tuiWs, msg);
338
+ }
339
+ } finally {
340
+ this.replayingBufferedMessages = false;
341
+ }
342
+ } catch (err) {
343
+ this.log(`Failed to reconnect app-server for new session: ${err.message}`);
344
+ this.pendingTuiMessages = [];
345
+ this.reconnectingForNewSession = false;
346
+ this.intentionalDisconnect = false;
347
+ this.scheduleReconnect();
348
+ }
349
+ }
223
350
  reconnectAttempts = 0;
224
351
  reconnectTimer = null;
225
352
  static MAX_RECONNECT_ATTEMPTS = 10;
@@ -245,6 +372,176 @@ class CodexAdapter extends EventEmitter {
245
372
  }
246
373
  }, delay);
247
374
  }
375
+ handleAppServerClose() {
376
+ const intentional = this.intentionalDisconnect;
377
+ const tuiConnected = this.tuiWs !== null;
378
+ this.log(`App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`);
379
+ this.appServerWs = null;
380
+ this.clearResponseTrackingState();
381
+ this.activeTurnIds.clear();
382
+ this.turnInProgress = false;
383
+ if (!intentional) {
384
+ this.scheduleReconnect();
385
+ }
386
+ }
387
+ bufferDuringOutage(ws, raw) {
388
+ if (this.outageQueue.length >= CodexAdapter.OUTAGE_QUEUE_MAX) {
389
+ this.log(`ERROR: outage queue overflow (${this.outageQueue.length}/${CodexAdapter.OUTAGE_QUEUE_MAX}) \u2014 closing TUI with 1011`);
390
+ this.outageQueue = [];
391
+ this.clearOutageTimer();
392
+ if (this.tuiWs && this.tuiWs === ws) {
393
+ try {
394
+ ws.close(1011, "agentbridge: app-server unavailable; pending TUI queue overflow");
395
+ } catch (e) {
396
+ this.log(`Failed to close TUI WS after outage queue overflow: ${e.message}`);
397
+ }
398
+ }
399
+ return;
400
+ }
401
+ this.outageQueue.push({ raw, connId: ws.data.connId });
402
+ this.log(`DIAGNOSTIC: buffered TUI message while app-server unavailable (queue size=${this.outageQueue.length}/${CodexAdapter.OUTAGE_QUEUE_MAX})`);
403
+ this.ensureOutageTimer();
404
+ }
405
+ ensureOutageTimer() {
406
+ if (this.outageTimer !== null)
407
+ return;
408
+ this.outageTimer = setTimeout(() => {
409
+ this.outageTimer = null;
410
+ const buffered = this.outageQueue.length;
411
+ this.outageQueue = [];
412
+ this.log(`ERROR: app-server did not return within ${CodexAdapter.OUTAGE_TIMEOUT_MS}ms (buffered=${buffered}) \u2014 closing TUI with 1011`);
413
+ const ws = this.tuiWs;
414
+ if (ws) {
415
+ try {
416
+ ws.close(1011, `agentbridge: app-server unavailable after ${CodexAdapter.OUTAGE_TIMEOUT_MS}ms; buffered=${buffered}`);
417
+ } catch (e) {
418
+ this.log(`Failed to close TUI WS on outage timeout: ${e.message}`);
419
+ }
420
+ }
421
+ }, CodexAdapter.OUTAGE_TIMEOUT_MS);
422
+ }
423
+ clearOutageTimer() {
424
+ if (this.outageTimer !== null) {
425
+ clearTimeout(this.outageTimer);
426
+ this.outageTimer = null;
427
+ }
428
+ }
429
+ async handleSessionRestoreAfterReconnect() {
430
+ if (!this.lastInitializeRaw) {
431
+ this.log("DIAGNOSTIC: no cached initialize to replay after unintentional reconnect");
432
+ return;
433
+ }
434
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
435
+ this.log("DIAGNOSTIC: app-server not open at session restore start \u2014 skipping");
436
+ return;
437
+ }
438
+ this.sessionRestoreInProgress = true;
439
+ try {
440
+ this.log(`DIAGNOSTIC: replaying cached initialize to restore session (threadId=${this.threadId ?? "none"})`);
441
+ await this.sendReplayAndAwait(this.lastInitializeRaw, "initialize");
442
+ if (this.lastInitializedRaw && this.appServerWs.readyState === WebSocket.OPEN) {
443
+ this.appServerWs.send(this.lastInitializedRaw);
444
+ }
445
+ if (this.threadId && this.appServerWs.readyState === WebSocket.OPEN) {
446
+ const replayId = `agentbridge-replay-thread-resume-${Date.now()}`;
447
+ const resumeRaw = JSON.stringify({
448
+ jsonrpc: "2.0",
449
+ id: replayId,
450
+ method: "thread/resume",
451
+ params: { threadId: this.threadId }
452
+ });
453
+ await this.sendReplayAndAwait(resumeRaw, "thread/resume");
454
+ }
455
+ this.log(`DIAGNOSTIC: session restored after unintentional reconnect (threadId=${this.threadId ?? "none"})`);
456
+ } catch (err) {
457
+ const msg = err instanceof Error ? err.message : String(err);
458
+ this.log(`ERROR: session restore failed (${msg}) \u2014 closing TUI with 1011`);
459
+ const tuiWs = this.tuiWs;
460
+ if (tuiWs) {
461
+ try {
462
+ tuiWs.close(1011, `agentbridge: session restore failed: ${msg}`);
463
+ } catch (closeErr) {
464
+ const cm = closeErr instanceof Error ? closeErr.message : String(closeErr);
465
+ this.log(`Failed to close TUI after session restore failure: ${cm}`);
466
+ }
467
+ }
468
+ } finally {
469
+ this.sessionRestoreInProgress = false;
470
+ }
471
+ }
472
+ sendReplayAndAwait(raw, method) {
473
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
474
+ return Promise.reject(new Error("app-server not open"));
475
+ }
476
+ let id;
477
+ try {
478
+ const parsed = JSON.parse(raw);
479
+ if (parsed.id === undefined) {
480
+ return Promise.reject(new Error(`replay payload for ${method} has no id`));
481
+ }
482
+ id = parsed.id;
483
+ } catch (e) {
484
+ const m = e instanceof Error ? e.message : String(e);
485
+ return Promise.reject(new Error(`replay parse failed for ${method}: ${m}`));
486
+ }
487
+ return new Promise((resolve, reject) => {
488
+ const timer = setTimeout(() => {
489
+ this.replayPending.delete(id);
490
+ reject(new Error(`replay timeout (${CodexAdapter.SESSION_REPLAY_TIMEOUT_MS}ms) for ${method} id=${JSON.stringify(id)}`));
491
+ }, CodexAdapter.SESSION_REPLAY_TIMEOUT_MS);
492
+ this.replayPending.set(id, { method, resolve, reject, timer });
493
+ try {
494
+ this.appServerWs.send(raw);
495
+ } catch (e) {
496
+ clearTimeout(timer);
497
+ this.replayPending.delete(id);
498
+ const m = e instanceof Error ? e.message : String(e);
499
+ reject(new Error(`replay send failed for ${method}: ${m}`));
500
+ }
501
+ });
502
+ }
503
+ tryConsumeReplayResponse(payload) {
504
+ const id = payload.id;
505
+ if (id === undefined)
506
+ return false;
507
+ const pending = this.replayPending.get(id);
508
+ if (!pending)
509
+ return false;
510
+ clearTimeout(pending.timer);
511
+ this.replayPending.delete(id);
512
+ if (payload.error !== undefined) {
513
+ const errMsg = typeof payload.error === "object" && payload.error !== null && "message" in payload.error ? String(payload.error.message ?? "unknown") : JSON.stringify(payload.error);
514
+ pending.reject(new Error(`${pending.method} rejected: ${errMsg}`));
515
+ } else {
516
+ pending.resolve(payload);
517
+ }
518
+ return true;
519
+ }
520
+ drainOutageQueue() {
521
+ if (this.outageQueue.length === 0) {
522
+ this.clearOutageTimer();
523
+ return;
524
+ }
525
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
526
+ return;
527
+ const ws = this.tuiWs;
528
+ if (!ws) {
529
+ this.outageQueue = [];
530
+ this.clearOutageTimer();
531
+ return;
532
+ }
533
+ const messages = this.outageQueue;
534
+ this.outageQueue = [];
535
+ this.clearOutageTimer();
536
+ this.log(`DIAGNOSTIC: replaying ${messages.length} buffered TUI messages after app-server reconnect`);
537
+ for (const msg of messages) {
538
+ try {
539
+ this.onTuiMessage(ws, msg.raw);
540
+ } catch (e) {
541
+ this.log(`Failed to replay buffered TUI message (conn #${msg.connId}): ${e.message}`);
542
+ }
543
+ }
544
+ }
248
545
  startProxy() {
249
546
  const self = this;
250
547
  this.proxyServer = Bun.serve({
@@ -252,40 +549,109 @@ class CodexAdapter extends EventEmitter {
252
549
  hostname: "127.0.0.1",
253
550
  fetch(req, server) {
254
551
  const url = new URL(req.url);
552
+ const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
553
+ self.log(`HTTP ${req.method} ${url.pathname} (upgrade=${isUpgrade})`);
255
554
  if (url.pathname === "/healthz" || url.pathname === "/readyz") {
256
555
  return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
257
556
  }
258
557
  if (server.upgrade(req, { data: { connId: 0 } }))
259
558
  return;
559
+ self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
260
560
  return new Response("AgentBridge Codex Proxy");
261
561
  },
262
562
  websocket: {
263
563
  open: (ws) => self.onTuiConnect(ws),
264
- close: (ws) => self.onTuiDisconnect(ws),
564
+ close: (ws, code, reason) => {
565
+ self.log(`WebSocket close event: conn #${ws.data.connId}, code=${code}, reason=${reason || "none"}`);
566
+ self.onTuiDisconnect(ws);
567
+ },
265
568
  message: (ws, msg) => self.onTuiMessage(ws, msg)
266
569
  }
267
570
  });
268
571
  }
269
572
  onTuiConnect(ws) {
270
- this.tuiConnId++;
271
- ws.data.connId = this.tuiConnId;
573
+ const connId = ++this.connIdCounter;
574
+ ws.data.connId = connId;
575
+ if (this.tuiWs) {
576
+ this.log(`Secondary TUI connected (conn #${connId}, primary is #${this.tuiConnId})`);
577
+ this.setupSecondaryConnection(ws, connId);
578
+ return;
579
+ }
580
+ const previousConnId = this.tuiConnId > 0 ? this.tuiConnId : null;
581
+ this.tuiConnId = connId;
272
582
  this.tuiWs = ws;
583
+ this.threadId = null;
273
584
  this.log(`TUI connected (conn #${this.tuiConnId})`);
274
585
  this.emit("tuiConnected", this.tuiConnId);
586
+ if (previousConnId !== null) {
587
+ this.retireConnectionState(previousConnId);
588
+ }
589
+ }
590
+ setupSecondaryConnection(ws, connId) {
591
+ const appWs = new WebSocket(this.appServerUrl);
592
+ const entry = { tuiWs: ws, appServerWs: appWs, buffer: [] };
593
+ this.secondaryConnections.set(connId, entry);
594
+ appWs.onopen = () => {
595
+ if (!this.secondaryConnections.has(connId)) {
596
+ appWs.close();
597
+ return;
598
+ }
599
+ this.log(`Secondary conn #${connId}: app-server WS connected, flushing ${entry.buffer.length} buffered messages`);
600
+ for (const msg of entry.buffer) {
601
+ try {
602
+ appWs.send(msg);
603
+ } catch {}
604
+ }
605
+ entry.buffer = [];
606
+ };
607
+ appWs.onmessage = (event) => {
608
+ if (!this.secondaryConnections.has(connId))
609
+ return;
610
+ const data = typeof event.data === "string" ? event.data : event.data.toString();
611
+ try {
612
+ ws.send(data);
613
+ } catch {}
614
+ };
615
+ appWs.onerror = () => {
616
+ this.log(`Secondary conn #${connId}: app-server WS error`);
617
+ };
618
+ appWs.onclose = () => {
619
+ this.log(`Secondary conn #${connId}: app-server WS closed`);
620
+ const sec = this.secondaryConnections.get(connId);
621
+ if (sec) {
622
+ this.secondaryConnections.delete(connId);
623
+ try {
624
+ sec.tuiWs.close();
625
+ } catch {}
626
+ }
627
+ };
628
+ }
629
+ replayPendingForThread(resumedThreadId, ws) {
275
630
  const remaining = [];
276
631
  for (const buffered of this.pendingServerRequests) {
632
+ const belongsToThread = buffered.threadId === null || buffered.threadId === resumedThreadId;
633
+ if (!belongsToThread) {
634
+ remaining.push(buffered);
635
+ continue;
636
+ }
277
637
  const proxyId = this.nextProxyId++;
278
638
  try {
279
639
  const parsed = JSON.parse(buffered.raw);
280
640
  parsed.id = proxyId;
281
641
  ws.send(JSON.stringify(parsed));
282
642
  this.serverRequestToProxy.set(proxyId, {
643
+ raw: buffered.raw,
283
644
  serverId: buffered.serverId,
284
645
  connId: this.tuiConnId,
285
646
  method: buffered.method,
286
- timestamp: Date.now()
647
+ timestamp: Date.now(),
648
+ threadId: buffered.threadId
287
649
  });
288
- this.log(`Replayed buffered server request: ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId})`);
650
+ if (buffered.threadId === null) {
651
+ 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})`);
652
+ } else {
653
+ this.log(`Replayed buffered server request on thread/resume: ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId}, threadId=${buffered.threadId})`);
654
+ }
289
655
  } catch (e) {
290
656
  this.log(`Failed to replay buffered server request: ${buffered.method} (server id=${buffered.serverId}): ${e.message}`);
291
657
  remaining.push(buffered);
@@ -293,11 +659,47 @@ class CodexAdapter extends EventEmitter {
293
659
  }
294
660
  this.pendingServerRequests = remaining;
295
661
  }
662
+ dropOrphanPendingRequests(reason, matchThreadId = null) {
663
+ if (this.pendingServerRequests.length === 0)
664
+ return;
665
+ const remaining = [];
666
+ for (const buffered of this.pendingServerRequests) {
667
+ const shouldDrop = matchThreadId === null ? true : buffered.threadId !== null && buffered.threadId !== matchThreadId;
668
+ if (shouldDrop) {
669
+ this.log(`Dropped orphan pending server request: ${buffered.method} (server id=${buffered.serverId}, threadId=${buffered.threadId ?? "unknown"}, reason=${reason})`);
670
+ continue;
671
+ }
672
+ remaining.push(buffered);
673
+ }
674
+ this.pendingServerRequests = remaining;
675
+ }
296
676
  onTuiDisconnect(ws) {
297
677
  const connId = ws.data.connId;
678
+ const secondary = this.secondaryConnections.get(connId);
679
+ if (secondary) {
680
+ this.log(`Secondary TUI disconnected (conn #${connId})`);
681
+ this.secondaryConnections.delete(connId);
682
+ if (secondary.appServerWs) {
683
+ try {
684
+ secondary.appServerWs.close();
685
+ } catch {}
686
+ }
687
+ return;
688
+ }
298
689
  if (this.tuiWs === ws) {
299
- this.log(`TUI disconnected (conn #${connId})`);
690
+ const appServerOpen = this.appServerWs?.readyState === WebSocket.OPEN;
691
+ this.log(`TUI disconnected (conn #${connId}, appServerOpen=${appServerOpen}, turnInProgress=${this.turnInProgress}, pendingTuiMessages=${this.pendingTuiMessages.length}, outageQueue=${this.outageQueue.length}, reconnectingForNewSession=${this.reconnectingForNewSession})`);
300
692
  this.tuiWs = null;
693
+ if (this.reconnectingForNewSession) {
694
+ this.log("Clearing pending TUI message buffer (TUI disconnected during app-server reconnect)");
695
+ this.pendingTuiMessages = [];
696
+ this.reconnectingForNewSession = false;
697
+ }
698
+ if (this.outageQueue.length > 0 || this.outageTimer !== null) {
699
+ this.log(`Clearing outage queue on TUI disconnect (buffered=${this.outageQueue.length})`);
700
+ this.outageQueue = [];
701
+ this.clearOutageTimer();
702
+ }
301
703
  this.emit("tuiDisconnected", connId);
302
704
  } else {
303
705
  this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
@@ -307,6 +709,17 @@ class CodexAdapter extends EventEmitter {
307
709
  onTuiMessage(ws, msg) {
308
710
  const data = typeof msg === "string" ? msg : msg.toString();
309
711
  const connId = ws.data.connId;
712
+ const secondary = this.secondaryConnections.get(connId);
713
+ if (secondary) {
714
+ if (secondary.appServerWs && secondary.appServerWs.readyState === WebSocket.OPEN) {
715
+ try {
716
+ secondary.appServerWs.send(data);
717
+ } catch {}
718
+ } else {
719
+ secondary.buffer.push(data);
720
+ }
721
+ return;
722
+ }
310
723
  if (connId !== this.tuiConnId) {
311
724
  this.log(`Dropping message from stale TUI conn #${connId} (current is #${this.tuiConnId})`);
312
725
  return;
@@ -315,29 +728,63 @@ class CodexAdapter extends EventEmitter {
315
728
  const parsed = JSON.parse(data);
316
729
  if (parsed.id !== undefined && !parsed.method) {
317
730
  const normalizedId = this.normalizeNumericId(parsed.id);
731
+ if (!isNaN(normalizedId) && this.pendingServerResponses.has(normalizedId)) {
732
+ this.log(`Ignoring duplicate approval response while app-server reconnect is pending (proxy id=${normalizedId})`);
733
+ return;
734
+ }
318
735
  const pending = !isNaN(normalizedId) ? this.serverRequestToProxy.get(normalizedId) : undefined;
319
736
  if (pending !== undefined) {
320
737
  if (pending.connId !== connId) {
321
738
  this.log(`Dropping stale server request response (proxy id=${normalizedId}, expected conn #${pending.connId}, got #${connId})`);
322
739
  return;
323
740
  }
741
+ parsed.id = pending.serverId;
742
+ const forwardedResponse = JSON.stringify(parsed);
324
743
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
325
- this.log(`Cannot forward approval response: app-server disconnected (proxy id=${normalizedId})`);
744
+ this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, "app-server disconnected");
326
745
  return;
327
746
  }
328
- parsed.id = pending.serverId;
329
747
  try {
330
- this.appServerWs.send(JSON.stringify(parsed));
748
+ this.appServerWs.send(forwardedResponse);
331
749
  this.serverRequestToProxy.delete(normalizedId);
332
750
  this.log(`TUI \u2192 app-server: ${pending.method} response (proxy id=${normalizedId} \u2192 server id=${pending.serverId})`);
333
751
  } catch (e) {
334
- parsed.id = normalizedId;
335
- this.log(`Failed to forward approval response (proxy id=${normalizedId}): ${e.message}`);
752
+ this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, `send failed: ${e.message}`);
336
753
  }
337
754
  return;
338
755
  }
339
756
  }
340
757
  } catch {}
758
+ let detectedMethod;
759
+ try {
760
+ const parsed = JSON.parse(data);
761
+ detectedMethod = typeof parsed.method === "string" ? parsed.method : undefined;
762
+ } catch {}
763
+ if (!this.replayingBufferedMessages) {
764
+ if (detectedMethod === "initialize") {
765
+ this.lastInitializeRaw = data;
766
+ this.log("Detected initialize \u2014 reconnecting app-server for fresh session");
767
+ this.reconnectingForNewSession = true;
768
+ this.pendingTuiMessages = [data];
769
+ this.reconnectAppServerForNewSession(ws);
770
+ return;
771
+ }
772
+ if (this.reconnectingForNewSession) {
773
+ this.pendingTuiMessages.push(data);
774
+ return;
775
+ }
776
+ }
777
+ if (detectedMethod === "initialized") {
778
+ this.lastInitializedRaw = data;
779
+ }
780
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN || this.sessionRestoreInProgress) {
781
+ if (this.tuiWs && this.tuiWs === ws) {
782
+ this.bufferDuringOutage(ws, data);
783
+ } else {
784
+ this.log(`WARNING: non-primary TUI attempted to send while app-server down \u2014 dropped (connId=${connId})`);
785
+ }
786
+ return;
787
+ }
341
788
  let forwarded = data;
342
789
  try {
343
790
  const parsed = JSON.parse(data);
@@ -358,14 +805,24 @@ class CodexAdapter extends EventEmitter {
358
805
  if (this.appServerWs?.readyState === WebSocket.OPEN) {
359
806
  this.appServerWs.send(forwarded);
360
807
  } else {
361
- this.log(`WARNING: app-server not connected, dropping message`);
808
+ this.log(`WARNING: app-server closed between OPEN check and send \u2014 message lost (connId=${ws.data.connId})`);
362
809
  }
363
810
  }
364
811
  handleAppServerPayload(raw) {
365
812
  try {
366
813
  const parsed = JSON.parse(raw);
814
+ if (typeof parsed === "object" && parsed !== null && "id" in parsed) {
815
+ if (this.tryConsumeReplayResponse(parsed)) {
816
+ return null;
817
+ }
818
+ }
367
819
  if (isAppServerNotification(parsed) || typeof parsed === "object" && parsed !== null && !("id" in parsed)) {
368
820
  const notificationLike = parsed;
821
+ if (notificationLike.method === "thread/closed") {
822
+ const params = notificationLike.params;
823
+ const threadId = typeof params?.threadId === "string" ? params.threadId : "unknown";
824
+ this.log(`DIAGNOSTIC: app-server emitted thread/closed (threadId=${threadId}) \u2014 TUI will exit(0) silently`);
825
+ }
369
826
  const forwarded = this.patchResponse(notificationLike, raw);
370
827
  this.interceptServerMessage(notificationLike);
371
828
  return forwarded;
@@ -386,9 +843,10 @@ class CodexAdapter extends EventEmitter {
386
843
  handleServerRequest(parsed, raw) {
387
844
  const serverId = parsed.id;
388
845
  const method = parsed.method;
846
+ const threadId = this.extractThreadIdFromParams(parsed.params);
389
847
  if (!this.tuiWs) {
390
- this.pendingServerRequests.push({ raw, serverId, method });
391
- this.log(`Server request buffered (no TUI): ${method} (server id=${serverId})`);
848
+ this.pendingServerRequests.push({ raw, serverId, method, threadId });
849
+ this.log(`Server request buffered (no TUI): ${method} (server id=${serverId}, threadId=${threadId ?? "unknown"})`);
392
850
  return;
393
851
  }
394
852
  const proxyId = this.nextProxyId++;
@@ -397,11 +855,24 @@ class CodexAdapter extends EventEmitter {
397
855
  this.tuiWs.send(JSON.stringify(parsed));
398
856
  } catch (e) {
399
857
  this.log(`Server request send failed, buffering: ${method} (server id=${serverId}): ${e.message}`);
400
- this.pendingServerRequests.push({ raw, serverId, method });
858
+ this.pendingServerRequests.push({ raw, serverId, method, threadId });
401
859
  return;
402
860
  }
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})`);
861
+ this.serverRequestToProxy.set(proxyId, {
862
+ raw,
863
+ serverId,
864
+ connId: this.tuiConnId,
865
+ method,
866
+ timestamp: Date.now(),
867
+ threadId
868
+ });
869
+ this.log(`Server request: ${method} (server id=${serverId} \u2192 proxy id=${proxyId}, conn #${this.tuiConnId}, threadId=${threadId ?? "unknown"})`);
870
+ }
871
+ extractThreadIdFromParams(params) {
872
+ if (typeof params !== "object" || params === null)
873
+ return null;
874
+ const tid = params.threadId;
875
+ return typeof tid === "string" && tid.length > 0 ? tid : null;
405
876
  }
406
877
  normalizeNumericId(id) {
407
878
  if (typeof id === "number")
@@ -410,6 +881,30 @@ class CodexAdapter extends EventEmitter {
410
881
  return Number(id);
411
882
  return NaN;
412
883
  }
884
+ bufferPendingServerResponse(proxyId, pending, forwardedResponse, reason) {
885
+ this.pendingServerResponses.set(proxyId, {
886
+ raw: forwardedResponse,
887
+ serverId: pending.serverId,
888
+ method: pending.method,
889
+ timestamp: Date.now()
890
+ });
891
+ this.serverRequestToProxy.delete(proxyId);
892
+ this.log(`Buffered approval response until app-server reconnect (${reason}) (proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
893
+ }
894
+ flushPendingServerResponses() {
895
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
896
+ return;
897
+ for (const [proxyId, pending] of this.pendingServerResponses.entries()) {
898
+ try {
899
+ this.appServerWs.send(pending.raw);
900
+ this.pendingServerResponses.delete(proxyId);
901
+ this.log(`Flushed buffered approval response after app-server reconnect (proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
902
+ } catch (e) {
903
+ this.log(`Failed to flush buffered approval response (proxy id=${proxyId}): ${e.message}`);
904
+ break;
905
+ }
906
+ }
907
+ }
413
908
  handleAppServerResponse(parsed, raw) {
414
909
  const responseId = parsed.id;
415
910
  const numericId = this.normalizeNumericId(responseId);
@@ -421,6 +916,7 @@ class CodexAdapter extends EventEmitter {
421
916
  return null;
422
917
  }
423
918
  parsed.id = mapping.clientId;
919
+ this.log(`app-server \u2192 TUI: response (proxy id=${numericId} \u2192 client id=${String(mapping.clientId)}, conn #${mapping.connId})`);
424
920
  const forwarded = this.patchResponse(parsed, JSON.stringify(parsed));
425
921
  this.interceptServerMessage(parsed, mapping.connId);
426
922
  return forwarded;
@@ -460,17 +956,6 @@ class CodexAdapter extends EventEmitter {
460
956
  }
461
957
  });
462
958
  }
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
959
  }
475
960
  return raw;
476
961
  }
@@ -544,7 +1029,6 @@ class CodexAdapter extends EventEmitter {
544
1029
  const rpcId = "id" in message ? message.id : undefined;
545
1030
  const method = "method" in message && typeof message.method === "string" ? message.method : undefined;
546
1031
  const key = this.pendingKey(rpcId, connId);
547
- this.log(`[track] method=${method} id=${rpcId} (type=${typeof rpcId}) key=${key}`);
548
1032
  if (!key || !isTrackedAppServerRequestMethod(method))
549
1033
  return;
550
1034
  const pending = { method };
@@ -582,12 +1066,17 @@ class CodexAdapter extends EventEmitter {
582
1066
  if (typeof threadId === "string" && threadId.length > 0) {
583
1067
  this.setActiveThreadId(threadId, `thread/start response ${key}`);
584
1068
  }
1069
+ this.dropOrphanPendingRequests(`thread/start (new session)`);
585
1070
  break;
586
1071
  }
587
1072
  case "thread/resume": {
588
1073
  const threadId = message?.result?.thread?.id;
589
1074
  if (typeof threadId === "string" && threadId.length > 0) {
590
1075
  this.setActiveThreadId(threadId, `thread/resume response ${key}`);
1076
+ if (this.tuiWs) {
1077
+ this.replayPendingForThread(threadId, this.tuiWs);
1078
+ }
1079
+ this.dropOrphanPendingRequests(`thread/resume to ${threadId}`, threadId);
591
1080
  }
592
1081
  break;
593
1082
  }
@@ -647,20 +1136,22 @@ class CodexAdapter extends EventEmitter {
647
1136
  this.upstreamToClient.delete(upId);
648
1137
  this.trackStaleProxyId(upId);
649
1138
  }
1139
+ const requeuedServerRequests = [];
650
1140
  for (const [proxyId, pending] of this.serverRequestToProxy.entries()) {
651
1141
  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);
1142
+ this.serverRequestToProxy.delete(proxyId);
1143
+ requeuedServerRequests.push({
1144
+ raw: pending.raw,
1145
+ serverId: pending.serverId,
1146
+ method: pending.method,
1147
+ threadId: pending.threadId
1148
+ });
1149
+ 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
1150
  }
663
1151
  }
1152
+ if (requeuedServerRequests.length === 0)
1153
+ return;
1154
+ this.pendingServerRequests.push(...requeuedServerRequests);
664
1155
  }
665
1156
  trackStaleProxyId(proxyId) {
666
1157
  this.clearTrackedId(this.staleProxyIds, proxyId);
@@ -695,7 +1186,7 @@ class CodexAdapter extends EventEmitter {
695
1186
  store.delete(id);
696
1187
  return true;
697
1188
  }
698
- clearResponseTrackingState() {
1189
+ clearTransientResponseTrackingState() {
699
1190
  this.pendingRequests.clear();
700
1191
  this.upstreamToClient.clear();
701
1192
  for (const timer of this.staleProxyIds.values()) {
@@ -706,17 +1197,36 @@ class CodexAdapter extends EventEmitter {
706
1197
  clearTimeout(timer);
707
1198
  }
708
1199
  this.bridgeRequestIds.clear();
709
- for (const timer of this.serverRequestTtlTimers.values()) {
710
- clearTimeout(timer);
711
- }
712
- this.serverRequestTtlTimers.clear();
1200
+ }
1201
+ clearResponseTrackingState() {
1202
+ this.clearTransientResponseTrackingState();
713
1203
  this.serverRequestToProxy.clear();
714
1204
  this.pendingServerRequests = [];
1205
+ this.pendingServerResponses.clear();
1206
+ }
1207
+ clearResponseTrackingStateForAppServerReconnect() {
1208
+ this.clearTransientResponseTrackingState();
1209
+ for (const pending of this.serverRequestToProxy.values()) {
1210
+ this.pendingServerRequests.push({
1211
+ raw: pending.raw,
1212
+ serverId: pending.serverId,
1213
+ method: pending.method,
1214
+ threadId: pending.threadId
1215
+ });
1216
+ this.log(`Requeued in-flight server request on app-server reconnect (server id=${pending.serverId}, method=${pending.method}, threadId=${pending.threadId ?? "unknown"})`);
1217
+ }
1218
+ this.serverRequestToProxy.clear();
1219
+ this.pendingServerResponses.clear();
1220
+ }
1221
+ static buildPortListenLsofCommand(port) {
1222
+ return `lsof -ti tcp:${port} -sTCP:LISTEN`;
715
1223
  }
716
1224
  async checkPorts() {
717
1225
  for (const port of [this.appPort, this.proxyPort]) {
718
1226
  try {
719
- const pids = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
1227
+ const pids = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
1228
+ encoding: "utf-8"
1229
+ }).trim();
720
1230
  if (!pids)
721
1231
  continue;
722
1232
  const pidList = pids.split(`
@@ -746,7 +1256,9 @@ class CodexAdapter extends EventEmitter {
746
1256
  throw new Error(`Port ${port} is already in use by non-Codex process(es): PID(s) ${foreignPids.join(", ")}. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
747
1257
  }
748
1258
  try {
749
- const remaining = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
1259
+ const remaining = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
1260
+ encoding: "utf-8"
1261
+ }).trim();
750
1262
  if (remaining) {
751
1263
  throw new Error(`Port ${port} is still occupied (PID(s): ${remaining.replace(/\n/g, ", ")}) after cleanup. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
752
1264
  }
@@ -765,7 +1277,7 @@ class CodexAdapter extends EventEmitter {
765
1277
  `;
766
1278
  process.stderr.write(line);
767
1279
  try {
768
- appendFileSync(LOG_FILE, line);
1280
+ appendFileSync(this.logFile, line);
769
1281
  } catch {}
770
1282
  }
771
1283
  }
@@ -973,7 +1485,7 @@ class TuiConnectionState {
973
1485
 
974
1486
  // src/daemon-lifecycle.ts
975
1487
  import { spawn as spawn2, execFileSync } from "child_process";
976
- import { existsSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
1488
+ import { existsSync as existsSync2, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
977
1489
  import { fileURLToPath } from "url";
978
1490
  var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY ?? "./daemon.ts";
979
1491
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
@@ -1111,7 +1623,7 @@ class DaemonLifecycle {
1111
1623
  } catch {}
1112
1624
  }
1113
1625
  wasKilled() {
1114
- return existsSync(this.stateDir.killedFile);
1626
+ return existsSync2(this.stateDir.killedFile);
1115
1627
  }
1116
1628
  launch() {
1117
1629
  this.stateDir.ensure();
@@ -1232,116 +1744,62 @@ function isProcessAlive(pid) {
1232
1744
  }
1233
1745
  }
1234
1746
 
1235
- // src/state-dir.ts
1236
- import { mkdirSync, existsSync as existsSync2 } from "fs";
1237
- import { join } from "path";
1238
- import { homedir, platform } from "os";
1239
-
1240
- class StateDirResolver {
1241
- stateDir;
1242
- constructor(envOverride) {
1243
- const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
1244
- if (override) {
1245
- this.stateDir = override;
1246
- } else if (platform() === "darwin") {
1247
- this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
1248
- } else {
1249
- const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
1250
- this.stateDir = join(xdgState, "agentbridge");
1251
- }
1252
- }
1253
- ensure() {
1254
- if (!existsSync2(this.stateDir)) {
1255
- mkdirSync(this.stateDir, { recursive: true });
1256
- }
1257
- }
1258
- get dir() {
1259
- return this.stateDir;
1260
- }
1261
- get pidFile() {
1262
- return join(this.stateDir, "daemon.pid");
1263
- }
1264
- get tuiPidFile() {
1265
- return join(this.stateDir, "codex-tui.pid");
1266
- }
1267
- get lockFile() {
1268
- return join(this.stateDir, "daemon.lock");
1269
- }
1270
- get statusFile() {
1271
- return join(this.stateDir, "status.json");
1272
- }
1273
- get portsFile() {
1274
- return join(this.stateDir, "ports.json");
1275
- }
1276
- get logFile() {
1277
- return join(this.stateDir, "agentbridge.log");
1278
- }
1279
- get killedFile() {
1280
- return join(this.stateDir, "killed");
1281
- }
1282
- }
1283
-
1284
1747
  // src/config-service.ts
1285
1748
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
1286
1749
  import { join as join2 } from "path";
1287
1750
  var DEFAULT_CONFIG = {
1288
1751
  version: "1.0",
1289
- daemon: {
1290
- port: 4500,
1752
+ codex: {
1753
+ appPort: 4500,
1291
1754
  proxyPort: 4501
1292
1755
  },
1293
- agents: {
1294
- claude: {
1295
- role: "Reviewer, Planner",
1296
- mode: "push"
1297
- },
1298
- codex: {
1299
- role: "Implementer, Executor"
1300
- }
1301
- },
1302
- markers: ["IMPORTANT", "STATUS", "FYI"],
1303
1756
  turnCoordination: {
1304
- attentionWindowSeconds: 15,
1305
- busyGuard: true
1757
+ attentionWindowSeconds: 15
1306
1758
  },
1307
1759
  idleShutdownSeconds: 30
1308
1760
  };
1309
- var DEFAULT_COLLABORATION_MD = `# Collaboration Rules
1310
-
1311
- ## Roles
1312
- - Claude: Reviewer, Planner, Hypothesis Challenger
1313
- - Codex: Implementer, Executor, Reproducer/Verifier
1314
-
1315
- ## Thinking Patterns
1316
- - Analytical/review tasks: Independent Analysis & Convergence
1317
- - Implementation tasks: Architect -> Builder -> Critic
1318
- - Debugging tasks: Hypothesis -> Experiment -> Interpretation
1319
-
1320
- ## Communication
1321
- - Use explicit phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"
1322
- - Tag messages with [IMPORTANT], [STATUS], or [FYI]
1323
-
1324
- ## Review Process
1325
- - Cross-review: author never reviews their own code
1326
- - All changes go through feature/fix branches + PR
1327
- - Merge via squash merge
1328
-
1329
- ## Custom Rules
1330
- <!-- Add your project-specific collaboration rules here -->
1331
- `;
1332
1761
  var CONFIG_DIR = ".agentbridge";
1333
1762
  var CONFIG_FILE = "config.json";
1334
- var COLLABORATION_FILE = "collaboration.md";
1763
+ function isRecord(value) {
1764
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1765
+ }
1766
+ function normalizeInteger(value, fallback) {
1767
+ if (typeof value === "number" && Number.isFinite(value))
1768
+ return value;
1769
+ if (typeof value === "string") {
1770
+ const parsed = Number(value);
1771
+ if (Number.isFinite(parsed))
1772
+ return parsed;
1773
+ }
1774
+ return fallback;
1775
+ }
1776
+ function normalizeConfig(raw) {
1777
+ if (!isRecord(raw))
1778
+ return null;
1779
+ const config = raw;
1780
+ const codex = isRecord(config.codex) ? config.codex : {};
1781
+ const daemon = isRecord(config.daemon) ? config.daemon : {};
1782
+ const turnCoordination = isRecord(config.turnCoordination) ? config.turnCoordination : {};
1783
+ return {
1784
+ version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version,
1785
+ codex: {
1786
+ appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
1787
+ proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
1788
+ },
1789
+ turnCoordination: {
1790
+ attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
1791
+ },
1792
+ idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
1793
+ };
1794
+ }
1335
1795
 
1336
1796
  class ConfigService {
1337
1797
  configDir;
1338
1798
  configPath;
1339
- collaborationPath;
1340
1799
  constructor(projectRoot) {
1341
1800
  const root = projectRoot ?? process.cwd();
1342
1801
  this.configDir = join2(root, CONFIG_DIR);
1343
1802
  this.configPath = join2(this.configDir, CONFIG_FILE);
1344
- this.collaborationPath = join2(this.configDir, COLLABORATION_FILE);
1345
1803
  }
1346
1804
  hasConfig() {
1347
1805
  return existsSync3(this.configPath);
@@ -1349,7 +1807,7 @@ class ConfigService {
1349
1807
  load() {
1350
1808
  try {
1351
1809
  const raw = readFileSync2(this.configPath, "utf-8");
1352
- return JSON.parse(raw);
1810
+ return normalizeConfig(JSON.parse(raw));
1353
1811
  } catch {
1354
1812
  return null;
1355
1813
  }
@@ -1362,17 +1820,6 @@ class ConfigService {
1362
1820
  writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
1363
1821
  `, "utf-8");
1364
1822
  }
1365
- loadCollaboration() {
1366
- try {
1367
- return readFileSync2(this.collaborationPath, "utf-8");
1368
- } catch {
1369
- return null;
1370
- }
1371
- }
1372
- saveCollaboration(content) {
1373
- this.ensureConfigDir();
1374
- writeFileSync2(this.collaborationPath, content, "utf-8");
1375
- }
1376
1823
  initDefaults() {
1377
1824
  this.ensureConfigDir();
1378
1825
  const created = [];
@@ -1380,18 +1827,11 @@ class ConfigService {
1380
1827
  this.save(DEFAULT_CONFIG);
1381
1828
  created.push(this.configPath);
1382
1829
  }
1383
- if (!existsSync3(this.collaborationPath)) {
1384
- this.saveCollaboration(DEFAULT_COLLABORATION_MD);
1385
- created.push(this.collaborationPath);
1386
- }
1387
1830
  return created;
1388
1831
  }
1389
1832
  get configFilePath() {
1390
1833
  return this.configPath;
1391
1834
  }
1392
- get collaborationFilePath() {
1393
- return this.collaborationPath;
1394
- }
1395
1835
  ensureConfigDir() {
1396
1836
  if (!existsSync3(this.configDir)) {
1397
1837
  mkdirSync2(this.configDir, { recursive: true });
@@ -1399,13 +1839,16 @@ class ConfigService {
1399
1839
  }
1400
1840
  }
1401
1841
 
1842
+ // src/control-protocol.ts
1843
+ var CLOSE_CODE_REPLACED = 4001;
1844
+
1402
1845
  // src/daemon.ts
1403
1846
  var stateDir = new StateDirResolver;
1404
1847
  stateDir.ensure();
1405
1848
  var configService = new ConfigService;
1406
1849
  var config = configService.loadOrDefault();
1407
- var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.daemon.port), 10);
1408
- var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.daemon.proxyPort), 10);
1850
+ var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
1851
+ var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
1409
1852
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
1410
1853
  var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
1411
1854
  var CLAUDE_DISCONNECT_GRACE_MS = 5000;
@@ -1414,7 +1857,7 @@ var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "fil
1414
1857
  var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
1415
1858
  var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
1416
1859
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
1417
- var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT);
1860
+ var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
1418
1861
  var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
1419
1862
  var controlServer = null;
1420
1863
  var attachedClaude = null;
@@ -1430,6 +1873,7 @@ var idleShutdownTimer = null;
1430
1873
  var claudeDisconnectTimer = null;
1431
1874
  var claudeOnlineNoticeSent = false;
1432
1875
  var claudeOfflineNoticeShown = false;
1876
+ var codexCollaborationKickoffSent = false;
1433
1877
  var lastAttachStatusSentTs = 0;
1434
1878
  var ATTACH_STATUS_COOLDOWN_MS = 30000;
1435
1879
  var bufferedMessages = [];
@@ -1496,6 +1940,9 @@ codex.on("turnCompleted", () => {
1496
1940
  replyReceivedDuringTurn = false;
1497
1941
  emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
1498
1942
  startAttentionWindow();
1943
+ if (attachedClaude && shouldNotifyCodexClaudeOnline()) {
1944
+ notifyCodexClaudeOnline();
1945
+ }
1499
1946
  });
1500
1947
  codex.on("ready", (threadId) => {
1501
1948
  tuiConnectionState.markBridgeReady();
@@ -1640,8 +2087,10 @@ function handleControlMessage(ws, raw) {
1640
2087
  }
1641
2088
  }
1642
2089
  function attachClaude(ws) {
1643
- if (attachedClaude && attachedClaude !== ws) {
1644
- attachedClaude.close(4001, "replaced by a newer Claude session");
2090
+ if (attachedClaude && attachedClaude !== ws && attachedClaude.readyState !== WebSocket.CLOSED) {
2091
+ log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 another session (#${attachedClaude.data.clientId}) is already attached (readyState=${attachedClaude.readyState})`);
2092
+ ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
2093
+ return;
1645
2094
  }
1646
2095
  clearPendingClaudeDisconnect("Claude frontend attached");
1647
2096
  attachedClaude = ws;
@@ -1826,9 +2275,23 @@ function currentReadyMessage() {
1826
2275
  return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
1827
2276
  }
1828
2277
  function notifyCodexClaudeOnline() {
2278
+ const message = !codexCollaborationKickoffSent ? [
2279
+ "\uD83E\uDD1D Claude Code has connected via AgentBridge.",
2280
+ "You are now in a multi-agent collaboration session.",
2281
+ "When you receive a complex task, propose a division of labor to Claude.",
2282
+ "Claude can send you messages \u2014 they will appear as injected user messages.",
2283
+ "Respond naturally and Claude will receive your output via AgentBridge."
2284
+ ].join(`
2285
+ `) : "\u2705 AgentBridge connected to Claude Code.";
2286
+ const delivered = codex.injectMessage(message);
2287
+ if (!delivered) {
2288
+ log("Deferred Claude-online notice to Codex \u2014 will retry after current turn completes");
2289
+ return false;
2290
+ }
1829
2291
  claudeOnlineNoticeSent = true;
1830
2292
  claudeOfflineNoticeShown = false;
1831
- codex.injectMessage("\u2705 AgentBridge connected to Claude Code.");
2293
+ codexCollaborationKickoffSent = true;
2294
+ return true;
1832
2295
  }
1833
2296
  function shouldNotifyCodexClaudeOnline() {
1834
2297
  return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;