@raysonmeng/agentbridge 0.1.5 → 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,6 +116,7 @@ class CodexAdapter extends EventEmitter {
66
116
  nextInjectionId = -1;
67
117
  appPort;
68
118
  proxyPort;
119
+ logFile;
69
120
  tuiConnId = 0;
70
121
  connIdCounter = 0;
71
122
  secondaryConnections = new Map;
@@ -85,10 +136,20 @@ class CodexAdapter extends EventEmitter {
85
136
  reconnectingForNewSession = false;
86
137
  replayingBufferedMessages = false;
87
138
  appServerGeneration = 0;
88
- constructor(appPort = 4500, proxyPort = 4501) {
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) {
89
149
  super();
90
150
  this.appPort = appPort;
91
151
  this.proxyPort = proxyPort;
152
+ this.logFile = logFile;
92
153
  }
93
154
  get appServerUrl() {
94
155
  return `ws://127.0.0.1:${this.appPort}`;
@@ -123,6 +184,8 @@ class CodexAdapter extends EventEmitter {
123
184
  clearTimeout(this.reconnectTimer);
124
185
  this.reconnectTimer = null;
125
186
  }
187
+ this.outageQueue = [];
188
+ this.clearOutageTimer();
126
189
  this.appServerWs?.close();
127
190
  this.appServerWs = null;
128
191
  for (const [id, sec] of this.secondaryConnections) {
@@ -204,6 +267,14 @@ class CodexAdapter extends EventEmitter {
204
267
  this.reconnectAttempts = 0;
205
268
  this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server");
206
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
+ }
207
278
  resolve();
208
279
  };
209
280
  appWs.onmessage = (event) => {
@@ -302,15 +373,175 @@ class CodexAdapter extends EventEmitter {
302
373
  }, delay);
303
374
  }
304
375
  handleAppServerClose() {
305
- this.log("App-server connection closed");
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})`);
306
379
  this.appServerWs = null;
307
380
  this.clearResponseTrackingState();
308
381
  this.activeTurnIds.clear();
309
382
  this.turnInProgress = false;
310
- if (!this.intentionalDisconnect) {
383
+ if (!intentional) {
311
384
  this.scheduleReconnect();
312
385
  }
313
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
+ }
314
545
  startProxy() {
315
546
  const self = this;
316
547
  this.proxyServer = Bun.serve({
@@ -456,13 +687,19 @@ class CodexAdapter extends EventEmitter {
456
687
  return;
457
688
  }
458
689
  if (this.tuiWs === ws) {
459
- 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})`);
460
692
  this.tuiWs = null;
461
693
  if (this.reconnectingForNewSession) {
462
694
  this.log("Clearing pending TUI message buffer (TUI disconnected during app-server reconnect)");
463
695
  this.pendingTuiMessages = [];
464
696
  this.reconnectingForNewSession = false;
465
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
+ }
466
703
  this.emit("tuiDisconnected", connId);
467
704
  } else {
468
705
  this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
@@ -525,6 +762,7 @@ class CodexAdapter extends EventEmitter {
525
762
  } catch {}
526
763
  if (!this.replayingBufferedMessages) {
527
764
  if (detectedMethod === "initialize") {
765
+ this.lastInitializeRaw = data;
528
766
  this.log("Detected initialize \u2014 reconnecting app-server for fresh session");
529
767
  this.reconnectingForNewSession = true;
530
768
  this.pendingTuiMessages = [data];
@@ -536,6 +774,17 @@ class CodexAdapter extends EventEmitter {
536
774
  return;
537
775
  }
538
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
+ }
539
788
  let forwarded = data;
540
789
  try {
541
790
  const parsed = JSON.parse(data);
@@ -556,14 +805,24 @@ class CodexAdapter extends EventEmitter {
556
805
  if (this.appServerWs?.readyState === WebSocket.OPEN) {
557
806
  this.appServerWs.send(forwarded);
558
807
  } else {
559
- 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})`);
560
809
  }
561
810
  }
562
811
  handleAppServerPayload(raw) {
563
812
  try {
564
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
+ }
565
819
  if (isAppServerNotification(parsed) || typeof parsed === "object" && parsed !== null && !("id" in parsed)) {
566
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
+ }
567
826
  const forwarded = this.patchResponse(notificationLike, raw);
568
827
  this.interceptServerMessage(notificationLike);
569
828
  return forwarded;
@@ -959,10 +1218,15 @@ class CodexAdapter extends EventEmitter {
959
1218
  this.serverRequestToProxy.clear();
960
1219
  this.pendingServerResponses.clear();
961
1220
  }
1221
+ static buildPortListenLsofCommand(port) {
1222
+ return `lsof -ti tcp:${port} -sTCP:LISTEN`;
1223
+ }
962
1224
  async checkPorts() {
963
1225
  for (const port of [this.appPort, this.proxyPort]) {
964
1226
  try {
965
- const pids = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
1227
+ const pids = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
1228
+ encoding: "utf-8"
1229
+ }).trim();
966
1230
  if (!pids)
967
1231
  continue;
968
1232
  const pidList = pids.split(`
@@ -992,7 +1256,9 @@ class CodexAdapter extends EventEmitter {
992
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.`);
993
1257
  }
994
1258
  try {
995
- const remaining = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
1259
+ const remaining = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
1260
+ encoding: "utf-8"
1261
+ }).trim();
996
1262
  if (remaining) {
997
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.`);
998
1264
  }
@@ -1011,7 +1277,7 @@ class CodexAdapter extends EventEmitter {
1011
1277
  `;
1012
1278
  process.stderr.write(line);
1013
1279
  try {
1014
- appendFileSync(LOG_FILE, line);
1280
+ appendFileSync(this.logFile, line);
1015
1281
  } catch {}
1016
1282
  }
1017
1283
  }
@@ -1219,7 +1485,7 @@ class TuiConnectionState {
1219
1485
 
1220
1486
  // src/daemon-lifecycle.ts
1221
1487
  import { spawn as spawn2, execFileSync } from "child_process";
1222
- import { existsSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
1488
+ import { existsSync as existsSync2, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
1223
1489
  import { fileURLToPath } from "url";
1224
1490
  var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY ?? "./daemon.ts";
1225
1491
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
@@ -1357,7 +1623,7 @@ class DaemonLifecycle {
1357
1623
  } catch {}
1358
1624
  }
1359
1625
  wasKilled() {
1360
- return existsSync(this.stateDir.killedFile);
1626
+ return existsSync2(this.stateDir.killedFile);
1361
1627
  }
1362
1628
  launch() {
1363
1629
  this.stateDir.ensure();
@@ -1478,116 +1744,62 @@ function isProcessAlive(pid) {
1478
1744
  }
1479
1745
  }
1480
1746
 
1481
- // src/state-dir.ts
1482
- import { mkdirSync, existsSync as existsSync2 } from "fs";
1483
- import { join } from "path";
1484
- import { homedir, platform } from "os";
1485
-
1486
- class StateDirResolver {
1487
- stateDir;
1488
- constructor(envOverride) {
1489
- const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
1490
- if (override) {
1491
- this.stateDir = override;
1492
- } else if (platform() === "darwin") {
1493
- this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
1494
- } else {
1495
- const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
1496
- this.stateDir = join(xdgState, "agentbridge");
1497
- }
1498
- }
1499
- ensure() {
1500
- if (!existsSync2(this.stateDir)) {
1501
- mkdirSync(this.stateDir, { recursive: true });
1502
- }
1503
- }
1504
- get dir() {
1505
- return this.stateDir;
1506
- }
1507
- get pidFile() {
1508
- return join(this.stateDir, "daemon.pid");
1509
- }
1510
- get tuiPidFile() {
1511
- return join(this.stateDir, "codex-tui.pid");
1512
- }
1513
- get lockFile() {
1514
- return join(this.stateDir, "daemon.lock");
1515
- }
1516
- get statusFile() {
1517
- return join(this.stateDir, "status.json");
1518
- }
1519
- get portsFile() {
1520
- return join(this.stateDir, "ports.json");
1521
- }
1522
- get logFile() {
1523
- return join(this.stateDir, "agentbridge.log");
1524
- }
1525
- get killedFile() {
1526
- return join(this.stateDir, "killed");
1527
- }
1528
- }
1529
-
1530
1747
  // src/config-service.ts
1531
1748
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
1532
1749
  import { join as join2 } from "path";
1533
1750
  var DEFAULT_CONFIG = {
1534
1751
  version: "1.0",
1535
- daemon: {
1536
- port: 4500,
1752
+ codex: {
1753
+ appPort: 4500,
1537
1754
  proxyPort: 4501
1538
1755
  },
1539
- agents: {
1540
- claude: {
1541
- role: "Reviewer, Planner",
1542
- mode: "push"
1543
- },
1544
- codex: {
1545
- role: "Implementer, Executor"
1546
- }
1547
- },
1548
- markers: ["IMPORTANT", "STATUS", "FYI"],
1549
1756
  turnCoordination: {
1550
- attentionWindowSeconds: 15,
1551
- busyGuard: true
1757
+ attentionWindowSeconds: 15
1552
1758
  },
1553
1759
  idleShutdownSeconds: 30
1554
1760
  };
1555
- var DEFAULT_COLLABORATION_MD = `# Collaboration Rules
1556
-
1557
- ## Roles
1558
- - Claude: Reviewer, Planner, Hypothesis Challenger
1559
- - Codex: Implementer, Executor, Reproducer/Verifier
1560
-
1561
- ## Thinking Patterns
1562
- - Analytical/review tasks: Independent Analysis & Convergence
1563
- - Implementation tasks: Architect -> Builder -> Critic
1564
- - Debugging tasks: Hypothesis -> Experiment -> Interpretation
1565
-
1566
- ## Communication
1567
- - Use explicit phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"
1568
- - Tag messages with [IMPORTANT], [STATUS], or [FYI]
1569
-
1570
- ## Review Process
1571
- - Cross-review: author never reviews their own code
1572
- - All changes go through feature/fix branches + PR
1573
- - Merge via squash merge
1574
-
1575
- ## Custom Rules
1576
- <!-- Add your project-specific collaboration rules here -->
1577
- `;
1578
1761
  var CONFIG_DIR = ".agentbridge";
1579
1762
  var CONFIG_FILE = "config.json";
1580
- 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
+ }
1581
1795
 
1582
1796
  class ConfigService {
1583
1797
  configDir;
1584
1798
  configPath;
1585
- collaborationPath;
1586
1799
  constructor(projectRoot) {
1587
1800
  const root = projectRoot ?? process.cwd();
1588
1801
  this.configDir = join2(root, CONFIG_DIR);
1589
1802
  this.configPath = join2(this.configDir, CONFIG_FILE);
1590
- this.collaborationPath = join2(this.configDir, COLLABORATION_FILE);
1591
1803
  }
1592
1804
  hasConfig() {
1593
1805
  return existsSync3(this.configPath);
@@ -1595,7 +1807,7 @@ class ConfigService {
1595
1807
  load() {
1596
1808
  try {
1597
1809
  const raw = readFileSync2(this.configPath, "utf-8");
1598
- return JSON.parse(raw);
1810
+ return normalizeConfig(JSON.parse(raw));
1599
1811
  } catch {
1600
1812
  return null;
1601
1813
  }
@@ -1608,17 +1820,6 @@ class ConfigService {
1608
1820
  writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
1609
1821
  `, "utf-8");
1610
1822
  }
1611
- loadCollaboration() {
1612
- try {
1613
- return readFileSync2(this.collaborationPath, "utf-8");
1614
- } catch {
1615
- return null;
1616
- }
1617
- }
1618
- saveCollaboration(content) {
1619
- this.ensureConfigDir();
1620
- writeFileSync2(this.collaborationPath, content, "utf-8");
1621
- }
1622
1823
  initDefaults() {
1623
1824
  this.ensureConfigDir();
1624
1825
  const created = [];
@@ -1626,18 +1827,11 @@ class ConfigService {
1626
1827
  this.save(DEFAULT_CONFIG);
1627
1828
  created.push(this.configPath);
1628
1829
  }
1629
- if (!existsSync3(this.collaborationPath)) {
1630
- this.saveCollaboration(DEFAULT_COLLABORATION_MD);
1631
- created.push(this.collaborationPath);
1632
- }
1633
1830
  return created;
1634
1831
  }
1635
1832
  get configFilePath() {
1636
1833
  return this.configPath;
1637
1834
  }
1638
- get collaborationFilePath() {
1639
- return this.collaborationPath;
1640
- }
1641
1835
  ensureConfigDir() {
1642
1836
  if (!existsSync3(this.configDir)) {
1643
1837
  mkdirSync2(this.configDir, { recursive: true });
@@ -1653,8 +1847,8 @@ var stateDir = new StateDirResolver;
1653
1847
  stateDir.ensure();
1654
1848
  var configService = new ConfigService;
1655
1849
  var config = configService.loadOrDefault();
1656
- var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.daemon.port), 10);
1657
- 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);
1658
1852
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
1659
1853
  var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
1660
1854
  var CLAUDE_DISCONNECT_GRACE_MS = 5000;
@@ -1663,7 +1857,7 @@ var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "fil
1663
1857
  var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
1664
1858
  var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
1665
1859
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
1666
- var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT);
1860
+ var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
1667
1861
  var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
1668
1862
  var controlServer = null;
1669
1863
  var attachedClaude = null;
@@ -1679,6 +1873,7 @@ var idleShutdownTimer = null;
1679
1873
  var claudeDisconnectTimer = null;
1680
1874
  var claudeOnlineNoticeSent = false;
1681
1875
  var claudeOfflineNoticeShown = false;
1876
+ var codexCollaborationKickoffSent = false;
1682
1877
  var lastAttachStatusSentTs = 0;
1683
1878
  var ATTACH_STATUS_COOLDOWN_MS = 30000;
1684
1879
  var bufferedMessages = [];
@@ -1745,6 +1940,9 @@ codex.on("turnCompleted", () => {
1745
1940
  replyReceivedDuringTurn = false;
1746
1941
  emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
1747
1942
  startAttentionWindow();
1943
+ if (attachedClaude && shouldNotifyCodexClaudeOnline()) {
1944
+ notifyCodexClaudeOnline();
1945
+ }
1748
1946
  });
1749
1947
  codex.on("ready", (threadId) => {
1750
1948
  tuiConnectionState.markBridgeReady();
@@ -2077,9 +2275,23 @@ function currentReadyMessage() {
2077
2275
  return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
2078
2276
  }
2079
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
+ }
2080
2291
  claudeOnlineNoticeSent = true;
2081
2292
  claudeOfflineNoticeShown = false;
2082
- codex.injectMessage("\u2705 AgentBridge connected to Claude Code.");
2293
+ codexCollaborationKickoffSent = true;
2294
+ return true;
2083
2295
  }
2084
2296
  function shouldNotifyCodexClaudeOnline() {
2085
2297
  return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;