@raysonmeng/agentbridge 0.1.6 → 0.1.7

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.
@@ -1,14 +1,50 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
- // src/daemon.ts
5
- import { appendFileSync as appendFileSync2 } from "fs";
4
+ // src/contract-version.ts
5
+ var CONTRACT_VERSION = 1;
6
+
7
+ // src/build-info.ts
8
+ function defineString(value, fallback) {
9
+ return typeof value === "string" && value.length > 0 ? value : fallback;
10
+ }
11
+ function defineBundle(value) {
12
+ if (value === "source" || value === "dist" || value === "plugin")
13
+ return value;
14
+ return import.meta.url.endsWith(".ts") ? "source" : "dist";
15
+ }
16
+ function defineNumber(value, fallback) {
17
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
18
+ }
19
+ var BUILD_INFO = Object.freeze({
20
+ version: defineString("0.1.7", "0.0.0-source"),
21
+ commit: defineString("1df8b91", "source"),
22
+ bundle: defineBundle("plugin"),
23
+ contractVersion: defineNumber(1, CONTRACT_VERSION)
24
+ });
25
+ function daemonStatusBuildInfo() {
26
+ return { ...BUILD_INFO };
27
+ }
28
+ function sameRuntimeContract(a, b) {
29
+ if (!a || !b)
30
+ return false;
31
+ return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
32
+ }
33
+ function compatibleContractVersion(a, b) {
34
+ if (!a || !b)
35
+ return false;
36
+ return a.contractVersion === b.contractVersion;
37
+ }
38
+ function formatBuildInfo(build) {
39
+ if (!build)
40
+ return "<unknown>";
41
+ return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
42
+ }
6
43
 
7
44
  // src/codex-adapter.ts
8
- import { spawn, execSync } from "child_process";
45
+ import { spawn, execFileSync } from "child_process";
9
46
  import { createInterface } from "readline";
10
47
  import { EventEmitter } from "events";
11
- import { appendFileSync } from "fs";
12
48
 
13
49
  // src/state-dir.ts
14
50
  import { mkdirSync, existsSync } from "fs";
@@ -17,16 +53,16 @@ import { homedir, platform } from "os";
17
53
 
18
54
  class StateDirResolver {
19
55
  stateDir;
56
+ static platformBaseDir() {
57
+ if (platform() === "darwin") {
58
+ return join(homedir(), "Library", "Application Support", "AgentBridge");
59
+ }
60
+ const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
61
+ return join(xdgState, "agentbridge");
62
+ }
20
63
  constructor(envOverride) {
21
64
  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
- }
65
+ this.stateDir = override && override.length > 0 ? override : StateDirResolver.platformBaseDir();
30
66
  }
31
67
  ensure() {
32
68
  if (!existsSync(this.stateDir)) {
@@ -51,6 +87,9 @@ class StateDirResolver {
51
87
  get portsFile() {
52
88
  return join(this.stateDir, "ports.json");
53
89
  }
90
+ get currentThreadFile() {
91
+ return join(this.stateDir, "current-thread.json");
92
+ }
54
93
  get logFile() {
55
94
  return join(this.stateDir, "agentbridge.log");
56
95
  }
@@ -60,6 +99,224 @@ class StateDirResolver {
60
99
  get killedFile() {
61
100
  return join(this.stateDir, "killed");
62
101
  }
102
+ get updateCheckFile() {
103
+ return join(this.stateDir, "update-check.json");
104
+ }
105
+ }
106
+
107
+ // src/port-cleanup.ts
108
+ function portPidsCommand(port, platform2 = process.platform) {
109
+ if (platform2 === "win32") {
110
+ return {
111
+ cmd: "powershell.exe",
112
+ args: [
113
+ "-NoProfile",
114
+ "-Command",
115
+ `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`
116
+ ]
117
+ };
118
+ }
119
+ return { cmd: "lsof", args: ["-ti", `tcp:${port}`, "-sTCP:LISTEN"] };
120
+ }
121
+ function processCommandLineCommand(pid, platform2 = process.platform) {
122
+ if (platform2 === "win32") {
123
+ return {
124
+ cmd: "powershell.exe",
125
+ args: [
126
+ "-NoProfile",
127
+ "-Command",
128
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue; if ($p -and $p.CommandLine) { $p.CommandLine }`
129
+ ]
130
+ };
131
+ }
132
+ return { cmd: "ps", args: ["-p", pid, "-o", "args="] };
133
+ }
134
+ function killPidCommand(pid, platform2 = process.platform) {
135
+ if (platform2 === "win32") {
136
+ return {
137
+ cmd: "powershell.exe",
138
+ args: ["-NoProfile", "-Command", `Stop-Process -Id ${pid} -Force -ErrorAction Stop`]
139
+ };
140
+ }
141
+ return { cmd: "kill", args: [pid] };
142
+ }
143
+ function parsePids(output) {
144
+ const seen = new Set;
145
+ const pids = [];
146
+ for (const line of output.split(/\r?\n/)) {
147
+ const pid = line.trim();
148
+ if (!/^\d+$/.test(pid))
149
+ continue;
150
+ if (pid === "0")
151
+ continue;
152
+ if (seen.has(pid))
153
+ continue;
154
+ seen.add(pid);
155
+ pids.push(pid);
156
+ }
157
+ return pids;
158
+ }
159
+ function isCodexAppServerCommandLine(cmdline, platform2 = process.platform) {
160
+ const s = platform2 === "win32" ? cmdline.toLowerCase() : cmdline;
161
+ return s.includes("codex") && s.includes("app-server");
162
+ }
163
+ async function cleanupPorts(options) {
164
+ const platform2 = options.platform ?? process.platform;
165
+ const listPids = (port) => {
166
+ try {
167
+ return parsePids(options.run(portPidsCommand(port, platform2)));
168
+ } catch {
169
+ return [];
170
+ }
171
+ };
172
+ for (const { port, envVar } of options.ports) {
173
+ const pidList = listPids(port);
174
+ if (pidList.length === 0)
175
+ continue;
176
+ const staleCodexPids = [];
177
+ const foreignPids = [];
178
+ for (const pid of pidList) {
179
+ try {
180
+ const cmdline = options.run(processCommandLineCommand(pid, platform2)).trim();
181
+ if (isCodexAppServerCommandLine(cmdline, platform2)) {
182
+ staleCodexPids.push(pid);
183
+ } else {
184
+ foreignPids.push(pid);
185
+ }
186
+ } catch {}
187
+ }
188
+ if (staleCodexPids.length > 0) {
189
+ options.log(`Cleaning up stale codex app-server on port ${port}: PID(s) ${staleCodexPids.join(", ")}`);
190
+ for (const pid of staleCodexPids) {
191
+ try {
192
+ options.run(killPidCommand(pid, platform2));
193
+ } catch {}
194
+ }
195
+ await options.sleep(500);
196
+ }
197
+ if (foreignPids.length > 0) {
198
+ 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 ${envVar} env var.`);
199
+ }
200
+ const remaining = listPids(port);
201
+ if (remaining.length > 0) {
202
+ throw new Error(`Port ${port} is still occupied (PID(s): ${remaining.join(", ")}) after cleanup. ` + `Please stop the process or set a different port via ${envVar} env var.`);
203
+ }
204
+ }
205
+ }
206
+
207
+ // src/rotating-log.ts
208
+ import { appendFileSync, existsSync as existsSync2, renameSync, statSync, unlinkSync } from "fs";
209
+ import { dirname } from "path";
210
+ var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
211
+ var DEFAULT_KEEP = 3;
212
+ function appendRotatingLog(path, content, options = {}) {
213
+ const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
214
+ const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
215
+ if (!existsSync2(dirname(path)))
216
+ return;
217
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
218
+ appendFileSync(path, content, "utf-8");
219
+ }
220
+ function positiveIntFromEnv(name, fallback) {
221
+ const value = process.env[name];
222
+ if (!value)
223
+ return fallback;
224
+ const parsed = Number(value);
225
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
226
+ }
227
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
228
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
229
+ return;
230
+ if (!existsSync2(path))
231
+ return;
232
+ const size = statSync(path).size;
233
+ if (size + incomingBytes <= maxBytes)
234
+ return;
235
+ for (let index = keep;index >= 1; index--) {
236
+ const current = `${path}.${index}`;
237
+ const next = `${path}.${index + 1}`;
238
+ if (!existsSync2(current))
239
+ continue;
240
+ if (index === keep) {
241
+ unlinkSync(current);
242
+ } else {
243
+ renameSync(current, next);
244
+ }
245
+ }
246
+ renameSync(path, `${path}.1`);
247
+ }
248
+
249
+ // src/process-log.ts
250
+ var stderrStates = new WeakMap;
251
+ function createProcessLogger(options) {
252
+ let fatalInProgress = false;
253
+ const stderr = options.stderr ?? process.stderr;
254
+ const stderrState = stateForStderr(stderr);
255
+ const write = (message) => {
256
+ const line = `[${new Date().toISOString()}] [${options.component}] ${message}
257
+ `;
258
+ if (options.logFile) {
259
+ try {
260
+ appendRotatingLog(options.logFile, line);
261
+ } catch {}
262
+ }
263
+ if (!stderrState.enabled)
264
+ return;
265
+ try {
266
+ stderr.write(line);
267
+ } catch (error) {
268
+ if (error?.code === "EPIPE")
269
+ stderrState.enabled = false;
270
+ }
271
+ };
272
+ return {
273
+ log: write,
274
+ fatal(label, error) {
275
+ if (fatalInProgress)
276
+ return;
277
+ fatalInProgress = true;
278
+ try {
279
+ write(`${label}: ${safeFormatError(error)}`);
280
+ } finally {
281
+ fatalInProgress = false;
282
+ }
283
+ }
284
+ };
285
+ }
286
+ function stateForStderr(stderr) {
287
+ const key = stderr;
288
+ let state = stderrStates.get(key);
289
+ if (state)
290
+ return state;
291
+ state = { enabled: true };
292
+ stderrStates.set(key, state);
293
+ if (typeof stderr.on === "function") {
294
+ stderr.on("error", (error) => {
295
+ if (error?.code === "EPIPE") {
296
+ state.enabled = false;
297
+ return;
298
+ }
299
+ setTimeout(() => {
300
+ throw error;
301
+ }, 0);
302
+ });
303
+ }
304
+ return state;
305
+ }
306
+ function safeFormatError(error) {
307
+ try {
308
+ return formatError(error);
309
+ } catch {
310
+ return "<failed to format error>";
311
+ }
312
+ }
313
+ function formatError(error) {
314
+ if (error instanceof Error)
315
+ return error.stack ?? error.message;
316
+ if (typeof error === "object" && error !== null && "stack" in error) {
317
+ return String(error.stack);
318
+ }
319
+ return String(error);
63
320
  }
64
321
 
65
322
  // src/app-server-protocol.ts
@@ -105,18 +362,263 @@ function isAppServerResponseMessage(value) {
105
362
  return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
106
363
  }
107
364
 
365
+ // src/codex-transport.ts
366
+ import { createServer, connect } from "net";
367
+ import { spawnSync } from "child_process";
368
+ import { mkdirSync as mkdirSync2, rmSync, chmodSync } from "fs";
369
+ import { join as join2 } from "path";
370
+ import { tmpdir } from "os";
371
+ var CODEX_TRANSPORT_ENV = "AGENTBRIDGE_CODEX_TRANSPORT";
372
+ var HEADER_SEP = `\r
373
+ \r
374
+ `;
375
+ var EXTENSIONS_HEADER_RE = /^sec-websocket-extensions:/i;
376
+ var MAX_UPGRADE_HEADER_BYTES = 64 * 1024;
377
+ function parseTransportMode(raw) {
378
+ switch ((raw ?? "").trim().toLowerCase()) {
379
+ case "ws":
380
+ return "ws";
381
+ case "unix":
382
+ return "unix";
383
+ case "auto":
384
+ case "":
385
+ return "auto";
386
+ default:
387
+ return "auto";
388
+ }
389
+ }
390
+ function probeCodexWsSupport(runHelp = defaultRunCodexAppServerHelp) {
391
+ const help = runHelp();
392
+ if (help === null)
393
+ return true;
394
+ return help.includes("ws://");
395
+ }
396
+ function defaultRunCodexAppServerHelp() {
397
+ try {
398
+ const res = spawnSync("codex", ["app-server", "--help"], {
399
+ encoding: "utf-8",
400
+ timeout: 5000
401
+ });
402
+ if (res.error || typeof res.stdout !== "string")
403
+ return null;
404
+ return res.stdout + (res.stderr ?? "");
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+ function resolveCodexTransport(mode, runHelp = defaultRunCodexAppServerHelp) {
410
+ if (mode === "ws")
411
+ return "ws";
412
+ if (mode === "unix")
413
+ return "unix";
414
+ return probeCodexWsSupport(runHelp) ? "ws" : "unix";
415
+ }
416
+ function codexSocketPath(appPort, baseTmpDir = tmpdir()) {
417
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
418
+ const dir = join2(baseTmpDir, `agentbridge-${uid}`);
419
+ const path = join2(dir, `codex-${appPort}.sock`);
420
+ if (path.length >= 104) {
421
+ throw new Error(`Codex unix socket path is too long for the platform (${path.length} >= 104): ${path}. ` + `Set a shorter TMPDIR or use ${CODEX_TRANSPORT_ENV}=ws.`);
422
+ }
423
+ return path;
424
+ }
425
+ function ensureSocketDir(socketPath) {
426
+ const dir = socketPath.slice(0, socketPath.lastIndexOf("/"));
427
+ if (!dir)
428
+ return;
429
+ mkdirSync2(dir, { recursive: true, mode: 448 });
430
+ try {
431
+ chmodSync(dir, 448);
432
+ } catch (err) {
433
+ throw new Error(`Refusing to use Codex socket dir ${dir}: cannot enforce 0700 perms ` + `(${err.message}). Remove it or set a private TMPDIR.`);
434
+ }
435
+ }
436
+ function removeSocketFile(socketPath) {
437
+ try {
438
+ rmSync(socketPath, { force: true });
439
+ } catch {}
440
+ }
441
+ function codexListenArg(transport, appPort, socketPath) {
442
+ return transport === "unix" ? `unix://${socketPath}` : `ws://127.0.0.1:${appPort}`;
443
+ }
444
+ function stripWebSocketExtensions(headerBlock) {
445
+ return headerBlock.split(`\r
446
+ `).filter((line) => !EXTENSIONS_HEADER_RE.test(line)).join(`\r
447
+ `);
448
+ }
449
+
450
+ class TcpToUnixRelay {
451
+ tcpHost;
452
+ tcpPort;
453
+ unixPath;
454
+ log;
455
+ server = null;
456
+ pairs = new Set;
457
+ constructor(tcpHost, tcpPort, unixPath, log = () => {}) {
458
+ this.tcpHost = tcpHost;
459
+ this.tcpPort = tcpPort;
460
+ this.unixPath = unixPath;
461
+ this.log = log;
462
+ }
463
+ start() {
464
+ return new Promise((resolve, reject) => {
465
+ const server = createServer((tcp) => this.handleConnection(tcp));
466
+ const onListenError = (err) => {
467
+ server.removeListener("listening", onListening);
468
+ reject(err);
469
+ };
470
+ const onListening = () => {
471
+ server.removeListener("error", onListenError);
472
+ server.on("error", (err) => this.log(`relay server error: ${err.message}`));
473
+ this.server = server;
474
+ resolve();
475
+ };
476
+ server.once("error", onListenError);
477
+ server.once("listening", onListening);
478
+ server.listen(this.tcpPort, this.tcpHost);
479
+ });
480
+ }
481
+ handleConnection(tcp) {
482
+ const unix = connect(this.unixPath);
483
+ const pair = { tcp, unix };
484
+ this.pairs.add(pair);
485
+ let closed = false;
486
+ const teardown = () => {
487
+ if (closed)
488
+ return;
489
+ closed = true;
490
+ this.pairs.delete(pair);
491
+ tcp.destroy();
492
+ unix.destroy();
493
+ };
494
+ let head = Buffer.alloc(0);
495
+ const onData = (chunk) => {
496
+ head = Buffer.concat([head, chunk]);
497
+ const sep = head.indexOf(HEADER_SEP);
498
+ if (sep === -1) {
499
+ if (head.length > MAX_UPGRADE_HEADER_BYTES) {
500
+ tcp.removeListener("data", onData);
501
+ unix.write(head);
502
+ head = Buffer.alloc(0);
503
+ tcp.pipe(unix);
504
+ }
505
+ return;
506
+ }
507
+ tcp.removeListener("data", onData);
508
+ const headers = head.subarray(0, sep).toString("utf8");
509
+ const rest = head.subarray(sep + HEADER_SEP.length);
510
+ unix.write(stripWebSocketExtensions(headers) + HEADER_SEP);
511
+ head = Buffer.alloc(0);
512
+ if (rest.length)
513
+ tcp.unshift(rest);
514
+ tcp.pipe(unix);
515
+ };
516
+ tcp.on("data", onData);
517
+ unix.pipe(tcp);
518
+ tcp.on("error", (e) => {
519
+ this.log(`relay tcp error: ${e.message}`);
520
+ teardown();
521
+ });
522
+ unix.on("error", (e) => {
523
+ this.log(`relay unix error: ${e.message}`);
524
+ teardown();
525
+ });
526
+ tcp.on("close", teardown);
527
+ unix.on("close", teardown);
528
+ }
529
+ get connectionCount() {
530
+ return this.pairs.size;
531
+ }
532
+ get port() {
533
+ const addr = this.server?.address();
534
+ return addr && typeof addr === "object" ? addr.port : this.tcpPort;
535
+ }
536
+ stop() {
537
+ if (this.server) {
538
+ this.server.close();
539
+ this.server = null;
540
+ }
541
+ for (const { tcp, unix } of this.pairs) {
542
+ tcp.destroy();
543
+ unix.destroy();
544
+ }
545
+ this.pairs.clear();
546
+ }
547
+ }
548
+ async function waitForUnixWsReady(socketPath, maxRetries = 40, delayMs = 250) {
549
+ for (let i = 0;i < maxRetries; i++) {
550
+ if (await attemptUnixWsUpgrade(socketPath))
551
+ return;
552
+ await new Promise((r) => setTimeout(r, delayMs));
553
+ }
554
+ throw new Error(`Codex unix app-server at ${socketPath} did not become ready`);
555
+ }
556
+ function attemptUnixWsUpgrade(socketPath) {
557
+ return new Promise((resolve) => {
558
+ let settled = false;
559
+ const done = (ok) => {
560
+ if (settled)
561
+ return;
562
+ settled = true;
563
+ try {
564
+ socket.destroy();
565
+ } catch {}
566
+ resolve(ok);
567
+ };
568
+ const socket = connect(socketPath, () => {
569
+ socket.write(`GET / HTTP/1.1\r
570
+ Host: localhost\r
571
+ Upgrade: websocket\r
572
+ Connection: Upgrade\r
573
+ ` + `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r
574
+ Sec-WebSocket-Version: 13\r
575
+ \r
576
+ `);
577
+ });
578
+ let buf = "";
579
+ socket.on("data", (d) => {
580
+ buf += d.toString("utf8");
581
+ if (buf.includes(`\r
582
+ `))
583
+ done(buf.startsWith("HTTP/1.1 101"));
584
+ });
585
+ socket.on("error", () => done(false));
586
+ socket.on("close", () => done(false));
587
+ setTimeout(() => done(false), 1500);
588
+ });
589
+ }
590
+
591
+ // src/turn-notices.ts
592
+ var ADAPTER_DISCONNECT_REASON = "adapter disconnect";
593
+ var APP_SERVER_RECONNECT_NEW_TUI_REASON = "app-server reconnect for new TUI session";
594
+ var SILENT_ABORT_REASONS = new Set([
595
+ ADAPTER_DISCONNECT_REASON,
596
+ APP_SERVER_RECONNECT_NEW_TUI_REASON
597
+ ]);
598
+ function buildTurnAbortedNotice(reason, replyWasRequired) {
599
+ if (SILENT_ABORT_REASONS.has(reason))
600
+ return null;
601
+ const tail = replyWasRequired ? " A reply you were waiting on will NOT arrive \u2014 retry your last message, or wait for the Codex TUI to reconnect." : " If you were waiting on a reply it will not arrive; retry, or wait for the Codex TUI to reconnect.";
602
+ return `\u26A0\uFE0F Codex's current turn ended without completing (${reason}). ` + "This usually means Codex hit an error (e.g. a rate limit / 429), the app-server connection dropped, or the turn was interrupted." + tail;
603
+ }
604
+
108
605
  // src/codex-adapter.ts
109
606
  class CodexAdapter extends EventEmitter {
110
607
  static RESPONSE_TRACKING_TTL_MS = 30000;
111
608
  proc = null;
609
+ appServerPid = null;
112
610
  appServerWs = null;
113
611
  tuiWs = null;
114
612
  proxyServer = null;
613
+ transport = "ws";
614
+ socketPath = null;
615
+ relay = null;
115
616
  threadId = null;
116
617
  nextInjectionId = -1;
117
618
  appPort;
118
619
  proxyPort;
119
620
  logFile;
621
+ logger;
120
622
  tuiConnId = 0;
121
623
  connIdCounter = 0;
122
624
  secondaryConnections = new Map;
@@ -124,6 +626,12 @@ class CodexAdapter extends EventEmitter {
124
626
  pendingRequests = new Map;
125
627
  activeTurnIds = new Set;
126
628
  turnInProgress = false;
629
+ turnWatchdogs = new Map;
630
+ stalledTurnIds = new Set;
631
+ currentlyStalledTurnIds = new Set;
632
+ lastTurnEndedAbnormally = false;
633
+ lastEmittedPhase = "idle";
634
+ threadSwitchSeq = 0;
127
635
  nextProxyId = 1e5;
128
636
  upstreamToClient = new Map;
129
637
  serverRequestToProxy = new Map;
@@ -139,7 +647,7 @@ class CodexAdapter extends EventEmitter {
139
647
  outageQueue = [];
140
648
  outageTimer = null;
141
649
  static OUTAGE_QUEUE_MAX = 64;
142
- static OUTAGE_TIMEOUT_MS = 5000;
650
+ static OUTAGE_TIMEOUT_MS = 1e4;
143
651
  lastInitializeRaw = null;
144
652
  lastInitializedRaw = null;
145
653
  sessionRestoreInProgress = false;
@@ -150,6 +658,7 @@ class CodexAdapter extends EventEmitter {
150
658
  this.appPort = appPort;
151
659
  this.proxyPort = proxyPort;
152
660
  this.logFile = logFile;
661
+ this.logger = createProcessLogger({ component: "CodexAdapter", logFile: this.logFile });
153
662
  }
154
663
  get appServerUrl() {
155
664
  return `ws://127.0.0.1:${this.appPort}`;
@@ -163,21 +672,44 @@ class CodexAdapter extends EventEmitter {
163
672
  async start() {
164
673
  this.intentionalDisconnect = false;
165
674
  await this.checkPorts();
166
- this.log(`Spawning codex app-server on ${this.appServerUrl}`);
167
- this.proc = spawn("codex", ["app-server", "--listen", this.appServerUrl], {
675
+ this.resolveTransport();
676
+ const listen = codexListenArg(this.transport, this.appPort, this.socketPath ?? "");
677
+ if (this.transport === "unix" && this.socketPath) {
678
+ ensureSocketDir(this.socketPath);
679
+ removeSocketFile(this.socketPath);
680
+ }
681
+ this.log(`Spawning codex app-server (transport=${this.transport}) --listen ${listen}`);
682
+ this.proc = spawn("codex", ["app-server", "--listen", listen], {
168
683
  stdio: ["pipe", "pipe", "pipe"]
169
684
  });
685
+ this.appServerPid = this.proc.pid ?? null;
170
686
  this.proc.on("error", (err) => this.emit("error", err));
171
- this.proc.on("exit", (code) => this.emit("exit", code));
687
+ this.proc.on("exit", (code) => {
688
+ this.appServerPid = null;
689
+ this.emit("exit", code);
690
+ });
172
691
  const stderrRl = createInterface({ input: this.proc.stderr });
173
692
  stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
174
693
  const stdoutRl = createInterface({ input: this.proc.stdout });
175
694
  stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
176
- await this.waitForHealthy();
695
+ if (this.transport === "unix" && this.socketPath) {
696
+ await waitForUnixWsReady(this.socketPath);
697
+ this.relay = new TcpToUnixRelay("127.0.0.1", this.appPort, this.socketPath, (m) => this.log(`[relay] ${m}`));
698
+ await this.relay.start();
699
+ this.log(`Transport relay ready: ws://127.0.0.1:${this.appPort} \u2192 unix://${this.socketPath}`);
700
+ } else {
701
+ await this.waitForHealthy();
702
+ }
177
703
  await this.connectToAppServer();
178
704
  this.startProxy();
179
705
  this.log(`Proxy ready on ${this.proxyUrl}`);
180
706
  }
707
+ resolveTransport() {
708
+ const mode = parseTransportMode(process.env[CODEX_TRANSPORT_ENV]);
709
+ this.transport = resolveCodexTransport(mode);
710
+ this.socketPath = this.transport === "unix" ? codexSocketPath(this.appPort) : null;
711
+ this.log(`Codex transport mode=${mode} resolved=${this.transport}`);
712
+ }
181
713
  disconnect() {
182
714
  this.intentionalDisconnect = true;
183
715
  if (this.reconnectTimer) {
@@ -196,7 +728,14 @@ class CodexAdapter extends EventEmitter {
196
728
  }
197
729
  this.proxyServer?.stop();
198
730
  this.proxyServer = null;
731
+ if (this.relay) {
732
+ this.relay.stop();
733
+ this.relay = null;
734
+ }
735
+ if (this.socketPath)
736
+ removeSocketFile(this.socketPath);
199
737
  this.clearResponseTrackingState();
738
+ this.resetTurnState(ADAPTER_DISCONNECT_REASON);
200
739
  }
201
740
  stop() {
202
741
  this.intentionalDisconnect = true;
@@ -213,7 +752,15 @@ class CodexAdapter extends EventEmitter {
213
752
  proc.on("exit", () => clearTimeout(killTimer));
214
753
  }
215
754
  }
216
- injectMessage(text) {
755
+ forceKillAppServerSync() {
756
+ const pid = this.appServerPid;
757
+ if (pid === null)
758
+ return;
759
+ try {
760
+ process.kill(pid, "SIGKILL");
761
+ } catch {}
762
+ }
763
+ injectMessage(text, overrides) {
217
764
  if (!this.threadId) {
218
765
  this.log("Cannot inject: no active thread");
219
766
  return false;
@@ -229,11 +776,19 @@ class CodexAdapter extends EventEmitter {
229
776
  this.log(`Injecting message into Codex (${text.length} chars)`);
230
777
  const requestId = this.nextInjectionId--;
231
778
  this.trackBridgeRequestId(requestId);
779
+ const params = { threadId: this.threadId, input: [{ type: "text", text }] };
780
+ if (overrides?.model)
781
+ params.model = overrides.model;
782
+ if (overrides?.effort)
783
+ params.effort = overrides.effort;
784
+ if (overrides?.model || overrides?.effort) {
785
+ this.log(`Budget tier override on turn/start (model=${overrides.model ?? "unchanged"}, effort=${overrides.effort ?? "unchanged"}) \u2014 sticky for subsequent turns; transport-accepted unless a JSON-RPC error follows`);
786
+ }
232
787
  try {
233
788
  this.appServerWs.send(JSON.stringify({
234
789
  method: "turn/start",
235
790
  id: requestId,
236
- params: { threadId: this.threadId, input: [{ type: "text", text }] }
791
+ params
237
792
  }));
238
793
  return true;
239
794
  } catch (err) {
@@ -323,8 +878,7 @@ class CodexAdapter extends EventEmitter {
323
878
  } catch {}
324
879
  }
325
880
  this.clearResponseTrackingStateForAppServerReconnect();
326
- this.activeTurnIds.clear();
327
- this.turnInProgress = false;
881
+ this.resetTurnState(APP_SERVER_RECONNECT_NEW_TUI_REASON);
328
882
  try {
329
883
  await this.connectToAppServer(false);
330
884
  this.log("App-server reconnected for new TUI session \u2014 replaying buffered messages");
@@ -378,8 +932,7 @@ class CodexAdapter extends EventEmitter {
378
932
  this.log(`App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`);
379
933
  this.appServerWs = null;
380
934
  this.clearResponseTrackingState();
381
- this.activeTurnIds.clear();
382
- this.turnInProgress = false;
935
+ this.resetTurnState("app-server connection closed");
383
936
  if (!intentional) {
384
937
  this.scheduleReconnect();
385
938
  }
@@ -552,6 +1105,10 @@ class CodexAdapter extends EventEmitter {
552
1105
  const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
553
1106
  self.log(`HTTP ${req.method} ${url.pathname} (upgrade=${isUpgrade})`);
554
1107
  if (url.pathname === "/healthz" || url.pathname === "/readyz") {
1108
+ if (self.transport === "unix") {
1109
+ const up = self.appServerWs?.readyState === WebSocket.OPEN;
1110
+ return new Response(up ? "ok" : "upstream not connected", { status: up ? 200 : 503 });
1111
+ }
555
1112
  return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
556
1113
  }
557
1114
  if (server.upgrade(req, { data: { connId: 0 } }))
@@ -589,7 +1146,13 @@ class CodexAdapter extends EventEmitter {
589
1146
  }
590
1147
  setupSecondaryConnection(ws, connId) {
591
1148
  const appWs = new WebSocket(this.appServerUrl);
592
- const entry = { tuiWs: ws, appServerWs: appWs, buffer: [] };
1149
+ const entry = {
1150
+ tuiWs: ws,
1151
+ appServerWs: appWs,
1152
+ buffer: [],
1153
+ initialized: false,
1154
+ initializationReplayed: false
1155
+ };
593
1156
  this.secondaryConnections.set(connId, entry);
594
1157
  appWs.onopen = () => {
595
1158
  if (!this.secondaryConnections.has(connId)) {
@@ -711,13 +1274,13 @@ class CodexAdapter extends EventEmitter {
711
1274
  const connId = ws.data.connId;
712
1275
  const secondary = this.secondaryConnections.get(connId);
713
1276
  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);
1277
+ const method = this.detectJsonMethod(data);
1278
+ if (method === "initialize" || method === "initialized") {
1279
+ secondary.initialized = true;
1280
+ } else if (!secondary.initialized) {
1281
+ this.ensureSecondaryInitialized(secondary, connId);
720
1282
  }
1283
+ this.sendOrBufferSecondary(secondary, data);
721
1284
  return;
722
1285
  }
723
1286
  if (connId !== this.tuiConnId) {
@@ -808,9 +1371,44 @@ class CodexAdapter extends EventEmitter {
808
1371
  this.log(`WARNING: app-server closed between OPEN check and send \u2014 message lost (connId=${ws.data.connId})`);
809
1372
  }
810
1373
  }
1374
+ detectJsonMethod(raw) {
1375
+ try {
1376
+ const parsed = JSON.parse(raw);
1377
+ return typeof parsed?.method === "string" ? parsed.method : undefined;
1378
+ } catch {
1379
+ return;
1380
+ }
1381
+ }
1382
+ ensureSecondaryInitialized(secondary, connId) {
1383
+ if (secondary.initializationReplayed)
1384
+ return;
1385
+ secondary.initializationReplayed = true;
1386
+ if (!this.lastInitializeRaw) {
1387
+ this.log(`Secondary conn #${connId}: no cached initialize available before first non-initialize request`);
1388
+ return;
1389
+ }
1390
+ this.log(`Secondary conn #${connId}: replaying cached initialize before picker request`);
1391
+ this.sendOrBufferSecondary(secondary, this.lastInitializeRaw);
1392
+ if (this.lastInitializedRaw) {
1393
+ this.sendOrBufferSecondary(secondary, this.lastInitializedRaw);
1394
+ }
1395
+ secondary.initialized = true;
1396
+ }
1397
+ sendOrBufferSecondary(secondary, raw) {
1398
+ if (secondary.appServerWs && secondary.appServerWs.readyState === WebSocket.OPEN) {
1399
+ try {
1400
+ secondary.appServerWs.send(raw);
1401
+ } catch {}
1402
+ } else {
1403
+ secondary.buffer.push(raw);
1404
+ }
1405
+ }
811
1406
  handleAppServerPayload(raw) {
812
1407
  try {
813
1408
  const parsed = JSON.parse(raw);
1409
+ if (typeof parsed === "object" && parsed !== null) {
1410
+ this.refreshTurnWatchdogs();
1411
+ }
814
1412
  if (typeof parsed === "object" && parsed !== null && "id" in parsed) {
815
1413
  if (this.tryConsumeReplayResponse(parsed)) {
816
1414
  return null;
@@ -889,7 +1487,7 @@ class CodexAdapter extends EventEmitter {
889
1487
  timestamp: Date.now()
890
1488
  });
891
1489
  this.serverRequestToProxy.delete(proxyId);
892
- this.log(`Buffered approval response until app-server reconnect (${reason}) (proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
1490
+ this.log(`Approval response could not reach the app-server (${reason}) \u2014 buffered best-effort, but it is ` + `likely lost (session-scoped id; reconnects clear this buffer). The TUI may need to re-approve. ` + `(proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
893
1491
  }
894
1492
  flushPendingServerResponses() {
895
1493
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
@@ -924,6 +1522,9 @@ class CodexAdapter extends EventEmitter {
924
1522
  if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
925
1523
  if (parsed.error) {
926
1524
  this.log(`Bridge-originated request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1525
+ this.lastTurnEndedAbnormally = true;
1526
+ this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1527
+ this.notifyPhaseIfChanged();
927
1528
  } else {
928
1529
  this.log(`Bridge-originated request completed (id ${responseId})`);
929
1530
  }
@@ -1039,6 +1640,9 @@ class CodexAdapter extends EventEmitter {
1039
1640
  pending.threadId = threadId;
1040
1641
  }
1041
1642
  }
1643
+ if (method === "thread/start" || method === "thread/resume") {
1644
+ pending.threadSwitchSeq = ++this.threadSwitchSeq;
1645
+ }
1042
1646
  if (this.pendingRequests.has(key)) {
1043
1647
  this.log(`WARNING: overwriting pending request for key ${key}`);
1044
1648
  }
@@ -1062,6 +1666,10 @@ class CodexAdapter extends EventEmitter {
1062
1666
  }
1063
1667
  switch (pending.method) {
1064
1668
  case "thread/start": {
1669
+ if (!this.isLatestThreadSwitch(pending)) {
1670
+ this.log(`Ignoring stale thread/start response ${key} (seq=${pending.threadSwitchSeq} < latest=${this.threadSwitchSeq})`);
1671
+ break;
1672
+ }
1065
1673
  const threadId = message?.result?.thread?.id;
1066
1674
  if (typeof threadId === "string" && threadId.length > 0) {
1067
1675
  this.setActiveThreadId(threadId, `thread/start response ${key}`);
@@ -1070,6 +1678,10 @@ class CodexAdapter extends EventEmitter {
1070
1678
  break;
1071
1679
  }
1072
1680
  case "thread/resume": {
1681
+ if (!this.isLatestThreadSwitch(pending)) {
1682
+ this.log(`Ignoring stale thread/resume response ${key} (seq=${pending.threadSwitchSeq} < latest=${this.threadSwitchSeq})`);
1683
+ break;
1684
+ }
1073
1685
  const threadId = message?.result?.thread?.id;
1074
1686
  if (typeof threadId === "string" && threadId.length > 0) {
1075
1687
  this.setActiveThreadId(threadId, `thread/resume response ${key}`);
@@ -1082,16 +1694,24 @@ class CodexAdapter extends EventEmitter {
1082
1694
  }
1083
1695
  case "turn/start":
1084
1696
  if (pending.threadId) {
1085
- this.setActiveThreadId(pending.threadId, `turn/start response ${key}`);
1697
+ if (this.threadId === null || this.threadId === pending.threadId) {
1698
+ this.setActiveThreadId(pending.threadId, `turn/start response ${key}`);
1699
+ } else {
1700
+ this.log(`Ignoring turn/start response ${key} threadId=${pending.threadId} (active thread is ${this.threadId})`);
1701
+ }
1086
1702
  }
1087
1703
  break;
1088
1704
  }
1089
1705
  }
1706
+ isLatestThreadSwitch(pending) {
1707
+ return pending.threadSwitchSeq === this.threadSwitchSeq;
1708
+ }
1090
1709
  setActiveThreadId(threadId, reason) {
1091
1710
  if (this.threadId === threadId)
1092
1711
  return;
1093
1712
  const previousThreadId = this.threadId;
1094
1713
  this.threadId = threadId;
1714
+ this.emit("threadChanged", { threadId, previousThreadId, reason });
1095
1715
  if (previousThreadId) {
1096
1716
  this.log(`Active thread changed: ${previousThreadId} \u2192 ${threadId} (${reason})`);
1097
1717
  return;
@@ -1099,25 +1719,118 @@ class CodexAdapter extends EventEmitter {
1099
1719
  this.log(`Thread detected: ${threadId} (${reason})`);
1100
1720
  this.emit("ready", threadId);
1101
1721
  }
1722
+ get turnPhase() {
1723
+ if (this.activeTurnIds.size > 0) {
1724
+ const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
1725
+ return allStalled ? "stalled" : "running";
1726
+ }
1727
+ return this.lastTurnEndedAbnormally ? "aborted" : "idle";
1728
+ }
1729
+ notifyPhaseIfChanged() {
1730
+ const phase = this.turnPhase;
1731
+ if (phase === this.lastEmittedPhase)
1732
+ return;
1733
+ const previous = this.lastEmittedPhase;
1734
+ this.lastEmittedPhase = phase;
1735
+ this.emit("turnPhaseChanged", { phase, previous });
1736
+ }
1102
1737
  markTurnStarted(turnId) {
1103
1738
  const wasInProgress = this.turnInProgress;
1104
- if (typeof turnId === "string" && turnId.length > 0) {
1105
- this.activeTurnIds.add(turnId);
1106
- } else {
1107
- this.activeTurnIds.add(`unknown:${Date.now()}`);
1108
- }
1739
+ const turnKey = typeof turnId === "string" && turnId.length > 0 ? turnId : `unknown:${Date.now()}`;
1740
+ this.activeTurnIds.add(turnKey);
1741
+ this.stalledTurnIds.delete(turnKey);
1742
+ this.currentlyStalledTurnIds.delete(turnKey);
1743
+ this.lastTurnEndedAbnormally = false;
1744
+ this.scheduleTurnWatchdog(turnKey);
1109
1745
  this.turnInProgress = this.activeTurnIds.size > 0;
1110
1746
  if (!wasInProgress && this.turnInProgress) {
1111
1747
  this.emit("turnStarted");
1112
1748
  }
1749
+ this.notifyPhaseIfChanged();
1113
1750
  }
1114
1751
  markTurnCompleted(turnId) {
1115
1752
  if (typeof turnId === "string" && turnId.length > 0) {
1116
1753
  this.activeTurnIds.delete(turnId);
1754
+ this.clearTurnWatchdog(turnId);
1755
+ this.stalledTurnIds.delete(turnId);
1756
+ this.currentlyStalledTurnIds.delete(turnId);
1117
1757
  } else {
1118
1758
  this.activeTurnIds.clear();
1759
+ this.clearAllTurnWatchdogs();
1760
+ this.stalledTurnIds.clear();
1761
+ this.currentlyStalledTurnIds.clear();
1119
1762
  }
1763
+ this.lastTurnEndedAbnormally = false;
1120
1764
  this.turnInProgress = this.activeTurnIds.size > 0;
1765
+ this.notifyPhaseIfChanged();
1766
+ }
1767
+ turnWatchdogMs() {
1768
+ const v = Number(process.env.AGENTBRIDGE_TURN_WATCHDOG_MS);
1769
+ return Number.isFinite(v) && v > 0 ? v : 300000;
1770
+ }
1771
+ scheduleTurnWatchdog(turnKey) {
1772
+ this.clearTurnWatchdog(turnKey);
1773
+ const timer = setTimeout(() => {
1774
+ if (!this.activeTurnIds.has(turnKey))
1775
+ return;
1776
+ this.log(`WARNING: turn ${turnKey} watchdog fired after ${this.turnWatchdogMs()}ms of inactivity \u2014 ` + `marking stalled but keeping Codex busy until a real completion or reconnect`);
1777
+ this.markTurnStalled(turnKey);
1778
+ }, this.turnWatchdogMs());
1779
+ timer.unref?.();
1780
+ this.turnWatchdogs.set(turnKey, timer);
1781
+ }
1782
+ clearTurnWatchdog(turnKey) {
1783
+ const timer = this.turnWatchdogs.get(turnKey);
1784
+ if (timer) {
1785
+ clearTimeout(timer);
1786
+ this.turnWatchdogs.delete(turnKey);
1787
+ }
1788
+ }
1789
+ clearAllTurnWatchdogs() {
1790
+ for (const timer of this.turnWatchdogs.values())
1791
+ clearTimeout(timer);
1792
+ this.turnWatchdogs.clear();
1793
+ }
1794
+ refreshTurnWatchdogs() {
1795
+ if (this.turnWatchdogs.size === 0)
1796
+ return;
1797
+ for (const turnKey of [...this.turnWatchdogs.keys()]) {
1798
+ this.scheduleTurnWatchdog(turnKey);
1799
+ }
1800
+ this.currentlyStalledTurnIds.clear();
1801
+ this.notifyPhaseIfChanged();
1802
+ }
1803
+ markTurnStalled(turnKey) {
1804
+ if (!this.activeTurnIds.has(turnKey))
1805
+ return;
1806
+ this.turnInProgress = true;
1807
+ this.currentlyStalledTurnIds.add(turnKey);
1808
+ this.notifyPhaseIfChanged();
1809
+ if (this.stalledTurnIds.has(turnKey))
1810
+ return;
1811
+ this.stalledTurnIds.add(turnKey);
1812
+ this.emit("turnStalled", {
1813
+ turnId: turnKey,
1814
+ inactivityMs: this.turnWatchdogMs()
1815
+ });
1816
+ }
1817
+ resetTurnState(reason, emitCompleted = false) {
1818
+ const wasInProgress = this.turnInProgress;
1819
+ this.activeTurnIds.clear();
1820
+ this.clearAllTurnWatchdogs();
1821
+ this.stalledTurnIds.clear();
1822
+ this.currentlyStalledTurnIds.clear();
1823
+ this.turnInProgress = false;
1824
+ if (wasInProgress) {
1825
+ this.lastTurnEndedAbnormally = !emitCompleted;
1826
+ if (emitCompleted) {
1827
+ this.emit("turnCompleted");
1828
+ } else {
1829
+ this.emit("turnAborted", reason);
1830
+ }
1831
+ this.log(`Turn state reset (${reason})`);
1832
+ }
1833
+ this.notifyPhaseIfChanged();
1121
1834
  }
1122
1835
  requestKey(id) {
1123
1836
  if (typeof id === "number" || typeof id === "string")
@@ -1219,75 +1932,61 @@ class CodexAdapter extends EventEmitter {
1219
1932
  this.pendingServerResponses.clear();
1220
1933
  }
1221
1934
  static buildPortListenLsofCommand(port) {
1222
- return `lsof -ti tcp:${port} -sTCP:LISTEN`;
1935
+ const { cmd, args } = portPidsCommand(port, "linux");
1936
+ return [cmd, ...args].join(" ");
1223
1937
  }
1224
1938
  async checkPorts() {
1225
- for (const port of [this.appPort, this.proxyPort]) {
1226
- try {
1227
- const pids = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
1228
- encoding: "utf-8"
1229
- }).trim();
1230
- if (!pids)
1231
- continue;
1232
- const pidList = pids.split(`
1233
- `).map((p) => p.trim()).filter(Boolean);
1234
- const staleCodexPids = [];
1235
- const foreignPids = [];
1236
- for (const pid of pidList) {
1237
- try {
1238
- const cmdline = execSync(`ps -p ${pid} -o args=`, { encoding: "utf-8" }).trim();
1239
- if (cmdline.includes("codex") && cmdline.includes("app-server")) {
1240
- staleCodexPids.push(pid);
1241
- } else {
1242
- foreignPids.push(pid);
1243
- }
1244
- } catch {}
1245
- }
1246
- if (staleCodexPids.length > 0) {
1247
- this.log(`Cleaning up stale codex app-server on port ${port}: PID(s) ${staleCodexPids.join(", ")}`);
1248
- for (const pid of staleCodexPids) {
1249
- try {
1250
- execSync(`kill ${pid}`, { encoding: "utf-8" });
1251
- } catch {}
1252
- }
1253
- await new Promise((r) => setTimeout(r, 500));
1254
- }
1255
- if (foreignPids.length > 0) {
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.`);
1257
- }
1258
- try {
1259
- const remaining = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
1260
- encoding: "utf-8"
1261
- }).trim();
1262
- if (remaining) {
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.`);
1264
- }
1265
- } catch (err) {
1266
- if (err.message?.includes("Port"))
1267
- throw err;
1268
- }
1269
- } catch (err) {
1270
- if (err.message?.includes("Port") || err.message?.includes("non-Codex"))
1271
- throw err;
1272
- }
1273
- }
1939
+ await cleanupPorts({
1940
+ ports: [
1941
+ { port: this.appPort, envVar: "CODEX_WS_PORT" },
1942
+ { port: this.proxyPort, envVar: "CODEX_PROXY_PORT" }
1943
+ ],
1944
+ run: ({ cmd, args }) => execFileSync(cmd, args, { encoding: "utf-8" }),
1945
+ log: (message) => this.log(message),
1946
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms))
1947
+ });
1274
1948
  }
1275
1949
  log(msg) {
1276
- const line = `[${new Date().toISOString()}] [CodexAdapter] ${msg}
1277
- `;
1278
- process.stderr.write(line);
1279
- try {
1280
- appendFileSync(this.logFile, line);
1281
- } catch {}
1950
+ this.logger.log(msg);
1282
1951
  }
1283
1952
  }
1284
1953
 
1285
- // src/message-filter.ts
1286
- var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
1287
- function parseMarker(content) {
1288
- const match = content.match(MARKER_REGEX);
1289
- if (!match)
1290
- return { marker: "untagged", body: content };
1954
+ // src/control-protocol.ts
1955
+ var CLOSE_CODE_REPLACED = 4001;
1956
+ var CLOSE_CODE_EVICTED_STALE = 4002;
1957
+ var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
1958
+ var CLOSE_CODE_PAIR_MISMATCH = 4004;
1959
+
1960
+ // src/daemon-identity.ts
1961
+ function validateClaudeClientIdentity(input) {
1962
+ if (!input.expectedPairId)
1963
+ return { ok: true };
1964
+ if (!input.identity) {
1965
+ return input.allowIdentityless ? { ok: true } : { ok: false, closeCode: CLOSE_CODE_PAIR_MISMATCH, reason: "missing client identity" };
1966
+ }
1967
+ if (input.identity.pairId !== input.expectedPairId) {
1968
+ return {
1969
+ ok: false,
1970
+ closeCode: CLOSE_CODE_PAIR_MISMATCH,
1971
+ reason: `pair mismatch: expected ${input.expectedPairId}, got ${input.identity.pairId ?? "<none>"}`
1972
+ };
1973
+ }
1974
+ if (!input.identity.cwd || input.identity.cwd !== input.daemonCwd) {
1975
+ return {
1976
+ ok: false,
1977
+ closeCode: CLOSE_CODE_PAIR_MISMATCH,
1978
+ reason: `cwd mismatch: expected ${input.daemonCwd}, got ${input.identity.cwd ?? "<none>"}`
1979
+ };
1980
+ }
1981
+ return { ok: true };
1982
+ }
1983
+
1984
+ // src/message-filter.ts
1985
+ var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
1986
+ function parseMarker(content) {
1987
+ const match = content.match(MARKER_REGEX);
1988
+ if (!match)
1989
+ return { marker: "untagged", body: content };
1291
1990
  return {
1292
1991
  marker: match[1].toLowerCase(),
1293
1992
  body: content.slice(match[0].length)
@@ -1308,27 +2007,6 @@ function classifyMessage(content, mode) {
1308
2007
  return { action: "forward", marker };
1309
2008
  }
1310
2009
  }
1311
- var BRIDGE_CONTRACT_REMINDER = `[Bridge Contract] When sending agentMessage, put the marker at the very start of the message:
1312
- - [IMPORTANT] for decisions, reviews, completions, blockers
1313
- - [STATUS] for progress updates
1314
- - [FYI] for background context
1315
- The marker MUST be the first text in the message (e.g. "[IMPORTANT] Task done", not "Task done [IMPORTANT]").
1316
- Keep agentMessage for high-value communication only.
1317
-
1318
- [Git Operations \u2014 FORBIDDEN]
1319
- You MUST NOT execute any git write commands. This includes but is not limited to:
1320
- git commit, git push, git pull, git fetch, git checkout -b, git branch, git merge, git rebase, git cherry-pick, git tag, git stash.
1321
- These commands write to the .git directory, which is blocked by your sandbox. Attempting them will cause your session to hang indefinitely.
1322
- Read-only git commands (git status, git log, git diff, git show, git rev-parse) are allowed.
1323
- All git write operations must be delegated to Claude Code via agentMessage. Report what you changed and let Claude handle branching, committing, and pushing.
1324
-
1325
- [Role Guidance for Codex]
1326
- - Your default role: Implementer, Executor, Verifier
1327
- - Analytical/review tasks: Independent Analysis & Convergence
1328
- - Implementation tasks: Architect -> Builder -> Critic
1329
- - Debugging tasks: Hypothesis -> Experiment -> Interpretation
1330
- - Do not blindly follow Claude - challenge with evidence when you disagree
1331
- - Use explicit collaboration phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"`;
1332
2010
  var REPLY_REQUIRED_INSTRUCTION = `
1333
2011
 
1334
2012
  [\u26A0\uFE0F REPLY REQUIRED] Claude has explicitly requested a reply. You MUST send an agentMessage with [IMPORTANT] marker containing your response. This is a mandatory requirement \u2014 do not skip or use [STATUS]/[FYI] markers for this reply.`;
@@ -1484,11 +2162,31 @@ class TuiConnectionState {
1484
2162
  }
1485
2163
 
1486
2164
  // src/daemon-lifecycle.ts
1487
- import { spawn as spawn2, execFileSync } from "child_process";
1488
- import { existsSync as existsSync2, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
2165
+ import { spawn as spawn2, execFileSync as execFileSync2 } from "child_process";
2166
+ import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
1489
2167
  import { fileURLToPath } from "url";
1490
- var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY ?? "./daemon.ts";
2168
+
2169
+ // src/env-utils.ts
2170
+ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
2171
+ const raw = env[name];
2172
+ if (raw == null || raw === "")
2173
+ return fallback;
2174
+ const parsed = Number(raw);
2175
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
2176
+ log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
2177
+ return fallback;
2178
+ }
2179
+ return parsed;
2180
+ }
2181
+
2182
+ // src/daemon-lifecycle.ts
2183
+ var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
2184
+ var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
1491
2185
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
2186
+ var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
2187
+ var REUSE_READY_DELAY_MS = 250;
2188
+ var HEALTH_FETCH_TIMEOUT_MS = 500;
2189
+ var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
1492
2190
 
1493
2191
  class DaemonLifecycle {
1494
2192
  stateDir;
@@ -1508,42 +2206,120 @@ class DaemonLifecycle {
1508
2206
  get controlWsUrl() {
1509
2207
  return `ws://127.0.0.1:${this.controlPort}/ws`;
1510
2208
  }
2209
+ get expectedPairId() {
2210
+ return process.env.AGENTBRIDGE_PAIR_ID || null;
2211
+ }
2212
+ async fetchStatus() {
2213
+ try {
2214
+ const response = await fetchWithTimeout(this.healthUrl);
2215
+ if (!response.ok)
2216
+ return null;
2217
+ return await response.json();
2218
+ } catch {
2219
+ return null;
2220
+ }
2221
+ }
2222
+ isForeignDaemon(status) {
2223
+ const expected = this.expectedPairId;
2224
+ if (!expected)
2225
+ return false;
2226
+ if (!status)
2227
+ return false;
2228
+ const reported = status.pairId;
2229
+ if (reported == null)
2230
+ return true;
2231
+ return reported !== expected;
2232
+ }
2233
+ isRegisteredPairDaemonInManualMode(status) {
2234
+ return !this.expectedPairId && status?.pairId != null;
2235
+ }
2236
+ isBuildDrifted(status) {
2237
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
2238
+ return false;
2239
+ const runtime = status?.build;
2240
+ if (!runtime)
2241
+ return true;
2242
+ return !sameRuntimeContract(runtime, BUILD_INFO);
2243
+ }
2244
+ canReuseDespiteDrift(status) {
2245
+ if (!compatibleContractVersion(status?.build, BUILD_INFO))
2246
+ return false;
2247
+ return status?.tuiConnected === true;
2248
+ }
1511
2249
  async ensureRunning() {
1512
2250
  if (await this.isHealthy()) {
1513
- await this.waitForReady();
1514
- return;
2251
+ const status = await this.fetchStatus();
2252
+ if (this.isRegisteredPairDaemonInManualMode(status)) {
2253
+ throw new Error(`Control port ${this.controlPort} is owned by registered pair ${status?.pairId}. ` + `This session has no pair identity (manual mode) and will not reuse or replace it \u2014 ` + `start with \`agentbridge claude\` from that pair's directory, or set AGENTBRIDGE_CONTROL_PORT to a free port.`);
2254
+ }
2255
+ if (this.isForeignDaemon(status)) {
2256
+ this.log(`Control port ${this.controlPort} held by a daemon for pair ${status?.pairId ?? "<none>"}, ` + `but this pair is ${this.expectedPairId} \u2014 replacing foreign daemon`);
2257
+ await this.replaceUnhealthyDaemon(status?.pid);
2258
+ return;
2259
+ }
2260
+ if (this.isBuildDrifted(status)) {
2261
+ if (this.canReuseDespiteDrift(status)) {
2262
+ this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `(launcher ${formatBuildInfo(BUILD_INFO)}) but a live Codex TUI is attached \u2014 reusing instead of ` + `replacing; the new build is picked up at the next restart (abg kill, then relaunch)`);
2263
+ } else {
2264
+ this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
2265
+ await this.replaceUnhealthyDaemon(status?.pid);
2266
+ return;
2267
+ }
2268
+ }
2269
+ try {
2270
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2271
+ return;
2272
+ } catch {
2273
+ this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
2274
+ await this.replaceUnhealthyDaemon(status?.pid);
2275
+ return;
2276
+ }
1515
2277
  }
1516
2278
  const existingPid = this.readPid();
1517
2279
  if (existingPid) {
1518
2280
  if (isProcessAlive(existingPid)) {
1519
2281
  if (this.isDaemonProcess(existingPid)) {
1520
2282
  try {
1521
- await this.waitForReady(12, 250);
2283
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
1522
2284
  return;
1523
2285
  } catch {
1524
- throw new Error(`Found existing daemon process ${existingPid}, but control port ${this.controlPort} never became ready.`);
2286
+ this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
2287
+ await this.replaceUnhealthyDaemon(existingPid);
2288
+ return;
1525
2289
  }
1526
2290
  }
1527
2291
  this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
1528
2292
  }
1529
2293
  this.removeStalePidFile();
1530
2294
  }
1531
- const lockAcquired = this.acquireLock();
1532
- if (!lockAcquired) {
1533
- this.log("Another process is starting the daemon, waiting for readiness...");
1534
- await this.waitForReady();
1535
- return;
1536
- }
1537
- try {
2295
+ await this.withStartupLockStrict(async (locked) => {
2296
+ if (!locked) {
2297
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
2298
+ await this.waitForReadyAndOurs();
2299
+ return;
2300
+ }
2301
+ if (await this.isHealthy()) {
2302
+ const status = await this.fetchStatus();
2303
+ if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
2304
+ this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
2305
+ await this.kill(3000, status?.pid);
2306
+ } else {
2307
+ try {
2308
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2309
+ return;
2310
+ } catch {
2311
+ this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
2312
+ await this.kill(3000, status?.pid);
2313
+ }
2314
+ }
2315
+ }
1538
2316
  this.launch();
1539
2317
  await this.waitForReady();
1540
- } finally {
1541
- this.releaseLock();
1542
- }
2318
+ });
1543
2319
  }
1544
2320
  async isHealthy() {
1545
2321
  try {
1546
- const response = await fetch(this.healthUrl);
2322
+ const response = await fetchWithTimeout(this.healthUrl);
1547
2323
  return response.ok;
1548
2324
  } catch {
1549
2325
  return false;
@@ -1559,7 +2335,7 @@ class DaemonLifecycle {
1559
2335
  }
1560
2336
  async isReady() {
1561
2337
  try {
1562
- const response = await fetch(this.readyUrl);
2338
+ const response = await fetchWithTimeout(this.readyUrl);
1563
2339
  return response.ok;
1564
2340
  } catch {
1565
2341
  return false;
@@ -1573,6 +2349,18 @@ class DaemonLifecycle {
1573
2349
  }
1574
2350
  throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
1575
2351
  }
2352
+ async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
2353
+ for (let attempt = 0;attempt < maxRetries; attempt++) {
2354
+ if (await this.isReady()) {
2355
+ const status = await this.fetchStatus();
2356
+ if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
2357
+ return;
2358
+ }
2359
+ }
2360
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
2361
+ }
2362
+ throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
2363
+ }
1576
2364
  readStatus() {
1577
2365
  try {
1578
2366
  const raw = readFileSync(this.stateDir.statusFile, "utf-8");
@@ -1604,12 +2392,12 @@ class DaemonLifecycle {
1604
2392
  }
1605
2393
  removePidFile() {
1606
2394
  try {
1607
- unlinkSync(this.stateDir.pidFile);
2395
+ unlinkSync2(this.stateDir.pidFile);
1608
2396
  } catch {}
1609
2397
  }
1610
2398
  removeStatusFile() {
1611
2399
  try {
1612
- unlinkSync(this.stateDir.statusFile);
2400
+ unlinkSync2(this.stateDir.statusFile);
1613
2401
  } catch {}
1614
2402
  }
1615
2403
  markKilled() {
@@ -1619,11 +2407,11 @@ class DaemonLifecycle {
1619
2407
  }
1620
2408
  clearKilled() {
1621
2409
  try {
1622
- unlinkSync(this.stateDir.killedFile);
2410
+ unlinkSync2(this.stateDir.killedFile);
1623
2411
  } catch {}
1624
2412
  }
1625
2413
  wasKilled() {
1626
- return existsSync2(this.stateDir.killedFile);
2414
+ return existsSync3(this.stateDir.killedFile);
1627
2415
  }
1628
2416
  launch() {
1629
2417
  this.stateDir.ensure();
@@ -1644,45 +2432,99 @@ class DaemonLifecycle {
1644
2432
  this.log("Removing stale pid file");
1645
2433
  this.removePidFile();
1646
2434
  }
1647
- acquireLock(depth = 0) {
1648
- if (depth > 1) {
1649
- this.log("Lock acquisition failed after retry, proceeding without lock");
1650
- return true;
2435
+ async replaceUnhealthyDaemon(statusPid) {
2436
+ await this.withStartupLockStrict(async (locked) => {
2437
+ if (!locked) {
2438
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
2439
+ await this.waitForReadyAndOurs();
2440
+ return;
2441
+ }
2442
+ if (await this.isHealthy()) {
2443
+ const status = await this.fetchStatus();
2444
+ if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
2445
+ try {
2446
+ await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2447
+ return;
2448
+ } catch {}
2449
+ }
2450
+ }
2451
+ this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
2452
+ await this.kill(3000, statusPid);
2453
+ this.launch();
2454
+ await this.waitForReady();
2455
+ });
2456
+ }
2457
+ async withStartupLockStrict(fn) {
2458
+ const locked = this.acquireLockStrict();
2459
+ try {
2460
+ return await fn(locked);
2461
+ } finally {
2462
+ if (locked)
2463
+ this.releaseLock();
1651
2464
  }
2465
+ }
2466
+ acquireLockStrict(reclaimed = false) {
1652
2467
  this.stateDir.ensure();
2468
+ let fd = null;
1653
2469
  try {
1654
- const fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
2470
+ fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
1655
2471
  writeFileSync(fd, `${process.pid}
1656
2472
  `);
1657
2473
  closeSync(fd);
1658
2474
  return true;
1659
2475
  } catch (err) {
2476
+ if (fd !== null && err.code !== "EEXIST") {
2477
+ try {
2478
+ closeSync(fd);
2479
+ } catch {}
2480
+ this.releaseLock();
2481
+ }
1660
2482
  if (err.code === "EEXIST") {
2483
+ if (reclaimed)
2484
+ return false;
1661
2485
  try {
1662
2486
  const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
1663
2487
  if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
1664
- this.log(`Stale lock file from dead process ${holderPid}, removing`);
2488
+ this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
1665
2489
  this.releaseLock();
1666
- return this.acquireLock(depth + 1);
2490
+ return this.acquireLockStrict(true);
2491
+ }
2492
+ if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
2493
+ this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
2494
+ this.releaseLock();
2495
+ return this.acquireLockStrict(true);
1667
2496
  }
1668
2497
  } catch {
1669
- this.log("Cannot read lock file, removing stale lock");
1670
- this.releaseLock();
1671
- return this.acquireLock(depth + 1);
2498
+ return false;
1672
2499
  }
1673
2500
  return false;
1674
2501
  }
1675
- this.log(`Warning: could not acquire startup lock: ${err.message}`);
1676
- return true;
2502
+ this.log(`Could not acquire strict startup lock: ${err.message}`);
2503
+ return false;
2504
+ }
2505
+ }
2506
+ lockAgeMs() {
2507
+ try {
2508
+ return Date.now() - statSync2(this.stateDir.lockFile).mtimeMs;
2509
+ } catch {
2510
+ return 0;
2511
+ }
2512
+ }
2513
+ isAgentBridgeProcess(pid) {
2514
+ try {
2515
+ const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
2516
+ return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
2517
+ } catch {
2518
+ return false;
1677
2519
  }
1678
2520
  }
1679
2521
  releaseLock() {
1680
2522
  try {
1681
- unlinkSync(this.stateDir.lockFile);
2523
+ unlinkSync2(this.stateDir.lockFile);
1682
2524
  } catch {}
1683
2525
  }
1684
- async kill(gracefulTimeoutMs = 3000) {
1685
- const pid = this.readPid();
2526
+ async kill(gracefulTimeoutMs = 3000, pidOverride) {
2527
+ const pid = pidOverride ?? this.readPid();
1686
2528
  if (!pid) {
1687
2529
  this.log("No daemon pid file found");
1688
2530
  this.cleanup();
@@ -1723,8 +2565,10 @@ class DaemonLifecycle {
1723
2565
  }
1724
2566
  isDaemonProcess(pid) {
1725
2567
  try {
1726
- const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
1727
- return cmd.includes("daemon") && (cmd.includes("agentbridge") || cmd.includes("agent_bridge"));
2568
+ const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
2569
+ const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
2570
+ const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
2571
+ return hasDaemonEntry && hasAgentbridge;
1728
2572
  } catch {
1729
2573
  return false;
1730
2574
  }
@@ -1732,7 +2576,15 @@ class DaemonLifecycle {
1732
2576
  cleanup() {
1733
2577
  this.removePidFile();
1734
2578
  this.removeStatusFile();
1735
- this.releaseLock();
2579
+ }
2580
+ }
2581
+ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
2582
+ const controller = new AbortController;
2583
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2584
+ try {
2585
+ return await fetch(url, { signal: controller.signal });
2586
+ } finally {
2587
+ clearTimeout(timer);
1736
2588
  }
1737
2589
  }
1738
2590
  function isProcessAlive(pid) {
@@ -1745,8 +2597,25 @@ function isProcessAlive(pid) {
1745
2597
  }
1746
2598
 
1747
2599
  // src/config-service.ts
1748
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
1749
- import { join as join2 } from "path";
2600
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
2601
+ import { join as join3 } from "path";
2602
+ var DEFAULT_BUDGET_CONFIG = {
2603
+ enabled: true,
2604
+ pollSeconds: 60,
2605
+ pauseAt: 90,
2606
+ resumeBelow: 30,
2607
+ syncDriftPct: 10,
2608
+ parallel: {
2609
+ minRemainingPct: 60,
2610
+ timeWindowSec: 3600
2611
+ },
2612
+ codexTierControl: false,
2613
+ codexTiers: {
2614
+ full: null,
2615
+ balanced: { effort: "medium" },
2616
+ eco: { effort: "low" }
2617
+ }
2618
+ };
1750
2619
  var DEFAULT_CONFIG = {
1751
2620
  version: "1.0",
1752
2621
  codex: {
@@ -1756,7 +2625,8 @@ var DEFAULT_CONFIG = {
1756
2625
  turnCoordination: {
1757
2626
  attentionWindowSeconds: 15
1758
2627
  },
1759
- idleShutdownSeconds: 30
2628
+ idleShutdownSeconds: 30,
2629
+ budget: DEFAULT_BUDGET_CONFIG
1760
2630
  };
1761
2631
  var CONFIG_DIR = ".agentbridge";
1762
2632
  var CONFIG_FILE = "config.json";
@@ -1773,6 +2643,79 @@ function normalizeInteger(value, fallback) {
1773
2643
  }
1774
2644
  return fallback;
1775
2645
  }
2646
+ function normalizeBoundedInteger(value, fallback, min, max) {
2647
+ const parsed = normalizeInteger(value, fallback);
2648
+ if (parsed < min || parsed > max)
2649
+ return fallback;
2650
+ return parsed;
2651
+ }
2652
+ function normalizeBoolean(value, fallback) {
2653
+ if (typeof value === "boolean")
2654
+ return value;
2655
+ if (value === "true" || value === "1")
2656
+ return true;
2657
+ if (value === "false" || value === "0")
2658
+ return false;
2659
+ return fallback;
2660
+ }
2661
+ function normalizeCodexOverride(raw) {
2662
+ if (!isRecord(raw))
2663
+ return null;
2664
+ const override = {};
2665
+ if (typeof raw.model === "string" && raw.model.trim() !== "")
2666
+ override.model = raw.model.trim();
2667
+ if (typeof raw.effort === "string" && raw.effort.trim() !== "")
2668
+ override.effort = raw.effort.trim();
2669
+ return Object.keys(override).length > 0 ? override : null;
2670
+ }
2671
+ function normalizeCodexTiers(raw) {
2672
+ const tiers = isRecord(raw) ? raw : {};
2673
+ return {
2674
+ full: normalizeCodexOverride(tiers.full),
2675
+ balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
2676
+ eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
2677
+ };
2678
+ }
2679
+ function normalizeBudgetConfig(raw) {
2680
+ const budget = isRecord(raw) ? raw : {};
2681
+ const parallel = isRecord(budget.parallel) ? budget.parallel : {};
2682
+ const codexTiers = normalizeCodexTiers(budget.codexTiers);
2683
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
2684
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
2685
+ if (pauseAt <= resumeBelow) {
2686
+ pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
2687
+ resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
2688
+ }
2689
+ return {
2690
+ enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
2691
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
2692
+ pauseAt,
2693
+ resumeBelow,
2694
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
2695
+ parallel: {
2696
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
2697
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
2698
+ },
2699
+ codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
2700
+ codexTiers
2701
+ };
2702
+ }
2703
+ function applyBudgetEnvOverrides(budget, env = process.env) {
2704
+ const overlay = {
2705
+ enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
2706
+ pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
2707
+ pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
2708
+ resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
2709
+ syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
2710
+ parallel: {
2711
+ minRemainingPct: env.AGENTBRIDGE_BUDGET_PARALLEL_MIN_REMAINING_PCT ?? budget.parallel.minRemainingPct,
2712
+ timeWindowSec: env.AGENTBRIDGE_BUDGET_PARALLEL_TIME_WINDOW_SEC ?? budget.parallel.timeWindowSec
2713
+ },
2714
+ codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
2715
+ codexTiers: budget.codexTiers
2716
+ };
2717
+ return normalizeBudgetConfig(overlay);
2718
+ }
1776
2719
  function normalizeConfig(raw) {
1777
2720
  if (!isRecord(raw))
1778
2721
  return null;
@@ -1789,7 +2732,8 @@ function normalizeConfig(raw) {
1789
2732
  turnCoordination: {
1790
2733
  attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
1791
2734
  },
1792
- idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
2735
+ idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
2736
+ budget: normalizeBudgetConfig(config.budget)
1793
2737
  };
1794
2738
  }
1795
2739
 
@@ -1798,11 +2742,11 @@ class ConfigService {
1798
2742
  configPath;
1799
2743
  constructor(projectRoot) {
1800
2744
  const root = projectRoot ?? process.cwd();
1801
- this.configDir = join2(root, CONFIG_DIR);
1802
- this.configPath = join2(this.configDir, CONFIG_FILE);
2745
+ this.configDir = join3(root, CONFIG_DIR);
2746
+ this.configPath = join3(this.configDir, CONFIG_FILE);
1803
2747
  }
1804
2748
  hasConfig() {
1805
- return existsSync3(this.configPath);
2749
+ return existsSync4(this.configPath);
1806
2750
  }
1807
2751
  load() {
1808
2752
  try {
@@ -1823,7 +2767,7 @@ class ConfigService {
1823
2767
  initDefaults() {
1824
2768
  this.ensureConfigDir();
1825
2769
  const created = [];
1826
- if (!existsSync3(this.configPath)) {
2770
+ if (!existsSync4(this.configPath)) {
1827
2771
  this.save(DEFAULT_CONFIG);
1828
2772
  created.push(this.configPath);
1829
2773
  }
@@ -1833,125 +2777,1262 @@ class ConfigService {
1833
2777
  return this.configPath;
1834
2778
  }
1835
2779
  ensureConfigDir() {
1836
- if (!existsSync3(this.configDir)) {
1837
- mkdirSync2(this.configDir, { recursive: true });
2780
+ if (!existsSync4(this.configDir)) {
2781
+ mkdirSync3(this.configDir, { recursive: true });
1838
2782
  }
1839
2783
  }
1840
2784
  }
1841
2785
 
1842
- // src/control-protocol.ts
1843
- var CLOSE_CODE_REPLACED = 4001;
2786
+ // src/budget/types.ts
2787
+ var STALE_MAX_AGE_SEC = 600;
1844
2788
 
1845
- // src/daemon.ts
1846
- var stateDir = new StateDirResolver;
1847
- stateDir.ensure();
1848
- var configService = new ConfigService;
1849
- var config = configService.loadOrDefault();
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);
1852
- var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
1853
- var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
1854
- var CLAUDE_DISCONNECT_GRACE_MS = 5000;
1855
- var MAX_BUFFERED_MESSAGES = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
1856
- var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "filtered";
1857
- var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
1858
- var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
1859
- var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
1860
- var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
1861
- var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
1862
- var controlServer = null;
1863
- var attachedClaude = null;
1864
- var nextControlClientId = 0;
1865
- var nextSystemMessageId = 0;
1866
- var codexBootstrapped = false;
1867
- var attentionWindowTimer = null;
1868
- var inAttentionWindow = false;
1869
- var replyRequired = false;
1870
- var replyReceivedDuringTurn = false;
1871
- var shuttingDown = false;
1872
- var idleShutdownTimer = null;
1873
- var claudeDisconnectTimer = null;
1874
- var claudeOnlineNoticeSent = false;
1875
- var claudeOfflineNoticeShown = false;
1876
- var codexCollaborationKickoffSent = false;
1877
- var lastAttachStatusSentTs = 0;
1878
- var ATTACH_STATUS_COOLDOWN_MS = 30000;
1879
- var bufferedMessages = [];
1880
- var tuiConnectionState = new TuiConnectionState({
1881
- disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
1882
- log,
1883
- onDisconnectPersisted: (connId) => {
1884
- emitToClaude(systemMessage("system_tui_disconnected", `\u26A0\uFE0F Codex TUI disconnected (conn #${connId}). Codex is still running in the background \u2014 reconnect the TUI to resume.`));
1885
- },
1886
- onReconnectAfterNotice: (connId) => {
1887
- emitToClaude(systemMessage("system_tui_reconnected", `\u2705 Codex TUI reconnected (conn #${connId}). Bridge restored, communication can continue.`));
1888
- codex.injectMessage("\u2705 Claude Code is still online, bridge restored. Bidirectional communication can continue.");
1889
- }
1890
- });
1891
- var statusBuffer = new StatusBuffer((summary) => emitToClaude(summary));
1892
- codex.on("turnStarted", () => {
1893
- log("Codex turn started");
1894
- emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
1895
- });
1896
- codex.on("agentMessage", (msg) => {
1897
- if (msg.source !== "codex")
1898
- return;
1899
- const result = classifyMessage(msg.content, FILTER_MODE);
1900
- if (replyRequired) {
1901
- log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
1902
- replyReceivedDuringTurn = true;
1903
- if (statusBuffer.size > 0) {
1904
- statusBuffer.flush("reply-required message arrived");
1905
- }
1906
- emitToClaude(msg);
1907
- return;
1908
- }
1909
- if (inAttentionWindow && result.marker === "status") {
1910
- log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
1911
- statusBuffer.add(msg);
1912
- return;
1913
- }
1914
- log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
1915
- switch (result.action) {
1916
- case "forward":
1917
- if (result.marker === "important" && statusBuffer.size > 0) {
1918
- statusBuffer.flush("important message arrived");
1919
- }
1920
- emitToClaude(msg);
1921
- if (result.marker === "important") {
1922
- startAttentionWindow();
1923
- }
1924
- break;
1925
- case "buffer":
1926
- statusBuffer.add(msg);
1927
- break;
1928
- case "drop":
1929
- break;
2789
+ // src/budget/budget-state.ts
2790
+ var AGENT_LABEL = {
2791
+ claude: "Claude",
2792
+ codex: "Codex"
2793
+ };
2794
+ var CODEX_BALANCED_WARN_UTIL = 60;
2795
+ var CODEX_ECO_WARN_UTIL = 80;
2796
+ var CLAUDE_ADVICE_WARN_UTIL = 80;
2797
+ function pct(value) {
2798
+ return `${Math.round(value * 10) / 10}%`;
2799
+ }
2800
+ function formatEpoch(epoch) {
2801
+ if (!epoch || epoch <= 0)
2802
+ return "\u672A\u77E5";
2803
+ return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
2804
+ }
2805
+ function usageSummary(name, usage) {
2806
+ if (!usage)
2807
+ return `${AGENT_LABEL[name]} \u672A\u77E5`;
2808
+ return `${AGENT_LABEL[name]} gate=${pct(usage.gateUtil)} warn=${pct(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
2809
+ }
2810
+ function matchingGateReset(usage) {
2811
+ if (!usage)
2812
+ return 0;
2813
+ const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
2814
+ const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
2815
+ const candidates = matching.length > 0 ? matching : windows;
2816
+ if (candidates.length === 0)
2817
+ return 0;
2818
+ return Math.min(...candidates.map((window) => window.resetEpoch));
2819
+ }
2820
+ function resumeBlockingEpoch(usage, cfg, now) {
2821
+ if (!usage)
2822
+ return 0;
2823
+ if (usage.rateLimitedUntil > now)
2824
+ return usage.rateLimitedUntil;
2825
+ if (usage.gateUtil >= cfg.resumeBelow)
2826
+ return matchingGateReset(usage);
2827
+ return 0;
2828
+ }
2829
+ function resumeAfterEpoch(claude, codex, cfg, now) {
2830
+ const epochs = [
2831
+ resumeBlockingEpoch(claude, cfg, now),
2832
+ resumeBlockingEpoch(codex, cfg, now)
2833
+ ].filter((epoch) => epoch > 0);
2834
+ if (epochs.length === 0)
2835
+ return null;
2836
+ return Math.max(...epochs);
2837
+ }
2838
+ function isDecisionGrade(usage, now) {
2839
+ if (!usage)
2840
+ return false;
2841
+ const freshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
2842
+ if (!freshWindow)
2843
+ return false;
2844
+ if (usage.fetchedAt > 0 && now - usage.fetchedAt > STALE_MAX_AGE_SEC)
2845
+ return false;
2846
+ return true;
2847
+ }
2848
+ function pauseTrigger(agent, usage, cfg, now) {
2849
+ if (!usage)
2850
+ return null;
2851
+ if (!isDecisionGrade(usage, now))
2852
+ return null;
2853
+ if (usage.gateUtil >= cfg.pauseAt) {
2854
+ return {
2855
+ agent,
2856
+ reason: `${AGENT_LABEL[agent]} gateUtil ${pct(usage.gateUtil)} \u2265 pauseAt ${pct(cfg.pauseAt)}`
2857
+ };
1930
2858
  }
1931
- });
1932
- codex.on("turnCompleted", () => {
1933
- log("Codex turn completed");
1934
- statusBuffer.flush("turn completed");
1935
- if (replyRequired && !replyReceivedDuringTurn) {
1936
- log("\u26A0\uFE0F Reply was required but Codex did not send any agentMessage");
1937
- emitToClaude(systemMessage("system_reply_missing", "\u26A0\uFE0F Codex completed the turn without sending a reply (require_reply was set). Codex may not have generated an agentMessage. You may want to retry or rephrase."));
2859
+ return null;
2860
+ }
2861
+ function driftFor(claude, codex, cfg) {
2862
+ if (!claude || !codex)
2863
+ return { pct: 0, heavier: null, lighter: null };
2864
+ const drift = Math.round((claude.warnUtil - codex.warnUtil) * 10) / 10;
2865
+ if (Math.abs(drift) <= cfg.syncDriftPct) {
2866
+ return { pct: drift, heavier: null, lighter: null };
1938
2867
  }
1939
- replyRequired = false;
1940
- replyReceivedDuringTurn = false;
1941
- emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
1942
- startAttentionWindow();
1943
- if (attachedClaude && shouldNotifyCodexClaudeOnline()) {
1944
- notifyCodexClaudeOnline();
2868
+ return {
2869
+ pct: drift,
2870
+ heavier: drift > 0 ? "claude" : "codex",
2871
+ lighter: drift > 0 ? "codex" : "claude"
2872
+ };
2873
+ }
2874
+ function parallelState(claude, codex, cfg, now) {
2875
+ if (!claude || !codex)
2876
+ return { recommended: false, reason: null };
2877
+ if (claude.remaining <= cfg.parallel.minRemainingPct || codex.remaining <= cfg.parallel.minRemainingPct) {
2878
+ return { recommended: false, reason: null };
2879
+ }
2880
+ const claudeReset = claude.fiveHour?.resetEpoch ?? 0;
2881
+ const codexReset = codex.fiveHour?.resetEpoch ?? 0;
2882
+ if (claudeReset <= now || codexReset <= now)
2883
+ return { recommended: false, reason: null };
2884
+ const nearestResetSec = Math.min(claudeReset - now, codexReset - now);
2885
+ if (nearestResetSec >= cfg.parallel.timeWindowSec)
2886
+ return { recommended: false, reason: null };
2887
+ const minutes = Math.ceil(nearestResetSec / 60);
2888
+ return {
2889
+ recommended: true,
2890
+ reason: `\u53CC\u65B9\u5269\u4F59\u989D\u5EA6\u5747\u9AD8\u4E8E ${pct(cfg.parallel.minRemainingPct)}\uFF0C\u6700\u8FD1 5h \u6876\u7EA6 ${minutes} \u5206\u949F\u540E\u91CD\u7F6E`
2891
+ };
2892
+ }
2893
+ function renderBudgetInterventionDirective(claude, codex, side, reason, resumeEpoch, cfg) {
2894
+ const resumeText = `\u9884\u8BA1\u6062\u590D\u65F6\u95F4\uFF08\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09\uFF1A${formatEpoch(resumeEpoch)}\u3002`;
2895
+ if (side === "claude") {
2896
+ return [
2897
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u8FDB\u5165\u63A5\u529B\u6A21\u5F0F\u3002",
2898
+ `\u89E6\u53D1\u65B9\uFF1AClaude\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
2899
+ `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
2900
+ `\u6062\u590D\u53C2\u8003\uFF1AClaude gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
2901
+ "\u8BF7\u7ACB\u5373\u4EA4\u63A5\uFF1A\u628A\u5269\u4F59\u4EFB\u52A1\u6E05\u5355\u3001\u5173\u952E\u4E0A\u4E0B\u6587\u3001\u4EA7\u51FA\u4F4D\u7F6E\u3001\u9A8C\u6536\u6807\u51C6\u6253\u5305\u6210\u4E00\u6761 reply \u53D1\u7ED9 Codex\u3002",
2902
+ "\u4EA4\u63A5\u540E Claude \u505C\u624B\uFF1B\u8981\u6C42 Codex \u5728\u5355 turn \u5185\u5C3D\u91CF\u5B8C\u6210\uFF0C\u5C3E\u5DF4\u5199 checkpoint\uFF0C\u6682\u505C\u671F\u4E0D\u8981\u671F\u5F85 Claude \u56DE\u590D\u3002"
2903
+ ].join(`
2904
+ `);
1945
2905
  }
1946
- });
1947
- codex.on("ready", (threadId) => {
1948
- tuiConnectionState.markBridgeReady();
1949
- log(`Codex ready \u2014 thread ${threadId}`);
1950
- log("Bridge fully operational");
1951
- emitToClaude(systemMessage("system_ready", currentReadyMessage()));
1952
- if (attachedClaude && shouldNotifyCodexClaudeOnline()) {
1953
- notifyCodexClaudeOnline();
2906
+ if (side === "codex") {
2907
+ return [
2908
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u6682\u505C\u59D4\u6D3E\u3002",
2909
+ `\u89E6\u53D1\u65B9\uFF1ACodex\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
2910
+ `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
2911
+ `\u6062\u590D\u53C2\u8003\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
2912
+ "\u8BF7 Claude \u5199 checkpoint\uFF0C\u5E76\u53EF solo \u63A8\u8FDB\u4E0D\u4F9D\u8D56 Codex \u7684\u72EC\u7ACB\u90E8\u5206\uFF1B\u4E0D\u8981\u7EE7\u7EED\u5411 Codex \u59D4\u6D3E\uFF0C\u6807\u6CE8\u6E05\u695A\u5206\u5DE5\u65AD\u70B9\u3002"
2913
+ ].join(`
2914
+ `);
1954
2915
  }
2916
+ return [
2917
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8FDB\u5165\u8054\u5408\u6682\u505C\u3002",
2918
+ `\u89E6\u53D1\u65B9\uFF1A\u53CC\u65B9\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
2919
+ `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
2920
+ `\u6062\u590D\u6761\u4EF6\uFF1AClaude \u4E0E Codex \u7684 gateUtil \u90FD\u4F4E\u4E8E ${pct(cfg.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
2921
+ "\u8BF7\u6536\u5C3E\u5F53\u524D\u6B65\u3001\u5199 checkpoint\u3001\u505C\u6B62\u7EE7\u7EED\u59D4\u6D3E\uFF1Bpause \u671F\u95F4\u4E0D\u8981\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\u3002"
2922
+ ].join(`
2923
+ `);
2924
+ }
2925
+ function balanceDirective(claude, codex, drift, parallel) {
2926
+ const heavier = drift.heavier ? AGENT_LABEL[drift.heavier] : "\u672A\u77E5";
2927
+ const lighter = drift.lighter ? AGENT_LABEL[drift.lighter] : "\u672A\u77E5";
2928
+ const lines = [
2929
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u68C0\u6D4B\u5230\u53CC\u65B9\u7528\u91CF\u6BD4\u4F8B\u6F02\u79FB\u3002",
2930
+ `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
2931
+ `${heavier} \u6BD4 ${lighter} \u9AD8 ${pct(Math.abs(drift.pct))}\uFF0C\u8BF7\u4F18\u5148\u628A\u540E\u7EED\u53EF\u62C6\u5206\u4EFB\u52A1\u5206\u7ED9 ${lighter}\uFF0C\u76F4\u5230 warnUtil \u63A5\u8FD1\u3002`
2932
+ ];
2933
+ if (parallel.recommended && parallel.reason) {
2934
+ lines.push(`${parallel.reason}\uFF1B\u53EF\u8BA9 ${lighter} \u627F\u62C5\u66F4\u591A\u5E76\u884C\u5B50\u4EFB\u52A1\uFF0C\u517C\u987E\u5747\u8861\u4E0E\u63D0\u901F\u3002`);
2935
+ }
2936
+ return lines.join(`
2937
+ `);
2938
+ }
2939
+ function parallelDirective(claude, codex, parallel) {
2940
+ return [
2941
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u5F53\u524D\u989D\u5EA6\u5BCC\u4F59\u4E14\u4E34\u8FD1 5h \u7ED3\u7B97\uFF0C\u5EFA\u8BAE\u52A8\u6001\u5E76\u884C\u3002",
2942
+ `${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
2943
+ `${parallel.reason}\uFF1B\u53EF\u4EE5\u62C6\u66F4\u591A\u72EC\u7ACB\u5B50\u4EFB\u52A1\u5E76\u884C\u63A8\u8FDB\u3002`
2944
+ ].join(`
2945
+ `);
2946
+ }
2947
+ function codexTierFor(codex, now) {
2948
+ if (!codex || !isDecisionGrade(codex, now))
2949
+ return "full";
2950
+ if (codex.warnUtil >= CODEX_ECO_WARN_UTIL)
2951
+ return "eco";
2952
+ if (codex.warnUtil >= CODEX_BALANCED_WARN_UTIL)
2953
+ return "balanced";
2954
+ return "full";
2955
+ }
2956
+ function claudeAdviceFor(claude, now) {
2957
+ if (!claude || !isDecisionGrade(claude, now))
2958
+ return null;
2959
+ if (claude.warnUtil < CLAUDE_ADVICE_WARN_UTIL)
2960
+ return null;
2961
+ return `Claude warnUtil ${pct(claude.warnUtil)} \u5DF2\u504F\u9AD8\uFF1B\u540E\u7EED\u53EF\u62C6\u5206 subagent \u5EFA\u8BAE\u964D\u6863\u5230 haiku/sonnet\uFF0C\u5E76\u4FDD\u7559\u9AD8\u96BE\u5EA6\u4E3B\u7EBF\u7ED9\u5F53\u524D\u4F1A\u8BDD\u3002`;
2962
+ }
2963
+ function computeBudgetState(claude, codex, cfg, now) {
2964
+ const triggers = [
2965
+ pauseTrigger("claude", claude, cfg, now),
2966
+ pauseTrigger("codex", codex, cfg, now)
2967
+ ].filter((trigger) => trigger !== null);
2968
+ const paused = triggers.length > 0;
2969
+ const drift = driftFor(claude, codex, cfg);
2970
+ const parallel = paused ? { recommended: false, reason: null } : parallelState(claude, codex, cfg, now);
2971
+ const resetEpochs = {
2972
+ claude: matchingGateReset(claude),
2973
+ codex: matchingGateReset(codex)
2974
+ };
2975
+ const filteredResumeAfterEpoch = paused ? resumeAfterEpoch(claude, codex, cfg, now) : null;
2976
+ let phase = "normal";
2977
+ if (paused)
2978
+ phase = "paused";
2979
+ else if (drift.heavier && drift.lighter)
2980
+ phase = "balance";
2981
+ else if (parallel.recommended)
2982
+ phase = "parallel";
2983
+ const pauseSide = !paused ? null : triggers.length > 1 ? "both" : triggers[0].agent;
2984
+ let directiveToClaude = null;
2985
+ if (phase === "paused") {
2986
+ directiveToClaude = renderBudgetInterventionDirective(claude, codex, pauseSide ?? "both", triggers.map((trigger) => trigger.reason).join("\uFF1B"), filteredResumeAfterEpoch, cfg);
2987
+ } else if (phase === "balance" && claude && codex) {
2988
+ directiveToClaude = balanceDirective(claude, codex, drift, parallel);
2989
+ } else if (phase === "parallel" && claude && codex) {
2990
+ directiveToClaude = parallelDirective(claude, codex, parallel);
2991
+ }
2992
+ return {
2993
+ phase,
2994
+ now,
2995
+ perAgent: { claude, codex },
2996
+ drift,
2997
+ pause: {
2998
+ active: paused,
2999
+ side: pauseSide,
3000
+ reason: paused ? triggers.map((trigger) => trigger.reason).join("\uFF1B") : null,
3001
+ resumeBelow: cfg.resumeBelow,
3002
+ resumeAfterEpoch: filteredResumeAfterEpoch,
3003
+ resetEpochs
3004
+ },
3005
+ parallel,
3006
+ effort: { claudeAdvice: claudeAdviceFor(claude, now), codexTier: codexTierFor(codex, now) },
3007
+ directiveToClaude
3008
+ };
3009
+ }
3010
+
3011
+ // src/budget/budget-coordinator.ts
3012
+ var RESET_FINGERPRINT_BUCKET_SEC = 600;
3013
+ var AGENT_LABEL2 = {
3014
+ claude: "Claude",
3015
+ codex: "Codex"
3016
+ };
3017
+ function pct2(value) {
3018
+ return `${Math.round(value * 10) / 10}%`;
3019
+ }
3020
+ function usageLine(agent, usage) {
3021
+ if (!usage)
3022
+ return `${AGENT_LABEL2[agent]} \u672A\u77E5`;
3023
+ return `${AGENT_LABEL2[agent]} gate=${pct2(usage.gateUtil)} warn=${pct2(usage.warnUtil)}`;
3024
+ }
3025
+ function matchingGateReset2(usage) {
3026
+ if (!usage)
3027
+ return 0;
3028
+ const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
3029
+ const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
3030
+ const candidates = matching.length > 0 ? matching : windows;
3031
+ if (candidates.length === 0)
3032
+ return 0;
3033
+ return Math.min(...candidates.map((window) => window.resetEpoch));
3034
+ }
3035
+
3036
+ class BudgetCoordinator {
3037
+ source;
3038
+ config;
3039
+ emit;
3040
+ onPauseChange;
3041
+ now;
3042
+ log;
3043
+ timer = null;
3044
+ running = false;
3045
+ activeSides = new Set;
3046
+ lastDirectiveFingerprint = null;
3047
+ latestSnapshot = null;
3048
+ pauseReason = null;
3049
+ pauseResumeAfterEpoch = null;
3050
+ pendingOverrideTier = null;
3051
+ pendingOverrides = null;
3052
+ lastAppliedTier = "full";
3053
+ missingFullMappingLogged = false;
3054
+ sequence = 0;
3055
+ constructor(options) {
3056
+ this.source = options.source;
3057
+ this.config = options.config;
3058
+ this.emit = options.emit;
3059
+ this.onPauseChange = options.onPauseChange;
3060
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
3061
+ this.log = options.log ?? (() => {});
3062
+ }
3063
+ async start() {
3064
+ if (this.running || !this.config.enabled)
3065
+ return;
3066
+ this.running = true;
3067
+ await this.pollOnce();
3068
+ if (this.running)
3069
+ this.scheduleNext();
3070
+ }
3071
+ stop() {
3072
+ this.running = false;
3073
+ if (this.timer) {
3074
+ clearTimeout(this.timer);
3075
+ this.timer = null;
3076
+ }
3077
+ }
3078
+ isPaused() {
3079
+ return this.activeSides.size > 0;
3080
+ }
3081
+ isGateClosed() {
3082
+ return this.activeSides.has("codex");
3083
+ }
3084
+ getSnapshot() {
3085
+ return this.latestSnapshot;
3086
+ }
3087
+ getCodexTurnOverrides() {
3088
+ if (!this.tierControlEnabled())
3089
+ return null;
3090
+ return this.pendingOverrides ? { ...this.pendingOverrides } : null;
3091
+ }
3092
+ notifyOverridesDelivered() {
3093
+ if (!this.pendingOverrideTier)
3094
+ return;
3095
+ this.lastAppliedTier = this.pendingOverrideTier;
3096
+ this.pendingOverrideTier = null;
3097
+ this.pendingOverrides = null;
3098
+ }
3099
+ resetAppliedTier() {
3100
+ this.lastAppliedTier = "full";
3101
+ this.pendingOverrideTier = null;
3102
+ this.pendingOverrides = null;
3103
+ }
3104
+ scheduleNext() {
3105
+ if (!this.running)
3106
+ return;
3107
+ if (this.timer)
3108
+ clearTimeout(this.timer);
3109
+ const delayMs = Math.max(0, this.config.pollSeconds * 1000);
3110
+ this.timer = setTimeout(() => {
3111
+ this.timer = null;
3112
+ this.pollAndReschedule();
3113
+ }, delayMs);
3114
+ }
3115
+ async pollAndReschedule() {
3116
+ await this.pollOnce();
3117
+ if (this.running)
3118
+ this.scheduleNext();
3119
+ }
3120
+ async pollOnce() {
3121
+ let usage;
3122
+ try {
3123
+ usage = await this.source.fetchBoth();
3124
+ } catch (error) {
3125
+ this.log(`budget coordinator poll failed: ${error instanceof Error ? error.message : String(error)}`);
3126
+ return;
3127
+ }
3128
+ if (!usage) {
3129
+ if (!this.isPaused())
3130
+ this.latestSnapshot = null;
3131
+ return;
3132
+ }
3133
+ if (!this.running) {
3134
+ return;
3135
+ }
3136
+ const state = computeBudgetState(usage.claude, usage.codex, this.config, this.now());
3137
+ this.updatePendingOverrides(state.effort.codexTier);
3138
+ this.applyState(state);
3139
+ this.latestSnapshot = this.toSnapshot(state);
3140
+ }
3141
+ applyState(state) {
3142
+ const previousSide = this.pauseSide();
3143
+ this.updateActiveSides(state);
3144
+ const currentSide = this.pauseSide();
3145
+ if (currentSide) {
3146
+ this.pauseReason = this.interventionReason(state);
3147
+ const nextResumeAfterEpoch = this.resumeAfterEpoch(state);
3148
+ this.pauseResumeAfterEpoch = previousSide === currentSide ? nextResumeAfterEpoch ?? this.pauseResumeAfterEpoch : nextResumeAfterEpoch;
3149
+ const fingerprint2 = previousSide === currentSide && this.activeSideProbeUncertain(state) && this.lastDirectiveFingerprint ? this.lastDirectiveFingerprint : this.directiveFingerprint(state, currentSide);
3150
+ if (!previousSide) {
3151
+ this.onPauseChange(true);
3152
+ }
3153
+ if (!previousSide || previousSide !== currentSide || fingerprint2 !== this.lastDirectiveFingerprint) {
3154
+ this.emitDirective(this.interventionPrefix(currentSide), this.interventionDirective(state, currentSide));
3155
+ }
3156
+ this.lastDirectiveFingerprint = fingerprint2;
3157
+ return;
3158
+ }
3159
+ if (previousSide) {
3160
+ this.pauseReason = null;
3161
+ this.pauseResumeAfterEpoch = null;
3162
+ this.lastDirectiveFingerprint = null;
3163
+ this.onPauseChange(false);
3164
+ this.emitDirective(this.recoveryPrefix(previousSide), this.recoveryDirective(state, previousSide));
3165
+ return;
3166
+ }
3167
+ if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
3168
+ return;
3169
+ }
3170
+ if (!state.directiveToClaude) {
3171
+ this.lastDirectiveFingerprint = null;
3172
+ return;
3173
+ }
3174
+ const fingerprint = this.directiveFingerprint(state);
3175
+ if (fingerprint !== this.lastDirectiveFingerprint) {
3176
+ const prefix = state.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
3177
+ this.emitDirective(prefix, state.directiveToClaude);
3178
+ this.lastDirectiveFingerprint = fingerprint;
3179
+ }
3180
+ }
3181
+ updateActiveSides(state) {
3182
+ for (const agent of ["claude", "codex"]) {
3183
+ const usage = state.perAgent[agent];
3184
+ if (this.shouldEnter(usage, state.now)) {
3185
+ this.activeSides.add(agent);
3186
+ } else if (this.activeSides.has(agent) && this.canAgentResume(usage, state.now)) {
3187
+ this.activeSides.delete(agent);
3188
+ }
3189
+ }
3190
+ }
3191
+ shouldEnter(usage, now) {
3192
+ if (!isDecisionGrade(usage, now))
3193
+ return false;
3194
+ return usage.gateUtil >= this.config.pauseAt;
3195
+ }
3196
+ canAgentResume(usage, now) {
3197
+ if (!isDecisionGrade(usage, now))
3198
+ return false;
3199
+ if (usage.rateLimitedUntil > now)
3200
+ return false;
3201
+ return usage.gateUtil < this.config.resumeBelow;
3202
+ }
3203
+ resumeAfterEpoch(state) {
3204
+ const epochs = ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.resumeBlockingEpoch(state.perAgent[agent], state.now)).filter((epoch) => epoch > 0);
3205
+ if (epochs.length === 0)
3206
+ return null;
3207
+ return Math.max(...epochs);
3208
+ }
3209
+ resumeBlockingEpoch(usage, now) {
3210
+ if (!usage)
3211
+ return 0;
3212
+ if (usage.rateLimitedUntil > now)
3213
+ return usage.rateLimitedUntil;
3214
+ if (usage.gateUtil >= this.config.resumeBelow)
3215
+ return matchingGateReset2(usage);
3216
+ return 0;
3217
+ }
3218
+ tierControlEnabled() {
3219
+ if (!this.config.codexTierControl)
3220
+ return false;
3221
+ if (this.config.codexTiers.full)
3222
+ return true;
3223
+ if (!this.missingFullMappingLogged) {
3224
+ this.missingFullMappingLogged = true;
3225
+ this.log("Codex tier control disabled: budget.codexTiers.full restore mapping is missing");
3226
+ }
3227
+ return false;
3228
+ }
3229
+ updatePendingOverrides(tier) {
3230
+ if (!this.tierControlEnabled()) {
3231
+ this.pendingOverrideTier = null;
3232
+ this.pendingOverrides = null;
3233
+ return;
3234
+ }
3235
+ if (this.lastAppliedTier === tier) {
3236
+ this.pendingOverrideTier = null;
3237
+ this.pendingOverrides = null;
3238
+ return;
3239
+ }
3240
+ if (this.pendingOverrideTier === tier)
3241
+ return;
3242
+ const overrides = this.config.codexTiers[tier];
3243
+ if (!overrides) {
3244
+ this.pendingOverrideTier = null;
3245
+ this.pendingOverrides = null;
3246
+ return;
3247
+ }
3248
+ this.pendingOverrideTier = tier;
3249
+ this.pendingOverrides = { ...overrides };
3250
+ }
3251
+ directiveFingerprint(state, activeSide) {
3252
+ const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
3253
+ let reset = 0;
3254
+ if (activeSide === "claude") {
3255
+ reset = state.pause.resetEpochs.claude;
3256
+ } else if (activeSide === "codex") {
3257
+ reset = state.pause.resetEpochs.codex;
3258
+ } else if (activeSide === "both") {
3259
+ reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3260
+ } else if (state.phase === "balance" && state.drift.lighter) {
3261
+ reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
3262
+ } else if (side === "claude") {
3263
+ reset = state.pause.resetEpochs.claude;
3264
+ } else if (side === "codex") {
3265
+ reset = state.pause.resetEpochs.codex;
3266
+ } else if (side === "both") {
3267
+ reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3268
+ }
3269
+ return [
3270
+ activeSide ? "paused" : state.phase,
3271
+ state.drift.heavier ?? "none",
3272
+ side,
3273
+ Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
3274
+ ].join("|");
3275
+ }
3276
+ emitDirective(prefix, content) {
3277
+ this.emit(`${prefix}_${this.sequence++}`, content);
3278
+ }
3279
+ pauseSide() {
3280
+ const claude = this.activeSides.has("claude");
3281
+ const codex = this.activeSides.has("codex");
3282
+ if (claude && codex)
3283
+ return "both";
3284
+ if (claude)
3285
+ return "claude";
3286
+ if (codex)
3287
+ return "codex";
3288
+ return null;
3289
+ }
3290
+ interventionPrefix(side) {
3291
+ return side === "claude" ? "system_budget_handoff" : "system_budget_pause";
3292
+ }
3293
+ recoveryPrefix(previousSide) {
3294
+ return previousSide === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
3295
+ }
3296
+ interventionDirective(state, side) {
3297
+ return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, this.pauseReason ?? "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", this.pauseResumeAfterEpoch, this.config);
3298
+ }
3299
+ interventionReason(state) {
3300
+ return ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.activeSideReason(agent, state.perAgent[agent], state.now)).join("\uFF1B");
3301
+ }
3302
+ activeSideProbeUncertain(state) {
3303
+ return ["claude", "codex"].some((agent) => {
3304
+ if (!this.activeSides.has(agent))
3305
+ return false;
3306
+ const usage = state.perAgent[agent];
3307
+ return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
3308
+ });
3309
+ }
3310
+ activeSideReason(agent, usage, now) {
3311
+ if (!usage)
3312
+ return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
3313
+ if (usage.rateLimitedUntil > now) {
3314
+ return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${this.formatEpoch(usage.rateLimitedUntil)}`;
3315
+ }
3316
+ if (usage.gateUtil >= this.config.pauseAt) {
3317
+ return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(this.config.pauseAt)}`;
3318
+ }
3319
+ return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(this.config.resumeBelow)}`;
3320
+ }
3321
+ recoveryDirective(state, previousSide) {
3322
+ if (previousSide === "claude") {
3323
+ return [
3324
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u9884\u7B97\u5DF2\u6062\u590D\u3002",
3325
+ `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3326
+ `Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3327
+ "Claude \u53EF\u6062\u590D orchestrator \u89D2\u8272\uFF1B\u540E\u7EED\u5206\u914D\u524D\u8BF7\u91CD\u65B0\u67E5\u8BE2\u5B9E\u65F6\u989D\u5EA6\uFF0C\u4E0D\u8981\u4F9D\u8D56\u65E7\u6570\u5B57\u3002"
3328
+ ].join(`
3329
+ `);
3330
+ }
3331
+ if (previousSide === "codex") {
3332
+ return [
3333
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
3334
+ `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3335
+ `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3336
+ "\u5EFA\u8BAE Claude \u7528 reply \u5E26\u4E0A\u5F53\u524D\u76EE\u6807\u3001checkpoint \u548C\u4E0B\u4E00\u6B65\uFF0C\u5524\u9192 Codex \u63A5\u7EED\u6267\u884C\u3002"
3337
+ ].join(`
3338
+ `);
3339
+ }
3340
+ return [
3341
+ "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8054\u5408\u6682\u505C\u89E3\u9664\u3002",
3342
+ `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3343
+ `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1A\u53CC\u65B9 gateUtil \u5747\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3344
+ "\u5EFA\u8BAE Claude \u7528 reply \u5E26\u4E0A\u5F53\u524D\u76EE\u6807\u3001checkpoint \u548C\u4E0B\u4E00\u6B65\uFF0C\u5524\u9192 Codex \u63A5\u7EED\u6267\u884C\u3002"
3345
+ ].join(`
3346
+ `);
3347
+ }
3348
+ formatEpoch(epoch) {
3349
+ return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
3350
+ }
3351
+ toSnapshot(state) {
3352
+ const paused = this.isPaused();
3353
+ return {
3354
+ phase: paused ? "paused" : state.phase,
3355
+ updatedAt: state.now,
3356
+ claude: state.perAgent.claude,
3357
+ codex: state.perAgent.codex,
3358
+ driftPct: state.drift.pct,
3359
+ paused,
3360
+ gateClosed: this.isGateClosed(),
3361
+ pauseSide: this.pauseSide(),
3362
+ pauseReason: paused ? this.pauseReason ?? state.pause.reason : null,
3363
+ resumeAfterEpoch: paused ? this.pauseResumeAfterEpoch ?? state.pause.resumeAfterEpoch : null,
3364
+ parallelRecommended: paused ? false : state.parallel.recommended,
3365
+ codexTier: state.effort.codexTier,
3366
+ claudeAdvice: state.effort.claudeAdvice
3367
+ };
3368
+ }
3369
+ }
3370
+
3371
+ // src/budget/quota-source.ts
3372
+ import { execFile } from "child_process";
3373
+ import { existsSync as existsSync5 } from "fs";
3374
+ import { homedir as homedir2 } from "os";
3375
+ import { basename, join as join4 } from "path";
3376
+ var DEFAULT_TIMEOUT_MS = 1e4;
3377
+ var MAX_BUFFER = 1024 * 1024;
3378
+ function defaultRunner(command, args, options) {
3379
+ return new Promise((resolve, reject) => {
3380
+ execFile(command, args, {
3381
+ env: options.env,
3382
+ timeout: options.timeoutMs,
3383
+ maxBuffer: MAX_BUFFER
3384
+ }, (error, stdout) => {
3385
+ if (error && !stdout) {
3386
+ reject(error);
3387
+ return;
3388
+ }
3389
+ resolve({ stdout });
3390
+ });
3391
+ });
3392
+ }
3393
+ function commandKind(command) {
3394
+ return basename(command) === "probe.mjs" ? "probe-mjs" : "budget-probe";
3395
+ }
3396
+ function argsFor(candidate, agent) {
3397
+ if (candidate.kind === "probe-mjs")
3398
+ return [agent, "probe"];
3399
+ return ["--agent", agent];
3400
+ }
3401
+ function asFiniteNumber(value) {
3402
+ if (typeof value === "number" && Number.isFinite(value))
3403
+ return value;
3404
+ if (typeof value === "string" && value.trim() !== "") {
3405
+ const parsed = Number(value);
3406
+ if (Number.isFinite(parsed))
3407
+ return parsed;
3408
+ }
3409
+ return null;
3410
+ }
3411
+ function numberOr(value, fallback) {
3412
+ return asFiniteNumber(value) ?? fallback;
3413
+ }
3414
+ function clamp(value, min, max) {
3415
+ return Math.min(max, Math.max(min, value));
3416
+ }
3417
+ function asRecord(value) {
3418
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
3419
+ }
3420
+ function normalizeBucket(value, fetchedAt) {
3421
+ const bucket = asRecord(value);
3422
+ if (!bucket)
3423
+ return null;
3424
+ const id = typeof bucket.id === "string" ? bucket.id : "";
3425
+ const util = asFiniteNumber(bucket.util);
3426
+ if (util === null)
3427
+ return null;
3428
+ const resetAfter = asFiniteNumber(bucket.reset_after_seconds ?? bucket.resetAfterSeconds);
3429
+ let resetEpoch = numberOr(bucket.reset_epoch ?? bucket.resetEpoch, 0);
3430
+ if (resetEpoch <= 0 && resetAfter !== null && fetchedAt > 0) {
3431
+ resetEpoch = fetchedAt + resetAfter;
3432
+ }
3433
+ return {
3434
+ id,
3435
+ util: clamp(util, 0, 100),
3436
+ resetEpoch: Math.max(0, resetEpoch),
3437
+ resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
3438
+ };
3439
+ }
3440
+ function normalizeTopLevelBucket(record, util, fetchedAt) {
3441
+ const resetAfter = asFiniteNumber(record.reset_after_seconds ?? record.resetAfterSeconds);
3442
+ let resetEpoch = numberOr(record.reset_epoch ?? record.resetEpoch, 0);
3443
+ if (resetEpoch <= 0 && resetAfter !== null && fetchedAt > 0) {
3444
+ resetEpoch = fetchedAt + resetAfter;
3445
+ }
3446
+ return {
3447
+ id: "top_level",
3448
+ util: clamp(util, 0, 100),
3449
+ resetEpoch: Math.max(0, resetEpoch),
3450
+ resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
3451
+ };
3452
+ }
3453
+ function toWindow(bucket) {
3454
+ if (!bucket)
3455
+ return null;
3456
+ return { util: bucket.util, resetEpoch: bucket.resetEpoch };
3457
+ }
3458
+ function bucketSortKey(bucket) {
3459
+ if (bucket.resetAfterSeconds !== null)
3460
+ return bucket.resetAfterSeconds;
3461
+ if (bucket.resetEpoch > 0)
3462
+ return bucket.resetEpoch;
3463
+ return Number.POSITIVE_INFINITY;
3464
+ }
3465
+ function sameBucketWindow(bucket, window) {
3466
+ return !!window && bucket.util === window.util && bucket.resetEpoch === window.resetEpoch;
3467
+ }
3468
+ function pickHighestUtil(buckets) {
3469
+ if (buckets.length === 0)
3470
+ return null;
3471
+ return buckets.reduce((best, current) => {
3472
+ if (current.util > best.util)
3473
+ return current;
3474
+ if (current.util === best.util && bucketSortKey(current) < bucketSortKey(best))
3475
+ return current;
3476
+ return best;
3477
+ });
3478
+ }
3479
+ function identifyWindows(buckets) {
3480
+ const fiveHourMatches = buckets.filter((bucket) => bucket.id.includes("five_hour") || bucket.id.includes("primary_window"));
3481
+ const weeklyMatches = buckets.filter((bucket) => bucket.id.includes("seven_day") || bucket.id.includes("secondary_window"));
3482
+ let fiveHour = toWindow(pickHighestUtil(fiveHourMatches));
3483
+ let weekly = toWindow(pickHighestUtil(weeklyMatches));
3484
+ const sorted = [...buckets].sort((a, b) => bucketSortKey(a) - bucketSortKey(b));
3485
+ if (!fiveHour && sorted.length > 0) {
3486
+ fiveHour = toWindow(sorted[0]);
3487
+ }
3488
+ if (!weekly && sorted.length > 1) {
3489
+ const latestDistinct = [...sorted].reverse().find((bucket) => !sameBucketWindow(bucket, fiveHour));
3490
+ weekly = toWindow(latestDistinct);
3491
+ }
3492
+ return { fiveHour, weekly };
3493
+ }
3494
+ function normalizeProbeResult(raw) {
3495
+ const record = asRecord(raw);
3496
+ if (!record)
3497
+ return null;
3498
+ const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
3499
+ const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
3500
+ const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
3501
+ const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
3502
+ const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
3503
+ const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
3504
+ if (buckets.length === 0 && hasFiniteUtil) {
3505
+ const topLevelBucket = normalizeTopLevelBucket(record, gateUtil, fetchedAt);
3506
+ if (topLevelBucket)
3507
+ buckets.push(topLevelBucket);
3508
+ }
3509
+ const rateLimitedUntil = Math.max(0, numberOr(record.rate_limited_until ?? record.rateLimitedUntil, 0));
3510
+ const ok = record.ok === true;
3511
+ if (!ok && rateLimitedUntil <= 0 && buckets.length === 0)
3512
+ return null;
3513
+ const { fiveHour, weekly } = identifyWindows(buckets);
3514
+ if (!fiveHour && !weekly && rateLimitedUntil === 0 && !hasFiniteUtil)
3515
+ return null;
3516
+ return {
3517
+ ok,
3518
+ stale: record.stale === true,
3519
+ gateUtil,
3520
+ warnUtil,
3521
+ fiveHour,
3522
+ weekly,
3523
+ remaining: clamp(100 - gateUtil, 0, 100),
3524
+ rateLimitedUntil,
3525
+ fetchedAt
3526
+ };
3527
+ }
3528
+ function withTimeout(promise, timeoutMs) {
3529
+ let timer = null;
3530
+ const timeout = new Promise((_, reject) => {
3531
+ timer = setTimeout(() => reject(new Error(`budget probe timed out after ${timeoutMs}ms`)), timeoutMs);
3532
+ });
3533
+ return Promise.race([promise, timeout]).finally(() => {
3534
+ if (timer)
3535
+ clearTimeout(timer);
3536
+ });
3537
+ }
3538
+ function isDegradedUsage(usage, now = Math.floor(Date.now() / 1000)) {
3539
+ if (usage.stale || !usage.ok)
3540
+ return true;
3541
+ const hasFreshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
3542
+ return !hasFreshWindow;
3543
+ }
3544
+
3545
+ class QuotaSource {
3546
+ env;
3547
+ homeDir;
3548
+ timeoutMs;
3549
+ runner;
3550
+ log;
3551
+ now;
3552
+ degradedLogged = new Map;
3553
+ constructor(options = {}) {
3554
+ this.env = options.env ?? process.env;
3555
+ this.homeDir = options.homeDir ?? homedir2();
3556
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3557
+ this.runner = options.runner ?? defaultRunner;
3558
+ this.log = options.log ?? (() => {});
3559
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
3560
+ }
3561
+ async fetchBoth() {
3562
+ const candidates = this.findProbeCandidates();
3563
+ if (candidates.length === 0)
3564
+ return null;
3565
+ const [claude, codex] = await Promise.all([
3566
+ this.fetchAgent(candidates, "claude"),
3567
+ this.fetchAgent(candidates, "codex")
3568
+ ]);
3569
+ return { claude, codex };
3570
+ }
3571
+ findProbeCandidates() {
3572
+ const candidates = [];
3573
+ const seen = new Set;
3574
+ const add = (command, kind) => {
3575
+ const key = `${kind}:${command}`;
3576
+ if (seen.has(key))
3577
+ return;
3578
+ seen.add(key);
3579
+ candidates.push({ command, kind });
3580
+ };
3581
+ const explicit = this.env.AGENTBRIDGE_QUOTA_PROBE || this.env.BUDGET_PROBE;
3582
+ if (explicit && explicit.trim() !== "") {
3583
+ const command = explicit.trim();
3584
+ add(command, commandKind(command));
3585
+ return candidates;
3586
+ }
3587
+ const binDir = join4(this.homeDir, ".budget-guard/bin");
3588
+ const installedBudgetProbe = join4(binDir, "budget-probe");
3589
+ if (existsSync5(installedBudgetProbe))
3590
+ add(installedBudgetProbe, "budget-probe");
3591
+ const installedProbeMjs = join4(binDir, "probe.mjs");
3592
+ if (existsSync5(installedProbeMjs))
3593
+ add(installedProbeMjs, "probe-mjs");
3594
+ return candidates;
3595
+ }
3596
+ async fetchAgent(candidates, agent) {
3597
+ for (const candidate of candidates) {
3598
+ try {
3599
+ const result = await withTimeout(this.runner(candidate.command, argsFor(candidate, agent), {
3600
+ env: this.env,
3601
+ timeoutMs: this.timeoutMs,
3602
+ agent
3603
+ }), this.timeoutMs);
3604
+ const text = String(result.stdout).trim();
3605
+ if (!text)
3606
+ continue;
3607
+ let parsed;
3608
+ try {
3609
+ parsed = JSON.parse(text);
3610
+ } catch {
3611
+ this.log(`budget probe output unparseable for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
3612
+ continue;
3613
+ }
3614
+ const usage = normalizeProbeResult(parsed);
3615
+ if (usage) {
3616
+ this.noteDegradation(agent, usage);
3617
+ return usage;
3618
+ }
3619
+ this.log(`budget probe returned no usable data for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
3620
+ } catch (error) {
3621
+ this.log(`budget probe failed for ${agent}: ${error instanceof Error ? error.message : String(error)}`);
3622
+ }
3623
+ }
3624
+ return null;
3625
+ }
3626
+ noteDegradation(agent, usage) {
3627
+ const degraded = isDegradedUsage(usage, this.now());
3628
+ const wasDegraded = this.degradedLogged.get(agent) === true;
3629
+ if (degraded && !wasDegraded) {
3630
+ const gate = usage.rateLimitedUntil > 0 ? `, rate-limit gated until ${usage.rateLimitedUntil}` : "";
3631
+ this.log(`budget probe degraded data accepted for ${agent} (stale=${usage.stale}, ok=${usage.ok}${gate}) \u2014 display only, decisions hold`);
3632
+ } else if (!degraded && wasDegraded) {
3633
+ this.log(`budget probe recovered to fresh data for ${agent}`);
3634
+ }
3635
+ this.degradedLogged.set(agent, degraded);
3636
+ }
3637
+ }
3638
+ function createQuotaSource(options) {
3639
+ return new QuotaSource(options);
3640
+ }
3641
+
3642
+ // src/reply-required-tracker.ts
3643
+ class ReplyRequiredTracker {
3644
+ armed = false;
3645
+ forwardedDuringTurn = false;
3646
+ get isArmed() {
3647
+ return this.armed;
3648
+ }
3649
+ arm() {
3650
+ this.armed = true;
3651
+ this.forwardedDuringTurn = false;
3652
+ }
3653
+ noteForwarded() {
3654
+ if (this.armed)
3655
+ this.forwardedDuringTurn = true;
3656
+ }
3657
+ consumeOnTurnComplete() {
3658
+ const warnReplyMissing = this.armed && !this.forwardedDuringTurn;
3659
+ this.reset();
3660
+ return { warnReplyMissing };
3661
+ }
3662
+ reset() {
3663
+ this.armed = false;
3664
+ this.forwardedDuringTurn = false;
3665
+ }
3666
+ }
3667
+
3668
+ // src/thread-state.ts
3669
+ import {
3670
+ existsSync as existsSync6,
3671
+ mkdirSync as mkdirSync4,
3672
+ readdirSync,
3673
+ readFileSync as readFileSync3,
3674
+ renameSync as renameSync2,
3675
+ writeFileSync as writeFileSync3
3676
+ } from "fs";
3677
+ import { homedir as homedir3 } from "os";
3678
+ import { basename as basename2, dirname as dirname2, join as join5 } from "path";
3679
+ function nowIso() {
3680
+ return new Date().toISOString();
3681
+ }
3682
+ function threadTag(identity) {
3683
+ const name = identity.pairName ?? identity.pairId ?? "manual";
3684
+ return `abg:${name}:${identity.cwd}`;
3685
+ }
3686
+ function codexHome(env = process.env) {
3687
+ return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join5(homedir3(), ".codex");
3688
+ }
3689
+ function atomicWriteJson(path, value) {
3690
+ mkdirSync4(dirname2(path), { recursive: true });
3691
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
3692
+ writeFileSync3(tmp, JSON.stringify(value, null, 2) + `
3693
+ `, "utf-8");
3694
+ renameSync2(tmp, path);
3695
+ }
3696
+ function readRawCurrentThread(stateDir) {
3697
+ try {
3698
+ const parsed = JSON.parse(readFileSync3(stateDir.currentThreadFile, "utf-8"));
3699
+ if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
3700
+ return parsed;
3701
+ }
3702
+ } catch {}
3703
+ return null;
3704
+ }
3705
+ function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
3706
+ const sessionsDir = join5(codexHome(env), "sessions");
3707
+ if (!threadId || !existsSync6(sessionsDir))
3708
+ return null;
3709
+ const exactName = `rollout-${threadId}.jsonl`;
3710
+ const stack = [sessionsDir];
3711
+ let visited = 0;
3712
+ while (stack.length > 0 && visited < maxEntries) {
3713
+ const dir = stack.pop();
3714
+ let entries;
3715
+ try {
3716
+ entries = readdirSync(dir, { withFileTypes: true });
3717
+ } catch {
3718
+ continue;
3719
+ }
3720
+ for (const entry of entries) {
3721
+ visited++;
3722
+ const path = join5(dir, entry.name);
3723
+ if (entry.isDirectory()) {
3724
+ stack.push(path);
3725
+ continue;
3726
+ }
3727
+ if (!entry.isFile())
3728
+ continue;
3729
+ const name = basename2(entry.name);
3730
+ if (name === exactName || name.startsWith("rollout-") && name.endsWith(".jsonl") && name.includes(threadId)) {
3731
+ return path;
3732
+ }
3733
+ }
3734
+ }
3735
+ return null;
3736
+ }
3737
+ function writePendingCurrentThread(identity, threadId, reason) {
3738
+ const state = {
3739
+ version: 1,
3740
+ status: "pending",
3741
+ pairId: identity.pairId,
3742
+ pairName: identity.pairName,
3743
+ cwd: identity.cwd,
3744
+ threadId,
3745
+ updatedAt: nowIso(),
3746
+ reason,
3747
+ tag: threadTag(identity)
3748
+ };
3749
+ atomicWriteJson(identity.stateDir.currentThreadFile, state);
3750
+ return state;
3751
+ }
3752
+ function promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env = process.env) {
3753
+ const rolloutPath = findCodexRolloutFile(threadId, env);
3754
+ const state = {
3755
+ version: 1,
3756
+ status: rolloutPath ? "current" : "pending",
3757
+ pairId: identity.pairId,
3758
+ pairName: identity.pairName,
3759
+ cwd: identity.cwd,
3760
+ threadId,
3761
+ updatedAt: nowIso(),
3762
+ reason,
3763
+ tag: threadTag(identity),
3764
+ ...rolloutPath ? { rolloutPath, rolloutVerifiedAt: nowIso() } : {}
3765
+ };
3766
+ atomicWriteJson(identity.stateDir.currentThreadFile, state);
3767
+ return state;
3768
+ }
3769
+ async function persistCurrentThreadWithRolloutRetry(identity, threadId, reason, options = {}) {
3770
+ const env = options.env ?? process.env;
3771
+ const attempts = options.attempts ?? 20;
3772
+ const delayMs = options.delayMs ?? 250;
3773
+ const shouldContinue = options.shouldContinue ?? (() => true);
3774
+ if (!shouldContinue())
3775
+ return null;
3776
+ writePendingCurrentThread(identity, threadId, reason);
3777
+ for (let attempt = 1;attempt <= attempts; attempt++) {
3778
+ if (!shouldContinue()) {
3779
+ options.log?.(`Abandoned current-thread persistence for ${threadId}: a newer thread became active`);
3780
+ return null;
3781
+ }
3782
+ const state = promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env);
3783
+ if (state.status === "current") {
3784
+ options.log?.(`Current Codex thread persisted: ${threadId} (${state.rolloutPath})`);
3785
+ return state;
3786
+ }
3787
+ if (attempt < attempts) {
3788
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
3789
+ }
3790
+ }
3791
+ if (!shouldContinue())
3792
+ return null;
3793
+ options.log?.(`Current Codex thread left pending because no rollout file was found: ${threadId}`);
3794
+ return readRawCurrentThread(identity.stateDir) ?? writePendingCurrentThread(identity, threadId, reason);
3795
+ }
3796
+
3797
+ // src/waiting-message.ts
3798
+ function formatWaitingForCodexTuiMessage(options) {
3799
+ const pairName = options.pairName ?? "unknown";
3800
+ const pairId = options.pairId ?? "manual";
3801
+ const slot = options.slot === null || options.slot === undefined ? "manual" : String(options.slot);
3802
+ return [
3803
+ "\u23F3 Waiting for Codex TUI to connect.",
3804
+ `Current pair: cwd=${options.cwd} pair=${pairName} pairId=${pairId} slot=${slot} proxy=${options.proxyUrl}`,
3805
+ "If Codex was started from a different cwd, it belongs to another pair and will not attach here.",
3806
+ "Run in another terminal:",
3807
+ options.attachCmd,
3808
+ "For diagnostics: abg doctor"
3809
+ ].join(`
3810
+ `);
3811
+ }
3812
+
3813
+ // src/pair-registry.ts
3814
+ var PAIR_BASE_PORT = 4500;
3815
+ var PAIR_SLOT_STRIDE = 10;
3816
+ var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
3817
+
3818
+ // src/liveness-probe.ts
3819
+ var OPEN = 1;
3820
+ async function probeLiveness(target, options) {
3821
+ const {
3822
+ timeoutMs,
3823
+ pollMs = 50,
3824
+ now = Date.now,
3825
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms))
3826
+ } = options;
3827
+ if (target.readyState !== OPEN)
3828
+ return false;
3829
+ const baseline = target.pongCount;
3830
+ try {
3831
+ target.ping();
3832
+ } catch {
3833
+ return false;
3834
+ }
3835
+ const deadline = now() + timeoutMs;
3836
+ while (now() < deadline) {
3837
+ if (target.pongCount > baseline)
3838
+ return true;
3839
+ if (target.readyState !== OPEN)
3840
+ return false;
3841
+ await sleep(pollMs);
3842
+ }
3843
+ return target.pongCount > baseline;
3844
+ }
3845
+
3846
+ // src/daemon.ts
3847
+ var stateDir = new StateDirResolver;
3848
+ stateDir.ensure();
3849
+ var configService = new ConfigService;
3850
+ var config = configService.loadOrDefault();
3851
+ var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
3852
+ var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
3853
+ var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
3854
+ var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
3855
+ var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
3856
+ var CLAUDE_DISCONNECT_GRACE_MS = 5000;
3857
+ var MAX_BUFFERED_MESSAGES = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
3858
+ var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "filtered";
3859
+ var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
3860
+ var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
3861
+ var BOOTSTRAP_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_BOOTSTRAP_TIMEOUT_MS", 45000);
3862
+ var CODEX_BOOT_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_CODEX_BOOT_RETRIES", 2);
3863
+ var ALLOW_IDENTITYLESS_CLIENT = process.env.AGENTBRIDGE_COMPAT_IDENTITYLESS === "1";
3864
+ var BUDGET_CONFIG = applyBudgetEnvOverrides(config.budget);
3865
+ var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
3866
+ var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
3867
+ var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
3868
+ var controlServer = null;
3869
+ var attachedClaude = null;
3870
+ var nextControlClientId = 0;
3871
+ var nextSystemMessageId = 0;
3872
+ var codexBootstrapped = false;
3873
+ var attentionWindowTimer = null;
3874
+ var inAttentionWindow = false;
3875
+ var replyTracker = new ReplyRequiredTracker;
3876
+ var shuttingDown = false;
3877
+ var bootDeadlineTimer = null;
3878
+ var idleShutdownTimer = null;
3879
+ var claudeDisconnectTimer = null;
3880
+ var lastAttachStatusSentTs = 0;
3881
+ var ATTACH_STATUS_COOLDOWN_MS = 30000;
3882
+ var LIVENESS_PROBE_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_LIVENESS_PROBE_TIMEOUT_MS", 3000, log);
3883
+ var LIVENESS_PROBE_POLL_MS = 50;
3884
+ var challengeInProgress = false;
3885
+ var bufferedMessages = [];
3886
+ var budgetCoordinator = null;
3887
+ var budgetStatusTimer = null;
3888
+ function ensureBudgetCoordinatorStarted() {
3889
+ if (!BUDGET_CONFIG.enabled)
3890
+ return;
3891
+ if (!budgetCoordinator) {
3892
+ log(`Budget coordinator config: pollSeconds=${BUDGET_CONFIG.pollSeconds} pauseAt=${BUDGET_CONFIG.pauseAt} ` + `resumeBelow=${BUDGET_CONFIG.resumeBelow} syncDriftPct=${BUDGET_CONFIG.syncDriftPct} ` + `parallel=${BUDGET_CONFIG.parallel.minRemainingPct}%/${BUDGET_CONFIG.parallel.timeWindowSec}s ` + `codexTierControl=${BUDGET_CONFIG.codexTierControl} ` + `codexTiersFull=${BUDGET_CONFIG.codexTiers.full ? "configured" : "missing"}`);
3893
+ budgetCoordinator = new BudgetCoordinator({
3894
+ source: createQuotaSource({ log }),
3895
+ config: BUDGET_CONFIG,
3896
+ emit: (id, content) => {
3897
+ emitToClaude(systemMessage(id, content));
3898
+ queueMicrotask(() => broadcastStatus());
3899
+ },
3900
+ onPauseChange: (paused) => {
3901
+ log(`Budget intervention ${paused ? "ACTIVE" : "CLEARED"} ` + `(gate ${budgetCoordinator?.isGateClosed() ? "CLOSED" : "OPEN"})`);
3902
+ queueMicrotask(() => broadcastStatus());
3903
+ },
3904
+ log
3905
+ });
3906
+ }
3907
+ budgetCoordinator.start();
3908
+ if (!budgetStatusTimer) {
3909
+ budgetStatusTimer = setInterval(() => broadcastStatus(), BUDGET_CONFIG.pollSeconds * 1000);
3910
+ budgetStatusTimer.unref?.();
3911
+ }
3912
+ }
3913
+ function stopBudgetCoordinator() {
3914
+ budgetCoordinator?.stop();
3915
+ if (budgetStatusTimer) {
3916
+ clearInterval(budgetStatusTimer);
3917
+ budgetStatusTimer = null;
3918
+ }
3919
+ }
3920
+ function budgetPauseGateError() {
3921
+ const snapshot = budgetCoordinator?.getSnapshot() ?? null;
3922
+ const reason = snapshot?.pauseReason ?? "Codex \u4FA7\u989D\u5EA6\u63A5\u8FD1\u8017\u5C3D";
3923
+ const resumeAt = snapshot?.resumeAfterEpoch ? new Date(snapshot.resumeAfterEpoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z") : null;
3924
+ const sideHint = snapshot?.pauseSide === "both" ? "\u53CC\u4FA7\u989D\u5EA6\u5747\u5DF2\u8017\u5C3D\uFF0C\u8BF7\u5199 checkpoint \u7B49\u5F85\u5237\u65B0" : "\u4F60\u53EF\u7EE7\u7EED solo \u63A8\u8FDB\u53EF\u72EC\u7ACB\u90E8\u5206\uFF0C\u5E76\u5199 checkpoint \u6807\u6CE8\u5206\u5DE5\u65AD\u70B9";
3925
+ return `\u9884\u7B97\u6682\u505C\uFF08\u95F8\u95E8\u5173\u95ED\uFF09\uFF0C\u5DF2\u62D2\u7EDD\u8F6C\u53D1\uFF1A${reason}\u3002` + `Codex \u4FA7 gateUtil \u4F4E\u4E8E ${BUDGET_CONFIG.resumeBelow}% \u540E\u95F8\u95E8\u81EA\u52A8\u653E\u5F00` + (resumeAt ? `\uFF08\u9884\u8BA1\u6062\u590D ${resumeAt}\uFF0C\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09` : "") + `\u3002\u6536\u5230 RESUME \u901A\u77E5\u524D\u8BF7\u52FF\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\uFF1B${sideHint}\u3002`;
3926
+ }
3927
+ var tuiConnectionState = new TuiConnectionState({
3928
+ disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
3929
+ log,
3930
+ onDisconnectPersisted: (connId) => {
3931
+ emitToClaude(systemMessage("system_tui_disconnected", `\u26A0\uFE0F Codex TUI disconnected (conn #${connId}). Codex is still running in the background \u2014 reconnect the TUI to resume.`));
3932
+ },
3933
+ onReconnectAfterNotice: (connId) => {
3934
+ emitToClaude(systemMessage("system_tui_reconnected", `\u2705 Codex TUI reconnected (conn #${connId}). Bridge restored, communication can continue.`));
3935
+ }
3936
+ });
3937
+ var statusBuffer = new StatusBuffer((summary) => emitToClaude(summary));
3938
+ function tryWriteStatusFile(reason) {
3939
+ try {
3940
+ writeStatusFile();
3941
+ } catch (err) {
3942
+ log(`status file write failed (${reason}): ${err?.message ?? err}`);
3943
+ }
3944
+ }
3945
+ codex.on("turnPhaseChanged", ({ phase, previous }) => {
3946
+ log(`Codex turn phase: ${previous} \u2192 ${phase}`);
3947
+ tryWriteStatusFile(`turnPhase:${phase}`);
3948
+ broadcastStatus();
3949
+ });
3950
+ codex.on("turnStarted", () => {
3951
+ log("Codex turn started");
3952
+ emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
3953
+ });
3954
+ codex.on("agentMessage", (msg) => {
3955
+ if (msg.source !== "codex")
3956
+ return;
3957
+ const result = classifyMessage(msg.content, FILTER_MODE);
3958
+ if (replyTracker.isArmed) {
3959
+ log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
3960
+ replyTracker.noteForwarded();
3961
+ if (statusBuffer.size > 0) {
3962
+ statusBuffer.flush("reply-required message arrived");
3963
+ }
3964
+ emitToClaude(msg);
3965
+ return;
3966
+ }
3967
+ if (inAttentionWindow && result.marker === "status") {
3968
+ log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
3969
+ statusBuffer.add(msg);
3970
+ return;
3971
+ }
3972
+ log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
3973
+ switch (result.action) {
3974
+ case "forward":
3975
+ if (result.marker === "important" && statusBuffer.size > 0) {
3976
+ statusBuffer.flush("important message arrived");
3977
+ }
3978
+ emitToClaude(msg);
3979
+ if (result.marker === "important") {
3980
+ startAttentionWindow();
3981
+ }
3982
+ break;
3983
+ case "buffer":
3984
+ statusBuffer.add(msg);
3985
+ break;
3986
+ case "drop":
3987
+ break;
3988
+ }
3989
+ });
3990
+ codex.on("turnCompleted", () => {
3991
+ log("Codex turn completed");
3992
+ statusBuffer.flush("turn completed");
3993
+ const { warnReplyMissing } = replyTracker.consumeOnTurnComplete();
3994
+ if (warnReplyMissing) {
3995
+ log("\u26A0\uFE0F Reply was required but Codex did not send any agentMessage");
3996
+ emitToClaude(systemMessage("system_reply_missing", "\u26A0\uFE0F Codex completed the turn without sending a reply (require_reply was set). Codex may not have generated an agentMessage. You may want to retry or rephrase."));
3997
+ }
3998
+ emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
3999
+ startAttentionWindow();
4000
+ });
4001
+ codex.on("turnAborted", (reason) => {
4002
+ log(`Codex turn aborted (${reason}) \u2014 clearing reply-required state`);
4003
+ const replyWasRequired = replyTracker.isArmed;
4004
+ replyTracker.reset();
4005
+ const notice = buildTurnAbortedNotice(reason, replyWasRequired);
4006
+ if (notice) {
4007
+ emitToClaude(systemMessage("system_turn_aborted", notice));
4008
+ }
4009
+ });
4010
+ codex.on("turnStalled", (event) => {
4011
+ log(`Codex turn stalled (${event.turnId}, inactivity ${event.inactivityMs}ms)`);
4012
+ emitToClaude(systemMessage("system_turn_stalled", `\u26A0\uFE0F Codex has been silent for ${event.inactivityMs}ms while a turn is still in progress. AgentBridge is keeping the turn busy and will not send a fake completion; wait for Codex to finish or reconnect the TUI if it is stuck.`));
4013
+ });
4014
+ codex.on("ready", (threadId) => {
4015
+ tuiConnectionState.markBridgeReady();
4016
+ log(`Codex ready \u2014 thread ${threadId}`);
4017
+ log("Bridge fully operational");
4018
+ emitToClaude(systemMessage("system_ready", currentReadyMessage()));
4019
+ budgetCoordinator?.resetAppliedTier();
4020
+ ensureBudgetCoordinatorStarted();
4021
+ });
4022
+ codex.on("threadChanged", (event) => {
4023
+ budgetCoordinator?.resetAppliedTier();
4024
+ broadcastStatus();
4025
+ persistCurrentThreadWithRolloutRetry({
4026
+ stateDir,
4027
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
4028
+ pairName: process.env.AGENTBRIDGE_PAIR_NAME,
4029
+ cwd: process.cwd()
4030
+ }, event.threadId, event.reason, {
4031
+ log,
4032
+ shouldContinue: () => codex.activeThreadId === event.threadId
4033
+ }).catch((err) => {
4034
+ log(`Failed to persist current thread ${event.threadId}: ${err?.message ?? err}`);
4035
+ });
1955
4036
  });
1956
4037
  codex.on("tuiConnected", (connId) => {
1957
4038
  tuiConnectionState.handleTuiConnected(connId);
@@ -1971,13 +4052,13 @@ codex.on("error", (err) => {
1971
4052
  codex.on("exit", (code) => {
1972
4053
  log(`Codex process exited (code ${code})`);
1973
4054
  codexBootstrapped = false;
4055
+ replyTracker.reset();
1974
4056
  statusBuffer.flush("codex exited");
1975
4057
  tuiConnectionState.handleCodexExit();
1976
4058
  clearPendingClaudeDisconnect("Codex process exited");
1977
- claudeOnlineNoticeSent = false;
1978
- claudeOfflineNoticeShown = false;
1979
- emitToClaude(systemMessage("system_codex_exit", `\u26A0\uFE0F Codex app-server exited (code ${code ?? "unknown"}). AgentBridge daemon is still running, but the Codex side needs to be restarted.`));
4059
+ emitToClaude(systemMessage("system_codex_exit", `\u26A0\uFE0F Codex app-server exited (code ${code ?? "unknown"}). AgentBridge daemon is still running. ` + `Restart the Codex side (\`agentbridge codex\`); if it does not come back within ` + `${Math.round(BOOTSTRAP_TIMEOUT_MS / 1000)}s the daemon will self-replace so the next launch starts clean.`));
1980
4060
  broadcastStatus();
4061
+ armBootDeadline();
1981
4062
  });
1982
4063
  function startControlServer() {
1983
4064
  controlServer = Bun.serve({
@@ -1991,7 +4072,7 @@ function startControlServer() {
1991
4072
  if (url.pathname === "/readyz") {
1992
4073
  return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
1993
4074
  }
1994
- if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false } })) {
4075
+ if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
1995
4076
  return;
1996
4077
  }
1997
4078
  return new Response("AgentBridge daemon");
@@ -2001,6 +4082,8 @@ function startControlServer() {
2001
4082
  sendPings: true,
2002
4083
  open: (ws) => {
2003
4084
  ws.data.clientId = ++nextControlClientId;
4085
+ ws.data.lastPongAt = Date.now();
4086
+ ws.data.pendingBackpressure = [];
2004
4087
  log(`Frontend socket opened (#${ws.data.clientId})`);
2005
4088
  },
2006
4089
  close: (ws, code, reason) => {
@@ -2011,6 +4094,18 @@ function startControlServer() {
2011
4094
  },
2012
4095
  message: (ws, raw) => {
2013
4096
  handleControlMessage(ws, raw);
4097
+ },
4098
+ pong: (ws) => {
4099
+ ws.data.lastPongAt = Date.now();
4100
+ ws.data.pongCount++;
4101
+ },
4102
+ drain: (ws) => {
4103
+ if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
4104
+ ws.data.pendingBackpressure = [];
4105
+ }
4106
+ if (ws === attachedClaude && bufferedMessages.length > 0) {
4107
+ flushBufferedMessages(ws);
4108
+ }
2014
4109
  }
2015
4110
  }
2016
4111
  });
@@ -2026,7 +4121,20 @@ function handleControlMessage(ws, raw) {
2026
4121
  }
2027
4122
  switch (message.type) {
2028
4123
  case "claude_connect":
2029
- attachClaude(ws);
4124
+ const admission = validateClaudeClientIdentity({
4125
+ expectedPairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
4126
+ daemonCwd: process.cwd(),
4127
+ identity: message.identity,
4128
+ allowIdentityless: ALLOW_IDENTITYLESS_CLIENT
4129
+ });
4130
+ if (!admission.ok) {
4131
+ log(`Rejecting Claude frontend #${ws.data.clientId}: ${admission.reason}`);
4132
+ ws.close(admission.closeCode, admission.reason);
4133
+ return;
4134
+ }
4135
+ attachClaude(ws, message.identity).catch((err) => {
4136
+ log(`attachClaude threw for #${ws.data.clientId}: ${err?.message ?? err}`);
4137
+ });
2030
4138
  return;
2031
4139
  case "claude_disconnect":
2032
4140
  detachClaude(ws, "frontend requested disconnect");
@@ -2034,6 +4142,11 @@ function handleControlMessage(ws, raw) {
2034
4142
  case "status":
2035
4143
  sendStatus(ws);
2036
4144
  return;
4145
+ case "probe_incumbent":
4146
+ handleProbeIncumbent(ws).catch((err) => {
4147
+ log(`handleProbeIncumbent threw for #${ws.data.clientId}: ${err?.message ?? err}`);
4148
+ });
4149
+ return;
2037
4150
  case "claude_to_codex": {
2038
4151
  if (message.message.source !== "claude") {
2039
4152
  sendProtocolMessage(ws, {
@@ -2053,18 +4166,25 @@ function handleControlMessage(ws, raw) {
2053
4166
  });
2054
4167
  return;
2055
4168
  }
4169
+ if (budgetCoordinator?.isGateClosed()) {
4170
+ const reason = budgetPauseGateError();
4171
+ log(`Injection rejected by budget pause gate`);
4172
+ sendProtocolMessage(ws, {
4173
+ type: "claude_to_codex_result",
4174
+ requestId: message.requestId,
4175
+ success: false,
4176
+ error: reason
4177
+ });
4178
+ return;
4179
+ }
2056
4180
  const requireReply = !!message.requireReply;
2057
- let contentWithReminder = message.message.content + `
2058
-
2059
- ` + BRIDGE_CONTRACT_REMINDER;
4181
+ let contentToSend = message.message.content;
2060
4182
  if (requireReply) {
2061
- contentWithReminder += REPLY_REQUIRED_INSTRUCTION;
2062
- replyRequired = true;
2063
- replyReceivedDuringTurn = false;
2064
- log(`Reply required flag set for this message`);
4183
+ contentToSend += REPLY_REQUIRED_INSTRUCTION;
2065
4184
  }
2066
4185
  log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
2067
- const injected = codex.injectMessage(contentWithReminder);
4186
+ const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4187
+ const injected = codex.injectMessage(contentToSend, tierOverrides);
2068
4188
  if (!injected) {
2069
4189
  const reason = codex.turnInProgress ? "Codex is busy executing a turn. Wait for it to finish before sending another message." : "Injection failed: no active thread or WebSocket not connected.";
2070
4190
  log(`Injection rejected: ${reason}`);
@@ -2076,6 +4196,13 @@ function handleControlMessage(ws, raw) {
2076
4196
  });
2077
4197
  return;
2078
4198
  }
4199
+ if (tierOverrides) {
4200
+ budgetCoordinator?.notifyOverridesDelivered();
4201
+ }
4202
+ if (requireReply) {
4203
+ replyTracker.arm();
4204
+ log(`Reply required flag set for this message`);
4205
+ }
2079
4206
  clearAttentionWindow();
2080
4207
  sendProtocolMessage(ws, {
2081
4208
  type: "claude_to_codex_result",
@@ -2086,24 +4213,57 @@ function handleControlMessage(ws, raw) {
2086
4213
  }
2087
4214
  }
2088
4215
  }
2089
- function attachClaude(ws) {
4216
+ async function attachClaude(ws, identity) {
4217
+ const occupant = attachedClaude;
4218
+ if (occupant && occupant !== ws && occupant.readyState !== WebSocket.CLOSED) {
4219
+ const msSincePong = Date.now() - occupant.data.lastPongAt;
4220
+ log(`Claude frontend contest: new=#${ws.data.clientId}, incumbent=#${occupant.data.clientId} ` + `(readyState=${occupant.readyState}, msSincePong=${msSincePong})`);
4221
+ if (challengeInProgress) {
4222
+ log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 another liveness probe already in flight`);
4223
+ ws.close(CLOSE_CODE_PROBE_IN_PROGRESS, "liveness probe in progress, retry shortly");
4224
+ return;
4225
+ }
4226
+ challengeInProgress = true;
4227
+ let incumbentAlive = false;
4228
+ try {
4229
+ incumbentAlive = await probeLiveness2(occupant, LIVENESS_PROBE_TIMEOUT_MS);
4230
+ } finally {
4231
+ challengeInProgress = false;
4232
+ }
4233
+ if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
4234
+ log(`Contestant #${ws.data.clientId} disappeared during probe \u2014 aborting`);
4235
+ if (!incumbentAlive) {
4236
+ evictStale(occupant, "contestant gone but probe still failed");
4237
+ }
4238
+ return;
4239
+ }
4240
+ if (incumbentAlive) {
4241
+ log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 incumbent #${occupant.data.clientId} responded to liveness probe`);
4242
+ ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
4243
+ return;
4244
+ }
4245
+ evictStale(occupant, `liveness probe timed out after ${LIVENESS_PROBE_TIMEOUT_MS}ms`);
4246
+ }
2090
4247
  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})`);
4248
+ log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 slot re-acquired by #${attachedClaude.data.clientId} after probe`);
2092
4249
  ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
2093
4250
  return;
2094
4251
  }
2095
4252
  clearPendingClaudeDisconnect("Claude frontend attached");
4253
+ ws.data.identity = identity;
2096
4254
  attachedClaude = ws;
2097
4255
  ws.data.attached = true;
2098
4256
  cancelIdleShutdown();
2099
- log(`Claude frontend attached (#${ws.data.clientId})`);
4257
+ log(`Claude frontend attached (#${ws.data.clientId}, pair=${identity?.pairId ?? "<none>"}, cwd=${identity?.cwd ?? "<unknown>"})`);
4258
+ const hadBacklog = bufferedMessages.length > 0;
4259
+ if (hadBacklog) {
4260
+ flushBufferedMessages(ws);
4261
+ }
2100
4262
  statusBuffer.flush("claude reconnected");
2101
4263
  sendStatus(ws);
2102
4264
  const now = Date.now();
2103
4265
  const isRapidReattach = now - lastAttachStatusSentTs < ATTACH_STATUS_COOLDOWN_MS;
2104
- if (bufferedMessages.length > 0) {
2105
- flushBufferedMessages(ws);
2106
- } else if (!isRapidReattach) {
4266
+ if (!hadBacklog && !isRapidReattach) {
2107
4267
  if (tuiConnectionState.canReply()) {
2108
4268
  sendBridgeMessage(ws, systemMessage("system_ready", currentReadyMessage()));
2109
4269
  } else if (codexBootstrapped) {
@@ -2111,9 +4271,6 @@ function attachClaude(ws) {
2111
4271
  }
2112
4272
  }
2113
4273
  lastAttachStatusSentTs = now;
2114
- if (tuiConnectionState.canReply() && shouldNotifyCodexClaudeOnline()) {
2115
- notifyCodexClaudeOnline();
2116
- }
2117
4274
  }
2118
4275
  function detachClaude(ws, reason) {
2119
4276
  if (attachedClaude !== ws)
@@ -2121,19 +4278,75 @@ function detachClaude(ws, reason) {
2121
4278
  attachedClaude = null;
2122
4279
  ws.data.attached = false;
2123
4280
  log(`Claude frontend detached (#${ws.data.clientId}, ${reason})`);
4281
+ if (ws.data.pendingBackpressure.length > 0) {
4282
+ bufferedMessages.unshift(...ws.data.pendingBackpressure);
4283
+ log(`Re-buffered ${ws.data.pendingBackpressure.length} backpressured message(s) for redelivery on reconnect`);
4284
+ ws.data.pendingBackpressure = [];
4285
+ if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
4286
+ const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
4287
+ bufferedMessages.splice(0, dropped);
4288
+ log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
4289
+ }
4290
+ }
2124
4291
  scheduleClaudeDisconnectNotification(ws.data.clientId);
2125
4292
  scheduleIdleShutdown();
2126
4293
  }
4294
+ async function handleProbeIncumbent(ws) {
4295
+ const occupant = attachedClaude;
4296
+ log(`probe_incumbent from #${ws.data.clientId}: occupant=${occupant ? "#" + occupant.data.clientId : "none"} readyState=${occupant?.readyState}`);
4297
+ if (!occupant || occupant === ws || occupant.readyState !== WebSocket.OPEN) {
4298
+ sendProtocolMessage(ws, { type: "incumbent_status", connected: false, alive: false });
4299
+ return;
4300
+ }
4301
+ if (challengeInProgress) {
4302
+ sendProtocolMessage(ws, { type: "incumbent_status", connected: true, alive: true });
4303
+ return;
4304
+ }
4305
+ const alive = await probeLiveness2(occupant, LIVENESS_PROBE_TIMEOUT_MS);
4306
+ const stillConnected = attachedClaude === occupant && occupant.readyState === WebSocket.OPEN;
4307
+ log(`probe_incumbent reply to #${ws.data.clientId}: connected=${stillConnected} alive=${stillConnected && alive}`);
4308
+ sendProtocolMessage(ws, {
4309
+ type: "incumbent_status",
4310
+ connected: stillConnected,
4311
+ alive: stillConnected && alive
4312
+ });
4313
+ }
4314
+ async function probeLiveness2(ws, timeoutMs) {
4315
+ return probeLiveness({
4316
+ get readyState() {
4317
+ return ws.readyState;
4318
+ },
4319
+ get pongCount() {
4320
+ return ws.data.pongCount;
4321
+ },
4322
+ ping: () => {
4323
+ ws.ping();
4324
+ }
4325
+ }, { timeoutMs, pollMs: LIVENESS_PROBE_POLL_MS });
4326
+ }
4327
+ function evictStale(ws, reason) {
4328
+ log(`Evicting stale Claude frontend #${ws.data.clientId}: ${reason}`);
4329
+ if (attachedClaude === ws) {
4330
+ detachClaude(ws, `evicted: ${reason}`);
4331
+ }
4332
+ try {
4333
+ ws.close(CLOSE_CODE_EVICTED_STALE, "stale frontend evicted by newer session");
4334
+ } catch (err) {
4335
+ log(`Evict close threw on #${ws.data.clientId}: ${err.message}`);
4336
+ }
4337
+ }
2127
4338
  function startAttentionWindow() {
2128
4339
  clearAttentionWindow();
2129
4340
  inAttentionWindow = true;
2130
4341
  statusBuffer.pause();
2131
4342
  log(`Attention window started (${ATTENTION_WINDOW_MS}ms)`);
4343
+ tryWriteStatusFile("attentionWindowStarted");
2132
4344
  attentionWindowTimer = setTimeout(() => {
2133
4345
  attentionWindowTimer = null;
2134
4346
  inAttentionWindow = false;
2135
4347
  statusBuffer.resume();
2136
4348
  log("Attention window ended");
4349
+ tryWriteStatusFile("attentionWindowEnded");
2137
4350
  }, ATTENTION_WINDOW_MS);
2138
4351
  }
2139
4352
  function clearAttentionWindow() {
@@ -2143,8 +4356,9 @@ function clearAttentionWindow() {
2143
4356
  }
2144
4357
  if (inAttentionWindow) {
2145
4358
  statusBuffer.resume();
4359
+ inAttentionWindow = false;
4360
+ tryWriteStatusFile("attentionWindowCleared");
2146
4361
  }
2147
- inAttentionWindow = false;
2148
4362
  }
2149
4363
  function scheduleIdleShutdown() {
2150
4364
  cancelIdleShutdown();
@@ -2185,17 +4399,6 @@ function scheduleClaudeDisconnectNotification(clientId) {
2185
4399
  log(`Skipping Claude disconnect notification for client #${clientId} because Claude already reconnected`);
2186
4400
  return;
2187
4401
  }
2188
- if (!tuiConnectionState.canReply()) {
2189
- log(`Suppressing Claude disconnect notification for client #${clientId} because Codex cannot reply`);
2190
- return;
2191
- }
2192
- if (!claudeOnlineNoticeSent) {
2193
- log(`Suppressing Claude disconnect notification for client #${clientId} because Claude was never announced online`);
2194
- return;
2195
- }
2196
- codex.injectMessage("\u26A0\uFE0F Claude Code went offline. AgentBridge is still running in the background; it will reconnect automatically when Claude reopens.");
2197
- claudeOnlineNoticeSent = false;
2198
- claudeOfflineNoticeShown = true;
2199
4402
  log(`Claude disconnect persisted past grace window (client #${clientId})`);
2200
4403
  }, CLAUDE_DISCONNECT_GRACE_MS);
2201
4404
  }
@@ -2215,10 +4418,18 @@ function emitToClaude(message) {
2215
4418
  function trySendBridgeMessage(ws, message) {
2216
4419
  try {
2217
4420
  const result = ws.send(JSON.stringify({ type: "codex_to_claude", message }));
2218
- if (typeof result === "number" && result <= 0) {
2219
- log(`Bridge message send returned ${result} (0=dropped, -1=backpressure)`);
4421
+ if (typeof result === "number" && result === 0) {
4422
+ log("Bridge message send returned 0 (dropped)");
2220
4423
  return false;
2221
4424
  }
4425
+ if (typeof result === "number" && result === -1) {
4426
+ ws.data.pendingBackpressure.push(message);
4427
+ if (ws.data.pendingBackpressure.length > MAX_BUFFERED_MESSAGES) {
4428
+ const dropped = ws.data.pendingBackpressure.length - MAX_BUFFERED_MESSAGES;
4429
+ ws.data.pendingBackpressure.splice(0, dropped);
4430
+ log(`Backpressure overflow: dropped ${dropped} oldest tracked message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
4431
+ }
4432
+ }
2222
4433
  return true;
2223
4434
  } catch (err) {
2224
4435
  log(`Failed to send bridge message: ${err.message}`);
@@ -2227,10 +4438,9 @@ function trySendBridgeMessage(ws, message) {
2227
4438
  }
2228
4439
  function flushBufferedMessages(ws) {
2229
4440
  const messages = bufferedMessages.splice(0, bufferedMessages.length);
2230
- for (const message of messages) {
2231
- if (!trySendBridgeMessage(ws, message)) {
2232
- const failedIndex = messages.indexOf(message);
2233
- const remaining = messages.slice(failedIndex);
4441
+ for (let i = 0;i < messages.length; i++) {
4442
+ if (!trySendBridgeMessage(ws, messages[i])) {
4443
+ const remaining = messages.slice(i);
2234
4444
  bufferedMessages.unshift(...remaining);
2235
4445
  log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
2236
4446
  return;
@@ -2250,7 +4460,10 @@ function broadcastStatus() {
2250
4460
  }
2251
4461
  function sendProtocolMessage(ws, message) {
2252
4462
  try {
2253
- ws.send(JSON.stringify(message));
4463
+ const result = ws.send(JSON.stringify(message));
4464
+ if (typeof result === "number" && result === 0) {
4465
+ log(`Control message dropped (socket closed): type=${message.type}`);
4466
+ }
2254
4467
  } catch (err) {
2255
4468
  log(`Failed to send control message: ${err.message}`);
2256
4469
  }
@@ -2261,41 +4474,36 @@ function currentStatus() {
2261
4474
  bridgeReady: tuiConnectionState.canReply(),
2262
4475
  tuiConnected: snapshot.tuiConnected,
2263
4476
  threadId: codex.activeThreadId,
2264
- queuedMessageCount: bufferedMessages.length + statusBuffer.size,
4477
+ queuedMessageCount: bufferedMessages.length + statusBuffer.size + (attachedClaude?.data.pendingBackpressure.length ?? 0),
2265
4478
  proxyUrl: codex.proxyUrl,
2266
4479
  appServerUrl: codex.appServerUrl,
2267
- pid: process.pid
4480
+ pid: process.pid,
4481
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
4482
+ cwd: process.cwd(),
4483
+ stateDir: stateDir.dir,
4484
+ build: daemonStatusBuildInfo(),
4485
+ budget: budgetCoordinator?.getSnapshot() ?? undefined,
4486
+ turnInProgress: codex.turnInProgress,
4487
+ turnPhase: codex.turnPhase,
4488
+ attentionWindowActive: inAttentionWindow
2268
4489
  };
2269
4490
  }
2270
4491
  function currentWaitingMessage() {
2271
- return `\u23F3 Waiting for Codex TUI to connect. Run in another terminal:
2272
- ${attachCmd}`;
4492
+ const pairId = process.env.AGENTBRIDGE_PAIR_ID ?? null;
4493
+ const offset = CODEX_PROXY_PORT - PAIR_BASE_PORT - 1;
4494
+ const slot = pairId !== null && offset >= 0 && offset % PAIR_SLOT_STRIDE === 0 ? offset / PAIR_SLOT_STRIDE : null;
4495
+ return formatWaitingForCodexTuiMessage({
4496
+ attachCmd,
4497
+ cwd: process.cwd(),
4498
+ pairId,
4499
+ pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
4500
+ slot,
4501
+ proxyUrl: codex.proxyUrl
4502
+ });
2273
4503
  }
2274
4504
  function currentReadyMessage() {
2275
4505
  return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
2276
4506
  }
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
- }
2291
- claudeOnlineNoticeSent = true;
2292
- claudeOfflineNoticeShown = false;
2293
- codexCollaborationKickoffSent = true;
2294
- return true;
2295
- }
2296
- function shouldNotifyCodexClaudeOnline() {
2297
- return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;
2298
- }
2299
4507
  function systemMessage(idPrefix, content) {
2300
4508
  return {
2301
4509
  id: `${idPrefix}_${++nextSystemMessageId}`,
@@ -2315,34 +4523,82 @@ function writeStatusFile() {
2315
4523
  proxyUrl: codex.proxyUrl,
2316
4524
  appServerUrl: codex.appServerUrl,
2317
4525
  controlPort: CONTROL_PORT,
2318
- pid: process.pid
4526
+ pid: process.pid,
4527
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
4528
+ cwd: process.cwd(),
4529
+ stateDir: stateDir.dir,
4530
+ build: daemonStatusBuildInfo(),
4531
+ turnInProgress: codex.turnInProgress,
4532
+ turnPhase: codex.turnPhase,
4533
+ attentionWindowActive: inAttentionWindow
2319
4534
  });
2320
4535
  }
2321
4536
  function removeStatusFile() {
2322
4537
  daemonLifecycle.removeStatusFile();
2323
4538
  }
4539
+ function armBootDeadline() {
4540
+ if (bootDeadlineTimer)
4541
+ return;
4542
+ bootDeadlineTimer = setTimeout(() => {
4543
+ bootDeadlineTimer = null;
4544
+ if (codexBootstrapped)
4545
+ return;
4546
+ if (tuiConnectionState.snapshot().tuiConnected)
4547
+ return;
4548
+ log(`Codex not ready within bootstrap deadline (${BOOTSTRAP_TIMEOUT_MS}ms) \u2014 self-exiting to release control port`);
4549
+ if (attachedClaude) {
4550
+ emitToClaude(systemMessage("system_daemon_self_replace", "\u26A0\uFE0F Codex did not become ready within the bootstrap deadline \u2014 the AgentBridge daemon is restarting itself to release a clean slot. The bridge will reconnect automatically."));
4551
+ }
4552
+ shutdown("codex not ready within bootstrap deadline", 1);
4553
+ }, BOOTSTRAP_TIMEOUT_MS);
4554
+ bootDeadlineTimer.unref?.();
4555
+ }
4556
+ function clearBootDeadline() {
4557
+ if (bootDeadlineTimer) {
4558
+ clearTimeout(bootDeadlineTimer);
4559
+ bootDeadlineTimer = null;
4560
+ }
4561
+ }
2324
4562
  async function bootCodex() {
2325
4563
  log("Starting AgentBridge daemon...");
2326
4564
  log(`Codex app-server: ${codex.appServerUrl}`);
2327
4565
  log(`Codex proxy: ${codex.proxyUrl}`);
2328
4566
  log(`Control server: ws://127.0.0.1:${CONTROL_PORT}/ws`);
2329
- try {
2330
- await codex.start();
2331
- codexBootstrapped = true;
2332
- writeStatusFile();
2333
- emitToClaude(systemMessage("system_waiting", currentWaitingMessage()));
2334
- broadcastStatus();
2335
- } catch (err) {
2336
- log(`Failed to start Codex: ${err.message}`);
2337
- emitToClaude(systemMessage("system_codex_start_failed", `\u274C AgentBridge failed to start Codex app-server: ${err.message}`));
2338
- broadcastStatus();
4567
+ for (let attempt = 0;attempt <= CODEX_BOOT_RETRIES; attempt++) {
4568
+ try {
4569
+ await codex.start();
4570
+ codexBootstrapped = true;
4571
+ clearBootDeadline();
4572
+ writeStatusFile();
4573
+ emitToClaude(systemMessage("system_waiting", currentWaitingMessage()));
4574
+ broadcastStatus();
4575
+ scheduleIdleShutdown();
4576
+ return;
4577
+ } catch (err) {
4578
+ const attemptsLeft = CODEX_BOOT_RETRIES - attempt;
4579
+ log(`Failed to start Codex (attempt ${attempt + 1}/${CODEX_BOOT_RETRIES + 1}): ${err.message}`);
4580
+ if (attemptsLeft > 0) {
4581
+ const backoffMs = 1000 * (attempt + 1);
4582
+ log(`Retrying Codex bootstrap in ${backoffMs}ms (${attemptsLeft} attempt(s) left)...`);
4583
+ await new Promise((r) => setTimeout(r, backoffMs));
4584
+ if (shuttingDown)
4585
+ return;
4586
+ continue;
4587
+ }
4588
+ emitToClaude(systemMessage("system_codex_start_failed", `\u274C AgentBridge failed to start Codex app-server after ${CODEX_BOOT_RETRIES + 1} attempts: ${err.message}`));
4589
+ broadcastStatus();
4590
+ shutdown("codex bootstrap failed", 1);
4591
+ return;
4592
+ }
2339
4593
  }
2340
4594
  }
2341
- function shutdown(reason) {
4595
+ function shutdown(reason, exitCode = 0) {
2342
4596
  if (shuttingDown)
2343
4597
  return;
2344
4598
  shuttingDown = true;
2345
4599
  log(`Shutting down daemon (${reason})...`);
4600
+ clearBootDeadline();
4601
+ stopBudgetCoordinator();
2346
4602
  tuiConnectionState.dispose(`daemon shutdown (${reason})`);
2347
4603
  clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
2348
4604
  controlServer?.stop();
@@ -2350,27 +4606,23 @@ function shutdown(reason) {
2350
4606
  codex.stop();
2351
4607
  removePidFile();
2352
4608
  removeStatusFile();
2353
- process.exit(0);
4609
+ process.exit(exitCode);
2354
4610
  }
2355
4611
  process.on("SIGINT", () => shutdown("SIGINT"));
2356
4612
  process.on("SIGTERM", () => shutdown("SIGTERM"));
2357
4613
  process.on("exit", () => {
4614
+ codex.forceKillAppServerSync();
2358
4615
  removePidFile();
2359
4616
  removeStatusFile();
2360
4617
  });
2361
4618
  process.on("uncaughtException", (err) => {
2362
- log(`UNCAUGHT EXCEPTION: ${err.stack ?? err.message}`);
4619
+ processLogger.fatal("UNCAUGHT EXCEPTION", err);
2363
4620
  });
2364
4621
  process.on("unhandledRejection", (reason) => {
2365
- log(`UNHANDLED REJECTION: ${reason?.stack ?? reason}`);
4622
+ processLogger.fatal("UNHANDLED REJECTION", reason);
2366
4623
  });
2367
4624
  function log(msg) {
2368
- const line = `[${new Date().toISOString()}] [AgentBridgeDaemon] ${msg}
2369
- `;
2370
- process.stderr.write(line);
2371
- try {
2372
- appendFileSync2(stateDir.logFile, line);
2373
- } catch {}
4625
+ processLogger.log(msg);
2374
4626
  }
2375
4627
  if (daemonLifecycle.wasKilled()) {
2376
4628
  log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
@@ -2378,4 +4630,5 @@ if (daemonLifecycle.wasKilled()) {
2378
4630
  }
2379
4631
  writePidFile();
2380
4632
  startControlServer();
4633
+ armBootDeadline();
2381
4634
  bootCodex();