@raysonmeng/agentbridge 0.1.12 → 0.1.14

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.
package/dist/daemon.js CHANGED
@@ -1,10 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
+ // src/daemon.ts
5
+ import { rmSync as rmSync2 } from "fs";
6
+ import { randomUUID as randomUUID4 } from "crypto";
7
+
4
8
  // src/contract-version.ts
5
9
  var CONTRACT_VERSION = 1;
6
10
 
7
11
  // src/build-info.ts
12
+ var CODE_HASH_SENTINEL = "source";
13
+ function hasValidCodeHash(build) {
14
+ const hash = build?.codeHash;
15
+ return typeof hash === "string" && hash.length > 0 && hash !== CODE_HASH_SENTINEL;
16
+ }
8
17
  function defineString(value, fallback) {
9
18
  return typeof value === "string" && value.length > 0 ? value : fallback;
10
19
  }
@@ -17,10 +26,11 @@ function defineNumber(value, fallback) {
17
26
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
18
27
  }
19
28
  var BUILD_INFO = Object.freeze({
20
- version: defineString("0.1.12", "0.0.0-source"),
21
- commit: defineString("eec6018", "source"),
29
+ version: defineString("0.1.14", "0.0.0-source"),
30
+ commit: defineString("f5a9562", "source"),
22
31
  bundle: defineBundle("dist"),
23
- contractVersion: defineNumber(1, CONTRACT_VERSION)
32
+ contractVersion: defineNumber(1, CONTRACT_VERSION),
33
+ codeHash: defineString("e05d18c3cc72", "source")
24
34
  });
25
35
  function daemonStatusBuildInfo() {
26
36
  return { ...BUILD_INFO };
@@ -28,7 +38,14 @@ function daemonStatusBuildInfo() {
28
38
  function sameRuntimeContract(a, b) {
29
39
  if (!a || !b)
30
40
  return false;
31
- return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
41
+ if (a.version !== b.version || a.contractVersion !== b.contractVersion)
42
+ return false;
43
+ if (hasValidCodeHash(a) && hasValidCodeHash(b))
44
+ return a.codeHash === b.codeHash;
45
+ return a.commit === b.commit;
46
+ }
47
+ function runtimeContractComparisonBasis(a, b) {
48
+ return hasValidCodeHash(a) && hasValidCodeHash(b) ? "codeHash" : "commit";
32
49
  }
33
50
  function compatibleContractVersion(a, b) {
34
51
  if (!a || !b)
@@ -38,7 +55,175 @@ function compatibleContractVersion(a, b) {
38
55
  function formatBuildInfo(build) {
39
56
  if (!build)
40
57
  return "<unknown>";
41
- return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
58
+ const codeHash = hasValidCodeHash(build) ? `/code-${build.codeHash}` : "";
59
+ return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}${codeHash}`;
60
+ }
61
+
62
+ // src/daemon-record.ts
63
+ import { readFileSync } from "fs";
64
+
65
+ // src/atomic-json.ts
66
+ import * as fs from "fs";
67
+ import { randomUUID } from "crypto";
68
+ import { dirname } from "path";
69
+ function tmpPathFor(targetPath) {
70
+ return `${targetPath}.tmp.${process.pid}.${randomUUID()}`;
71
+ }
72
+ function atomicWriteText(path, content, options = {}) {
73
+ fs.mkdirSync(dirname(path), { recursive: true });
74
+ const tmp = tmpPathFor(path);
75
+ let renamed = false;
76
+ const fd = fs.openSync(tmp, "w", options.mode ?? 438);
77
+ try {
78
+ try {
79
+ fs.writeFileSync(fd, content, "utf-8");
80
+ if (options.fsync)
81
+ fs.fsyncSync(fd);
82
+ } finally {
83
+ fs.closeSync(fd);
84
+ }
85
+ fs.renameSync(tmp, path);
86
+ renamed = true;
87
+ } finally {
88
+ if (!renamed) {
89
+ try {
90
+ fs.unlinkSync(tmp);
91
+ } catch {}
92
+ }
93
+ }
94
+ }
95
+ function atomicWriteJson(path, value, options = {}) {
96
+ atomicWriteText(path, JSON.stringify(value, null, 2) + `
97
+ `, options);
98
+ }
99
+
100
+ // src/daemon-record.ts
101
+ var defaultRead = (path) => readFileSync(path, "utf-8");
102
+ function writeDaemonRecord(path, record) {
103
+ atomicWriteJson(path, record);
104
+ }
105
+ function sanitizePorts(value) {
106
+ if (typeof value !== "object" || value === null)
107
+ return;
108
+ const raw = value;
109
+ const ports = {};
110
+ if (typeof raw.appPort === "number")
111
+ ports.appPort = raw.appPort;
112
+ if (typeof raw.proxyPort === "number")
113
+ ports.proxyPort = raw.proxyPort;
114
+ if (typeof raw.controlPort === "number")
115
+ ports.controlPort = raw.controlPort;
116
+ return Object.keys(ports).length > 0 ? ports : undefined;
117
+ }
118
+ function readDaemonRecord(path, read = defaultRead) {
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(read(path));
122
+ } catch {
123
+ return null;
124
+ }
125
+ if (typeof parsed !== "object" || parsed === null)
126
+ return null;
127
+ const obj = parsed;
128
+ if (typeof obj.pid !== "number" || !Number.isFinite(obj.pid))
129
+ return null;
130
+ const phase = obj.phase === "ready" ? "ready" : "booting";
131
+ const record = { pid: obj.pid, phase };
132
+ if (typeof obj.startedAt === "number")
133
+ record.startedAt = obj.startedAt;
134
+ if (typeof obj.nonce === "string")
135
+ record.nonce = obj.nonce;
136
+ if (obj.pairId === null || typeof obj.pairId === "string")
137
+ record.pairId = obj.pairId;
138
+ if (obj.cwd === null || typeof obj.cwd === "string")
139
+ record.cwd = obj.cwd;
140
+ if (obj.stateDir === null || typeof obj.stateDir === "string")
141
+ record.stateDir = obj.stateDir;
142
+ if (typeof obj.proxyUrl === "string")
143
+ record.proxyUrl = obj.proxyUrl;
144
+ if (typeof obj.appServerUrl === "string")
145
+ record.appServerUrl = obj.appServerUrl;
146
+ const ports = sanitizePorts(obj.ports);
147
+ if (ports !== undefined)
148
+ record.ports = ports;
149
+ if (typeof obj.build === "object" && obj.build !== null) {
150
+ record.build = obj.build;
151
+ }
152
+ if (typeof obj.turnPhase === "string")
153
+ record.turnPhase = obj.turnPhase;
154
+ if (typeof obj.turnInProgress === "boolean")
155
+ record.turnInProgress = obj.turnInProgress;
156
+ if (typeof obj.attentionWindowActive === "boolean") {
157
+ record.attentionWindowActive = obj.attentionWindowActive;
158
+ }
159
+ return record;
160
+ }
161
+ function synthesizeLegacyRecord(pidFilePath, statusFilePath, read = defaultRead) {
162
+ let pidFromPidFile = null;
163
+ try {
164
+ const raw = read(pidFilePath).trim();
165
+ const n = Number.parseInt(raw, 10);
166
+ if (Number.isFinite(n))
167
+ pidFromPidFile = n;
168
+ } catch {}
169
+ let status = null;
170
+ try {
171
+ const parsed = JSON.parse(read(statusFilePath));
172
+ if (typeof parsed === "object" && parsed !== null)
173
+ status = parsed;
174
+ } catch {}
175
+ const pidFromStatus = status && typeof status.pid === "number" && Number.isFinite(status.pid) ? status.pid : null;
176
+ const pid = pidFromPidFile ?? pidFromStatus;
177
+ if (pid === null)
178
+ return null;
179
+ const record = {
180
+ pid,
181
+ phase: status ? "ready" : "booting"
182
+ };
183
+ if (status) {
184
+ if (typeof status.proxyUrl === "string")
185
+ record.proxyUrl = status.proxyUrl;
186
+ if (typeof status.appServerUrl === "string")
187
+ record.appServerUrl = status.appServerUrl;
188
+ const controlPort = typeof status.controlPort === "number" ? status.controlPort : undefined;
189
+ const proxyPort = portFromUrl(status.proxyUrl);
190
+ const appPort = portFromUrl(status.appServerUrl);
191
+ if (controlPort !== undefined || proxyPort !== undefined || appPort !== undefined) {
192
+ record.ports = {};
193
+ if (appPort !== undefined)
194
+ record.ports.appPort = appPort;
195
+ if (proxyPort !== undefined)
196
+ record.ports.proxyPort = proxyPort;
197
+ if (controlPort !== undefined)
198
+ record.ports.controlPort = controlPort;
199
+ }
200
+ if (status.pairId === null || typeof status.pairId === "string")
201
+ record.pairId = status.pairId;
202
+ if (status.cwd === null || typeof status.cwd === "string")
203
+ record.cwd = status.cwd;
204
+ if (status.stateDir === null || typeof status.stateDir === "string")
205
+ record.stateDir = status.stateDir;
206
+ if (typeof status.build === "object" && status.build !== null) {
207
+ record.build = status.build;
208
+ }
209
+ if (typeof status.turnPhase === "string")
210
+ record.turnPhase = status.turnPhase;
211
+ if (typeof status.turnInProgress === "boolean")
212
+ record.turnInProgress = status.turnInProgress;
213
+ if (typeof status.attentionWindowActive === "boolean") {
214
+ record.attentionWindowActive = status.attentionWindowActive;
215
+ }
216
+ }
217
+ return record;
218
+ }
219
+ function readUnifiedDaemonRecord(paths, read = defaultRead) {
220
+ return readDaemonRecord(paths.daemonRecordFile, read) ?? synthesizeLegacyRecord(paths.pidFile, paths.statusFile, read);
221
+ }
222
+ function portFromUrl(url) {
223
+ if (typeof url !== "string")
224
+ return;
225
+ const match = url.match(/:(\d+)(?:[/?]|$)/);
226
+ return match ? Number.parseInt(match[1], 10) : undefined;
42
227
  }
43
228
 
44
229
  // src/codex-adapter.ts
@@ -47,9 +232,13 @@ import { createInterface } from "readline";
47
232
  import { EventEmitter } from "events";
48
233
 
49
234
  // src/state-dir.ts
50
- import { mkdirSync, existsSync } from "fs";
235
+ import { mkdirSync as mkdirSync2, existsSync } from "fs";
51
236
  import { join } from "path";
52
237
  import { homedir, platform } from "os";
238
+ function resolveXdgStateBase(rawXdg = process.env.XDG_STATE_HOME) {
239
+ const xdgState = rawXdg && rawXdg.length > 0 ? rawXdg : join(homedir(), ".local", "state");
240
+ return join(xdgState, "agentbridge");
241
+ }
53
242
 
54
243
  class StateDirResolver {
55
244
  stateDir;
@@ -57,8 +246,7 @@ class StateDirResolver {
57
246
  if (platform() === "darwin") {
58
247
  return join(homedir(), "Library", "Application Support", "AgentBridge");
59
248
  }
60
- const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
61
- return join(xdgState, "agentbridge");
249
+ return resolveXdgStateBase(process.env.XDG_STATE_HOME);
62
250
  }
63
251
  constructor(envOverride) {
64
252
  const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
@@ -66,7 +254,7 @@ class StateDirResolver {
66
254
  }
67
255
  ensure() {
68
256
  if (!existsSync(this.stateDir)) {
69
- mkdirSync(this.stateDir, { recursive: true });
257
+ mkdirSync2(this.stateDir, { recursive: true });
70
258
  }
71
259
  }
72
260
  get dir() {
@@ -84,8 +272,8 @@ class StateDirResolver {
84
272
  get statusFile() {
85
273
  return join(this.stateDir, "status.json");
86
274
  }
87
- get portsFile() {
88
- return join(this.stateDir, "ports.json");
275
+ get daemonRecordFile() {
276
+ return join(this.stateDir, "daemon.json");
89
277
  }
90
278
  get currentThreadFile() {
91
279
  return join(this.stateDir, "current-thread.json");
@@ -205,15 +393,15 @@ async function cleanupPorts(options) {
205
393
  }
206
394
 
207
395
  // src/rotating-log.ts
208
- import { appendFileSync, existsSync as existsSync2, renameSync, statSync, unlinkSync } from "fs";
209
- import { dirname } from "path";
396
+ import { appendFileSync, existsSync as existsSync2, renameSync as renameSync2, statSync, unlinkSync as unlinkSync2 } from "fs";
397
+ import { dirname as dirname2 } from "path";
210
398
  var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
211
399
  var DEFAULT_KEEP = 3;
212
- var REAL_FS_OPS = { statSync, renameSync, unlinkSync, appendFileSync, existsSync: existsSync2 };
400
+ var REAL_FS_OPS = { statSync, renameSync: renameSync2, unlinkSync: unlinkSync2, appendFileSync, existsSync: existsSync2 };
213
401
  function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) {
214
402
  const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
215
403
  const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
216
- if (!fsOps.existsSync(dirname(path)))
404
+ if (!fsOps.existsSync(dirname2(path)))
217
405
  return;
218
406
  rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep, fsOps);
219
407
  fsOps.appendFileSync(path, content, "utf-8");
@@ -363,6 +551,16 @@ var APP_SERVER_NOTIFICATION_METHODS = [
363
551
  var TRACKED_REQUEST_METHOD_SET = new Set(APP_SERVER_TRACKED_REQUEST_METHODS);
364
552
  var SERVER_REQUEST_METHOD_SET = new Set(APP_SERVER_SERVER_REQUEST_METHODS);
365
553
  var NOTIFICATION_METHOD_SET = new Set(APP_SERVER_NOTIFICATION_METHODS);
554
+ function parseAppServerVersion(userAgent) {
555
+ if (typeof userAgent !== "string")
556
+ return null;
557
+ const match = userAgent.match(/\/([^\s]+)/);
558
+ return match ? match[1] : null;
559
+ }
560
+ var APP_SERVER_RATE_LIMIT_ERROR_CODES = new Set([
561
+ -32603,
562
+ -32600
563
+ ]);
366
564
  function isObjectRecord(value) {
367
565
  return typeof value === "object" && value !== null && !Array.isArray(value);
368
566
  }
@@ -410,7 +608,7 @@ function clampInterruptTimeoutMs(requested) {
410
608
  // src/codex-transport.ts
411
609
  import { createServer, connect } from "net";
412
610
  import { spawnSync } from "child_process";
413
- import { mkdirSync as mkdirSync2, rmSync, chmodSync } from "fs";
611
+ import { mkdirSync as mkdirSync3, rmSync, chmodSync } from "fs";
414
612
  import { join as join2 } from "path";
415
613
  import { tmpdir } from "os";
416
614
  var CODEX_TRANSPORT_ENV = "AGENTBRIDGE_CODEX_TRANSPORT";
@@ -471,7 +669,7 @@ function ensureSocketDir(socketPath) {
471
669
  const dir = socketPath.slice(0, socketPath.lastIndexOf("/"));
472
670
  if (!dir)
473
671
  return;
474
- mkdirSync2(dir, { recursive: true, mode: 448 });
672
+ mkdirSync3(dir, { recursive: true, mode: 448 });
475
673
  try {
476
674
  chmodSync(dir, 448);
477
675
  } catch (err) {
@@ -601,10 +799,13 @@ async function waitForUnixWsReady(socketPath, maxRetries = 40, delayMs = 250) {
601
799
  function attemptUnixWsUpgrade(socketPath) {
602
800
  return new Promise((resolve) => {
603
801
  let settled = false;
802
+ let timeout;
604
803
  const done = (ok) => {
605
804
  if (settled)
606
805
  return;
607
806
  settled = true;
807
+ if (timeout !== undefined)
808
+ clearTimeout(timeout);
608
809
  try {
609
810
  socket.destroy();
610
811
  } catch {}
@@ -629,7 +830,8 @@ Sec-WebSocket-Version: 13\r
629
830
  });
630
831
  socket.on("error", () => done(false));
631
832
  socket.on("close", () => done(false));
632
- setTimeout(() => done(false), 1500);
833
+ timeout = setTimeout(() => done(false), 1500);
834
+ timeout.unref?.();
633
835
  });
634
836
  }
635
837
 
@@ -666,6 +868,76 @@ function wsOriginRejectedResponse() {
666
868
  return new Response("Forbidden: WebSocket Origin not allowed", { status: 403 });
667
869
  }
668
870
 
871
+ // src/pending-request-registry.ts
872
+ class PendingRequestRegistry {
873
+ entries = new Map;
874
+ setTimer;
875
+ clearTimer;
876
+ constructor(deps = {}) {
877
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
878
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
879
+ }
880
+ get size() {
881
+ return this.entries.size;
882
+ }
883
+ has(id) {
884
+ return this.entries.has(id);
885
+ }
886
+ register(id, options) {
887
+ const existing = this.entries.get(id);
888
+ if (existing) {
889
+ this.clearTimer(existing.timer);
890
+ this.entries.delete(id);
891
+ }
892
+ return new Promise((resolve, reject) => {
893
+ const timer = this.setTimer(() => {
894
+ if (!this.entries.has(id))
895
+ return;
896
+ this.entries.delete(id);
897
+ options.onTimeout({ resolve, reject });
898
+ }, options.timeoutMs);
899
+ if (options.unref) {
900
+ timer.unref?.();
901
+ }
902
+ this.entries.set(id, { resolve, reject, timer });
903
+ });
904
+ }
905
+ settle(id, value) {
906
+ const entry = this.entries.get(id);
907
+ if (!entry)
908
+ return false;
909
+ this.clearTimer(entry.timer);
910
+ this.entries.delete(id);
911
+ entry.resolve(value);
912
+ return true;
913
+ }
914
+ reject(id, error) {
915
+ const entry = this.entries.get(id);
916
+ if (!entry)
917
+ return false;
918
+ this.clearTimer(entry.timer);
919
+ this.entries.delete(id);
920
+ entry.reject(error);
921
+ return true;
922
+ }
923
+ settleAll(value) {
924
+ const make = typeof value === "function" ? value : () => value;
925
+ for (const [id, entry] of this.entries) {
926
+ this.clearTimer(entry.timer);
927
+ this.entries.delete(id);
928
+ entry.resolve(make(id));
929
+ }
930
+ }
931
+ rejectAll(error) {
932
+ const make = typeof error === "function" ? error : () => error;
933
+ for (const [id, entry] of this.entries) {
934
+ this.clearTimer(entry.timer);
935
+ this.entries.delete(id);
936
+ entry.reject(make(id));
937
+ }
938
+ }
939
+ }
940
+
669
941
  // src/codex-adapter.ts
670
942
  class CodexAdapter extends EventEmitter {
671
943
  static RESPONSE_TRACKING_TTL_MS = 30000;
@@ -715,8 +987,13 @@ class CodexAdapter extends EventEmitter {
715
987
  static OUTAGE_TIMEOUT_MS = 1e4;
716
988
  lastInitializeRaw = null;
717
989
  lastInitializedRaw = null;
990
+ pendingInitializeProxyIds = new Set;
991
+ appServerInfo = null;
992
+ warnedAppServerVersions = new Set;
993
+ warnedFragileRateLimitMessages = new Set;
718
994
  sessionRestoreInProgress = false;
719
- replayPending = new Map;
995
+ replayPending = new PendingRequestRegistry;
996
+ replayMethods = new Map;
720
997
  static SESSION_REPLAY_TIMEOUT_MS = 5000;
721
998
  constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) {
722
999
  super();
@@ -734,16 +1011,40 @@ class CodexAdapter extends EventEmitter {
734
1011
  get activeThreadId() {
735
1012
  return this.threadId;
736
1013
  }
1014
+ get capturedAppServerInfo() {
1015
+ return this.appServerInfo;
1016
+ }
737
1017
  async start() {
738
1018
  this.intentionalDisconnect = false;
739
1019
  await this.checkPorts();
740
- this.resolveTransport();
741
- const listen = codexListenArg(this.transport, this.appPort, this.socketPath ?? "");
742
- if (this.transport === "unix" && this.socketPath) {
743
- ensureSocketDir(this.socketPath);
744
- removeSocketFile(this.socketPath);
1020
+ try {
1021
+ this.resolveTransport();
1022
+ const listen = codexListenArg(this.transport, this.appPort, this.socketPath ?? "");
1023
+ if (this.transport === "unix" && this.socketPath) {
1024
+ ensureSocketDir(this.socketPath);
1025
+ removeSocketFile(this.socketPath);
1026
+ }
1027
+ this.log(`Spawning codex app-server (transport=${this.transport}) --listen ${listen}`);
1028
+ this.spawnAppServer(listen);
1029
+ if (this.transport === "unix" && this.socketPath) {
1030
+ await waitForUnixWsReady(this.socketPath);
1031
+ this.relay = new TcpToUnixRelay("127.0.0.1", this.appPort, this.socketPath, (m) => this.log(`[relay] ${m}`));
1032
+ await this.relay.start();
1033
+ this.log(`Transport relay ready: ws://127.0.0.1:${this.appPort} \u2192 unix://${this.socketPath}`);
1034
+ } else {
1035
+ await this.waitForHealthy();
1036
+ }
1037
+ await this.connectToAppServer();
1038
+ this.startProxy();
1039
+ this.log(`Proxy ready on ${this.proxyUrl}`);
1040
+ } catch (err) {
1041
+ const m = err instanceof Error ? err.message : String(err);
1042
+ this.log(`start() failed (${m}) \u2014 tearing down partial transport before rethrow`);
1043
+ this.cleanupAfterFailedStart();
1044
+ throw err;
745
1045
  }
746
- this.log(`Spawning codex app-server (transport=${this.transport}) --listen ${listen}`);
1046
+ }
1047
+ spawnAppServer(listen) {
747
1048
  this.proc = spawn("codex", ["app-server", "--listen", listen], {
748
1049
  stdio: ["pipe", "pipe", "pipe"]
749
1050
  });
@@ -757,17 +1058,25 @@ class CodexAdapter extends EventEmitter {
757
1058
  stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
758
1059
  const stdoutRl = createInterface({ input: this.proc.stdout });
759
1060
  stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
760
- if (this.transport === "unix" && this.socketPath) {
761
- await waitForUnixWsReady(this.socketPath);
762
- this.relay = new TcpToUnixRelay("127.0.0.1", this.appPort, this.socketPath, (m) => this.log(`[relay] ${m}`));
763
- await this.relay.start();
764
- this.log(`Transport relay ready: ws://127.0.0.1:${this.appPort} \u2192 unix://${this.socketPath}`);
765
- } else {
766
- await this.waitForHealthy();
1061
+ }
1062
+ teardownTransport() {
1063
+ this.proxyServer?.stop();
1064
+ this.proxyServer = null;
1065
+ if (this.relay) {
1066
+ this.relay.stop();
1067
+ this.relay = null;
767
1068
  }
768
- await this.connectToAppServer();
769
- this.startProxy();
770
- this.log(`Proxy ready on ${this.proxyUrl}`);
1069
+ if (this.socketPath)
1070
+ removeSocketFile(this.socketPath);
1071
+ }
1072
+ cleanupAfterFailedStart() {
1073
+ try {
1074
+ this.teardownTransport();
1075
+ } catch (e) {
1076
+ this.log(`cleanupAfterFailedStart: teardownTransport error: ${e.message}`);
1077
+ }
1078
+ this.forceKillAppServerSync();
1079
+ this.proc = null;
771
1080
  }
772
1081
  resolveTransport() {
773
1082
  const mode = parseTransportMode(process.env[CODEX_TRANSPORT_ENV]);
@@ -791,14 +1100,7 @@ class CodexAdapter extends EventEmitter {
791
1100
  } catch {}
792
1101
  this.secondaryConnections.delete(id);
793
1102
  }
794
- this.proxyServer?.stop();
795
- this.proxyServer = null;
796
- if (this.relay) {
797
- this.relay.stop();
798
- this.relay = null;
799
- }
800
- if (this.socketPath)
801
- removeSocketFile(this.socketPath);
1103
+ this.teardownTransport();
802
1104
  this.clearResponseTrackingState();
803
1105
  this.resetTurnState(ADAPTER_DISCONNECT_REASON);
804
1106
  }
@@ -1225,36 +1527,38 @@ class CodexAdapter extends EventEmitter {
1225
1527
  const m = e instanceof Error ? e.message : String(e);
1226
1528
  return Promise.reject(new Error(`replay parse failed for ${method}: ${m}`));
1227
1529
  }
1228
- return new Promise((resolve, reject) => {
1229
- const timer = setTimeout(() => {
1230
- this.replayPending.delete(id);
1231
- reject(new Error(`replay timeout (${CodexAdapter.SESSION_REPLAY_TIMEOUT_MS}ms) for ${method} id=${JSON.stringify(id)}`));
1232
- }, CodexAdapter.SESSION_REPLAY_TIMEOUT_MS);
1233
- this.replayPending.set(id, { method, resolve, reject, timer });
1234
- try {
1235
- this.appServerWs.send(raw);
1236
- } catch (e) {
1237
- clearTimeout(timer);
1238
- this.replayPending.delete(id);
1239
- const m = e instanceof Error ? e.message : String(e);
1240
- reject(new Error(`replay send failed for ${method}: ${m}`));
1530
+ const timeoutMs = CodexAdapter.SESSION_REPLAY_TIMEOUT_MS;
1531
+ this.replayMethods.set(id, method);
1532
+ const pending = this.replayPending.register(id, {
1533
+ timeoutMs,
1534
+ onTimeout: ({ reject }) => {
1535
+ this.replayMethods.delete(id);
1536
+ reject(new Error(`replay timeout (${timeoutMs}ms) for ${method} id=${JSON.stringify(id)}`));
1241
1537
  }
1242
1538
  });
1539
+ try {
1540
+ this.appServerWs.send(raw);
1541
+ } catch (e) {
1542
+ this.replayMethods.delete(id);
1543
+ const m = e instanceof Error ? e.message : String(e);
1544
+ this.replayPending.reject(id, new Error(`replay send failed for ${method}: ${m}`));
1545
+ }
1546
+ return pending;
1243
1547
  }
1244
1548
  tryConsumeReplayResponse(payload) {
1245
1549
  const id = payload.id;
1246
1550
  if (id === undefined)
1247
1551
  return false;
1248
- const pending = this.replayPending.get(id);
1249
- if (!pending)
1552
+ const key = id;
1553
+ if (!this.replayPending.has(key))
1250
1554
  return false;
1251
- clearTimeout(pending.timer);
1252
- this.replayPending.delete(id);
1555
+ const method = this.replayMethods.get(key) ?? "replay";
1556
+ this.replayMethods.delete(key);
1253
1557
  if (payload.error !== undefined) {
1254
1558
  const errMsg = typeof payload.error === "object" && payload.error !== null && "message" in payload.error ? String(payload.error.message ?? "unknown") : JSON.stringify(payload.error);
1255
- pending.reject(new Error(`${pending.method} rejected: ${errMsg}`));
1559
+ this.replayPending.reject(key, new Error(`${method} rejected: ${errMsg}`));
1256
1560
  } else {
1257
- pending.resolve(payload);
1561
+ this.replayPending.settle(key, payload);
1258
1562
  }
1259
1563
  return true;
1260
1564
  }
@@ -1549,6 +1853,9 @@ class CodexAdapter extends EventEmitter {
1549
1853
  const proxyId = this.nextProxyId++;
1550
1854
  this.upstreamToClient.set(proxyId, { connId, clientId: parsed.id });
1551
1855
  this.trackPendingRequest(parsed, connId, proxyId);
1856
+ if (parsed.method === "initialize") {
1857
+ this.pendingInitializeProxyIds.add(proxyId);
1858
+ }
1552
1859
  parsed.id = proxyId;
1553
1860
  forwarded = JSON.stringify(parsed);
1554
1861
  } else {
@@ -1701,6 +2008,9 @@ class CodexAdapter extends EventEmitter {
1701
2008
  const mapping = !isNaN(numericId) ? this.upstreamToClient.get(numericId) : undefined;
1702
2009
  if (mapping) {
1703
2010
  this.upstreamToClient.delete(numericId);
2011
+ if (!isNaN(numericId) && this.pendingInitializeProxyIds.delete(numericId)) {
2012
+ this.captureAppServerInfo(parsed.result);
2013
+ }
1704
2014
  if (mapping.connId !== this.tuiConnId) {
1705
2015
  this.log(`Dropping stale response (upstream id ${responseId}, from conn #${mapping.connId}, current #${this.tuiConnId})`);
1706
2016
  return null;
@@ -1751,11 +2061,40 @@ class CodexAdapter extends EventEmitter {
1751
2061
  this.log(`Dropping unmatched app-server response id ${String(responseId)}`);
1752
2062
  return null;
1753
2063
  }
2064
+ captureAppServerInfo(result) {
2065
+ const init = typeof result === "object" && result !== null ? result : {};
2066
+ const userAgent = typeof init.userAgent === "string" ? init.userAgent : null;
2067
+ const version = parseAppServerVersion(userAgent);
2068
+ const info = {
2069
+ version,
2070
+ userAgent,
2071
+ platformFamily: typeof init.platformFamily === "string" ? init.platformFamily : null,
2072
+ platformOs: typeof init.platformOs === "string" ? init.platformOs : null
2073
+ };
2074
+ this.appServerInfo = info;
2075
+ this.log(`Captured app-server initialize: version=${version ?? "unknown"} ` + `userAgent=${userAgent ?? "none"} platform=${info.platformOs ?? "?"}/${info.platformFamily ?? "?"}`);
2076
+ if (version === null) {
2077
+ const dedupKey = userAgent ?? "<missing-userAgent>";
2078
+ if (!this.warnedAppServerVersions.has(dedupKey)) {
2079
+ this.warnedAppServerVersions.add(dedupKey);
2080
+ this.log(`WARNING: app-server initialize response carried no parseable version ` + `(userAgent=${userAgent ?? "missing"}). The proxy's intercept points assume a ` + `known protocol snapshot \u2014 verify the version-coupling checklist if Codex was upgraded.`);
2081
+ }
2082
+ }
2083
+ }
1754
2084
  patchResponse(parsed, raw) {
1755
2085
  if (isAppServerResponseMessage(parsed) && parsed.error && parsed.id !== undefined) {
1756
2086
  const errMsg = parsed.error.message ?? "";
1757
- if (errMsg.includes("rate limits") || errMsg.includes("rateLimits")) {
1758
- this.log(`Patching rateLimits error \u2192 mock success (id: ${parsed.id})`);
2087
+ const errCode = parsed.error.code;
2088
+ const textMatchesRateLimit = errMsg.includes("rate limits") || errMsg.includes("rateLimits");
2089
+ const codeRecognized = typeof errCode === "number" && APP_SERVER_RATE_LIMIT_ERROR_CODES.has(errCode);
2090
+ const structuredMatch = codeRecognized && textMatchesRateLimit;
2091
+ if (structuredMatch || textMatchesRateLimit) {
2092
+ if (structuredMatch) {
2093
+ this.log(`Patching rateLimits error \u2192 mock success via structured code ${errCode} (id: ${parsed.id})`);
2094
+ } else {
2095
+ this.warnFragileRateLimitMatch(errMsg, errCode);
2096
+ this.log(`Patching rateLimits error \u2192 mock success via fragile text fallback (id: ${parsed.id})`);
2097
+ }
1759
2098
  return JSON.stringify({
1760
2099
  id: parsed.id,
1761
2100
  result: {
@@ -1774,6 +2113,12 @@ class CodexAdapter extends EventEmitter {
1774
2113
  }
1775
2114
  return raw;
1776
2115
  }
2116
+ warnFragileRateLimitMatch(errMsg, errCode) {
2117
+ if (this.warnedFragileRateLimitMessages.has(errMsg))
2118
+ return;
2119
+ this.warnedFragileRateLimitMessages.add(errMsg);
2120
+ this.log(`WARNING: fragile-match \u2014 patched a rate-limit error by human-readable text ` + `(code=${errCode ?? "none"} not in the recognized set). If Codex changed the ` + `error wording or code, update patchResponse / APP_SERVER_RATE_LIMIT_ERROR_CODES. ` + `Message: ${errMsg.slice(0, 120)}`);
2121
+ }
1777
2122
  interceptServerMessage(msg, connId) {
1778
2123
  this.handleTrackedResponse(msg, connId);
1779
2124
  if ("method" in msg && typeof msg.method === "string" && isAppServerNotification(msg)) {
@@ -1977,15 +2322,27 @@ class CodexAdapter extends EventEmitter {
1977
2322
  markTurnCompleted(turnId) {
1978
2323
  const completedId = typeof turnId === "string" && turnId.length > 0 ? turnId : null;
1979
2324
  if (completedId !== null) {
2325
+ const idWasTracked = this.activeTurnIds.has(completedId);
1980
2326
  this.activeTurnIds.delete(completedId);
1981
2327
  this.clearTurnWatchdog(completedId);
1982
2328
  this.stalledTurnIds.delete(completedId);
1983
2329
  this.currentlyStalledTurnIds.delete(completedId);
2330
+ if (!idWasTracked) {
2331
+ const placeholders = [...this.activeTurnIds].filter((id) => id.startsWith("unknown:"));
2332
+ if (placeholders.length === 1) {
2333
+ const placeholder = placeholders[0];
2334
+ this.activeTurnIds.delete(placeholder);
2335
+ this.clearTurnWatchdog(placeholder);
2336
+ this.stalledTurnIds.delete(placeholder);
2337
+ this.currentlyStalledTurnIds.delete(placeholder);
2338
+ }
2339
+ }
1984
2340
  } else {
1985
2341
  this.activeTurnIds.clear();
1986
2342
  this.clearAllTurnWatchdogs();
1987
2343
  this.stalledTurnIds.clear();
1988
2344
  this.currentlyStalledTurnIds.clear();
2345
+ this.agentMessageBuffers.clear();
1989
2346
  }
1990
2347
  this.lastTurnEndedAbnormally = false;
1991
2348
  this.turnInProgress = this.activeTurnIds.size > 0;
@@ -2048,6 +2405,7 @@ class CodexAdapter extends EventEmitter {
2048
2405
  this.clearAllTurnWatchdogs();
2049
2406
  this.stalledTurnIds.clear();
2050
2407
  this.currentlyStalledTurnIds.clear();
2408
+ this.agentMessageBuffers.clear();
2051
2409
  this.turnInProgress = false;
2052
2410
  if (wasInProgress) {
2053
2411
  this.lastTurnEndedAbnormally = !emitCompleted;
@@ -2136,6 +2494,7 @@ class CodexAdapter extends EventEmitter {
2136
2494
  clearTransientResponseTrackingState() {
2137
2495
  this.pendingRequests.clear();
2138
2496
  this.upstreamToClient.clear();
2497
+ this.pendingInitializeProxyIds.clear();
2139
2498
  for (const timer of this.staleProxyIds.values()) {
2140
2499
  clearTimeout(timer);
2141
2500
  }
@@ -2191,11 +2550,65 @@ var CLOSE_CODE_REPLACED = 4001;
2191
2550
  var CLOSE_CODE_EVICTED_STALE = 4002;
2192
2551
  var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
2193
2552
  var CLOSE_CODE_PAIR_MISMATCH = 4004;
2553
+ var CLOSE_CODE_TOKEN_MISMATCH = 4005;
2554
+ var CLOSE_CODE_CONTRACT_MISMATCH = 4006;
2555
+
2556
+ // src/control-token.ts
2557
+ import { chmodSync as chmodSync2, readFileSync as readFileSync2 } from "fs";
2558
+ import { join as join3 } from "path";
2559
+ import { randomUUID as randomUUID2 } from "crypto";
2560
+ var CONTROL_TOKEN_FILENAME = "control-token";
2561
+ function resolveControlTokenPath(stateDir) {
2562
+ return join3(stateDir, CONTROL_TOKEN_FILENAME);
2563
+ }
2564
+ function generateControlToken() {
2565
+ return randomUUID2();
2566
+ }
2567
+ function writeControlToken(path, token) {
2568
+ atomicWriteText(path, token, { mode: 384 });
2569
+ chmodSync2(path, 384);
2570
+ }
2571
+ function validateControlToken(input) {
2572
+ const { expectedToken } = input;
2573
+ if (expectedToken == null || expectedToken.length === 0) {
2574
+ return { ok: true };
2575
+ }
2576
+ const provided = input.providedToken;
2577
+ if (provided == null || provided.length === 0) {
2578
+ return { ok: false, reason: "missing control token" };
2579
+ }
2580
+ if (!constantTimeEquals(provided, expectedToken)) {
2581
+ return { ok: false, reason: "control token mismatch" };
2582
+ }
2583
+ return { ok: true };
2584
+ }
2585
+ function constantTimeEquals(a, b) {
2586
+ const len = Math.max(a.length, b.length);
2587
+ let diff = a.length ^ b.length;
2588
+ for (let i = 0;i < len; i++) {
2589
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
2590
+ }
2591
+ return diff === 0;
2592
+ }
2194
2593
 
2195
2594
  // src/daemon-identity.ts
2196
2595
  function validateClaudeClientIdentity(input) {
2197
- if (!input.expectedPairId)
2198
- return { ok: true };
2596
+ if (input.expectedControlToken && input.identity) {
2597
+ const tokenResult = validateControlToken({
2598
+ expectedToken: input.expectedControlToken,
2599
+ providedToken: input.identity.controlToken
2600
+ });
2601
+ if (!tokenResult.ok) {
2602
+ return {
2603
+ ok: false,
2604
+ closeCode: CLOSE_CODE_TOKEN_MISMATCH,
2605
+ reason: tokenResult.reason
2606
+ };
2607
+ }
2608
+ }
2609
+ if (!input.expectedPairId) {
2610
+ return input.identity ? validateContractVersion(input) : { ok: true };
2611
+ }
2199
2612
  if (!input.identity) {
2200
2613
  return input.allowIdentityless ? { ok: true } : { ok: false, closeCode: CLOSE_CODE_PAIR_MISMATCH, reason: "missing client identity" };
2201
2614
  }
@@ -2213,10 +2626,43 @@ function validateClaudeClientIdentity(input) {
2213
2626
  reason: `cwd mismatch: expected ${input.daemonCwd}, got ${input.identity.cwd ?? "<none>"}`
2214
2627
  };
2215
2628
  }
2629
+ return validateContractVersion(input);
2630
+ }
2631
+ function validateContractVersion(input) {
2632
+ if (input.expectedContractVersion === undefined)
2633
+ return { ok: true };
2634
+ const provided = input.identity?.contractVersion;
2635
+ if (provided === undefined || provided === null) {
2636
+ return {
2637
+ ok: false,
2638
+ closeCode: CLOSE_CODE_CONTRACT_MISMATCH,
2639
+ reason: `missing contract version: daemon speaks contract v${input.expectedContractVersion}`
2640
+ };
2641
+ }
2642
+ if (provided !== input.expectedContractVersion) {
2643
+ return {
2644
+ ok: false,
2645
+ closeCode: CLOSE_CODE_CONTRACT_MISMATCH,
2646
+ reason: `contract version mismatch: daemon v${input.expectedContractVersion}, client v${provided}`
2647
+ };
2648
+ }
2216
2649
  return { ok: true };
2217
2650
  }
2651
+ function evaluateInjectionAttachGuard(attachedSocket, requestingSocket) {
2652
+ if (attachedSocket != null && attachedSocket === requestingSocket) {
2653
+ return { allowed: true };
2654
+ }
2655
+ return {
2656
+ allowed: false,
2657
+ code: "not_attached",
2658
+ reason: "This socket is not the attached Claude session. Send `claude_connect` " + "(with a valid control token) and win the attach slot before injecting a turn."
2659
+ };
2660
+ }
2218
2661
 
2219
2662
  // src/message-filter.ts
2663
+ import { randomUUID as randomUUID3 } from "crypto";
2664
+ var STATUS_SUMMARY_SALT = randomUUID3().slice(0, 8);
2665
+ var statusSummaryCounter = 0;
2220
2666
  var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
2221
2667
  function parseMarker(content) {
2222
2668
  const match = content.match(MARKER_REGEX);
@@ -2242,6 +2688,37 @@ function classifyMessage(content, mode) {
2242
2688
  return { action: "forward", marker };
2243
2689
  }
2244
2690
  }
2691
+ function routeCodexMessage(content, ctx) {
2692
+ const result = classifyMessage(content, ctx.mode);
2693
+ if (ctx.replyArmed) {
2694
+ return {
2695
+ action: "forward",
2696
+ marker: result.marker,
2697
+ reason: "force-forward-reply-required",
2698
+ flushStatusBuffer: true,
2699
+ noteReplyForwarded: true
2700
+ };
2701
+ }
2702
+ if (ctx.inAttentionWindow && result.marker === "status") {
2703
+ return {
2704
+ action: "buffer",
2705
+ marker: result.marker,
2706
+ reason: "buffer-attention"
2707
+ };
2708
+ }
2709
+ if (result.action === "forward" && result.marker === "important") {
2710
+ return {
2711
+ ...result,
2712
+ reason: "forward",
2713
+ flushStatusBuffer: true,
2714
+ startAttentionWindow: true
2715
+ };
2716
+ }
2717
+ return {
2718
+ ...result,
2719
+ reason: result.action
2720
+ };
2721
+ }
2245
2722
  var REPLY_REQUIRED_INSTRUCTION = `
2246
2723
 
2247
2724
  [\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.`;
@@ -2251,11 +2728,14 @@ class StatusBuffer {
2251
2728
  flushTimer = null;
2252
2729
  flushThreshold;
2253
2730
  flushTimeoutMs;
2731
+ maxBuffered;
2254
2732
  paused = false;
2733
+ droppedCount = 0;
2255
2734
  constructor(onFlush, options) {
2256
2735
  this.onFlush = onFlush;
2257
2736
  this.flushThreshold = options?.flushThreshold ?? 3;
2258
2737
  this.flushTimeoutMs = options?.flushTimeoutMs ?? 15000;
2738
+ this.maxBuffered = options?.maxBuffered ?? 200;
2259
2739
  }
2260
2740
  get size() {
2261
2741
  return this.buffer.length;
@@ -2275,6 +2755,10 @@ class StatusBuffer {
2275
2755
  }
2276
2756
  add(message) {
2277
2757
  this.buffer.push(message);
2758
+ while (this.buffer.length > this.maxBuffered) {
2759
+ this.buffer.shift();
2760
+ this.droppedCount++;
2761
+ }
2278
2762
  if (this.paused)
2279
2763
  return;
2280
2764
  this.resetTimer();
@@ -2289,19 +2773,22 @@ class StatusBuffer {
2289
2773
  const combined = this.buffer.map((m) => parseMarker(m.content).body).join(`
2290
2774
  ---
2291
2775
  `);
2776
+ const droppedNote = this.droppedCount > 0 ? `, ${this.droppedCount} older dropped` : "";
2292
2777
  const summary = {
2293
- id: `status_summary_${Date.now()}`,
2778
+ id: `status_summary_${STATUS_SUMMARY_SALT}_${++statusSummaryCounter}`,
2294
2779
  source: "codex",
2295
- content: `[STATUS summary \u2014 ${this.buffer.length} update(s), flushed: ${reason}]
2780
+ content: `[STATUS summary \u2014 ${this.buffer.length} update(s)${droppedNote}, flushed: ${reason}]
2296
2781
  ${combined}`,
2297
2782
  timestamp: Date.now()
2298
2783
  };
2299
2784
  this.onFlush(summary);
2300
2785
  this.buffer = [];
2786
+ this.droppedCount = 0;
2301
2787
  }
2302
2788
  dispose() {
2303
2789
  this.clearTimer();
2304
2790
  this.buffer = [];
2791
+ this.droppedCount = 0;
2305
2792
  }
2306
2793
  clearTimer() {
2307
2794
  if (this.flushTimer) {
@@ -2398,7 +2885,7 @@ class TuiConnectionState {
2398
2885
 
2399
2886
  // src/daemon-lifecycle.ts
2400
2887
  import { spawn as spawn2 } from "child_process";
2401
- import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
2888
+ import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync2, openSync as openSync2, closeSync as closeSync2, constants } from "fs";
2402
2889
  import { fileURLToPath } from "url";
2403
2890
 
2404
2891
  // src/process-lifecycle.ts
@@ -2442,6 +2929,8 @@ var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
2442
2929
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
2443
2930
  var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
2444
2931
  var REUSE_READY_DELAY_MS = 250;
2932
+ var WAIT_READY_RETRIES = 40;
2933
+ var WAIT_READY_DELAY_MS = 250;
2445
2934
  var HEALTH_FETCH_TIMEOUT_MS = 500;
2446
2935
  var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
2447
2936
  function isReuseVerdict(verdict) {
@@ -2479,22 +2968,33 @@ function classifyDaemon(expectedPairId, status, buildInfo) {
2479
2968
  reason: "runtime build drift has a compatible contract and a live Codex TUI is attached"
2480
2969
  };
2481
2970
  }
2971
+ const basis = runtimeContractComparisonBasis(status.build, buildInfo) === "codeHash" ? "compared by codeHash" : "compared by commit stamp; legacy build without codeHash";
2482
2972
  return {
2483
2973
  verdict: "replace-drifted",
2484
- reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ${formatBuildInfo(buildInfo)}`
2974
+ reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ` + `${formatBuildInfo(buildInfo)} (${basis})`
2485
2975
  };
2486
2976
  }
2487
2977
  return { verdict: "reuse", reason: "daemon pair and runtime contract match" };
2488
2978
  }
2979
+ function resolveTiming(timing) {
2980
+ return {
2981
+ reuseReadyRetries: timing?.reuseReadyRetries ?? REUSE_READY_RETRIES,
2982
+ reuseReadyDelayMs: timing?.reuseReadyDelayMs ?? REUSE_READY_DELAY_MS,
2983
+ waitReadyRetries: timing?.waitReadyRetries ?? WAIT_READY_RETRIES,
2984
+ waitReadyDelayMs: timing?.waitReadyDelayMs ?? WAIT_READY_DELAY_MS
2985
+ };
2986
+ }
2489
2987
 
2490
2988
  class DaemonLifecycle {
2491
2989
  stateDir;
2492
2990
  controlPort;
2493
2991
  log;
2992
+ timing;
2494
2993
  constructor(opts) {
2495
2994
  this.stateDir = opts.stateDir;
2496
2995
  this.controlPort = opts.controlPort;
2497
2996
  this.log = opts.log;
2997
+ this.timing = resolveTiming(opts.timing);
2498
2998
  }
2499
2999
  get healthUrl() {
2500
3000
  return `http://127.0.0.1:${this.controlPort}/healthz`;
@@ -2551,7 +3051,7 @@ class DaemonLifecycle {
2551
3051
  break;
2552
3052
  }
2553
3053
  try {
2554
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3054
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2555
3055
  return;
2556
3056
  } catch {
2557
3057
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
@@ -2564,7 +3064,7 @@ class DaemonLifecycle {
2564
3064
  if (isProcessAlive(existingPid)) {
2565
3065
  if (isAgentBridgeDaemon(existingPid)) {
2566
3066
  try {
2567
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3067
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2568
3068
  return;
2569
3069
  } catch {
2570
3070
  this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
@@ -2592,7 +3092,7 @@ class DaemonLifecycle {
2592
3092
  await this.kill(3000, status?.pid);
2593
3093
  } else {
2594
3094
  try {
2595
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3095
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2596
3096
  return;
2597
3097
  } catch {
2598
3098
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
@@ -2601,7 +3101,7 @@ class DaemonLifecycle {
2601
3101
  }
2602
3102
  }
2603
3103
  this.launch();
2604
- await this.waitForReady();
3104
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
2605
3105
  });
2606
3106
  }
2607
3107
  async isHealthy() {
@@ -2628,7 +3128,7 @@ class DaemonLifecycle {
2628
3128
  return false;
2629
3129
  }
2630
3130
  }
2631
- async waitForReady(maxRetries = 40, delayMs = 250) {
3131
+ async waitForReady(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
2632
3132
  for (let attempt = 0;attempt < maxRetries; attempt++) {
2633
3133
  if (await this.isReady())
2634
3134
  return;
@@ -2636,7 +3136,7 @@ class DaemonLifecycle {
2636
3136
  }
2637
3137
  throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
2638
3138
  }
2639
- async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
3139
+ async waitForReadyAndOurs(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
2640
3140
  for (let attempt = 0;attempt < maxRetries; attempt++) {
2641
3141
  if (await this.isReady()) {
2642
3142
  const status = await this.fetchStatus();
@@ -2652,22 +3152,35 @@ class DaemonLifecycle {
2652
3152
  }
2653
3153
  throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
2654
3154
  }
3155
+ readDaemonRecord() {
3156
+ return readUnifiedDaemonRecord({
3157
+ daemonRecordFile: this.stateDir.daemonRecordFile,
3158
+ pidFile: this.stateDir.pidFile,
3159
+ statusFile: this.stateDir.statusFile
3160
+ });
3161
+ }
3162
+ writeDaemonRecord(record) {
3163
+ writeDaemonRecord(this.stateDir.daemonRecordFile, record);
3164
+ }
3165
+ removeDaemonRecord() {
3166
+ try {
3167
+ unlinkSync3(this.stateDir.daemonRecordFile);
3168
+ } catch {}
3169
+ }
2655
3170
  readStatus() {
2656
3171
  try {
2657
- const raw = readFileSync(this.stateDir.statusFile, "utf-8");
3172
+ const raw = readFileSync3(this.stateDir.statusFile, "utf-8");
2658
3173
  return JSON.parse(raw);
2659
3174
  } catch {
2660
3175
  return null;
2661
3176
  }
2662
3177
  }
2663
3178
  writeStatus(status) {
2664
- this.stateDir.ensure();
2665
- writeFileSync(this.stateDir.statusFile, JSON.stringify(status, null, 2) + `
2666
- `, "utf-8");
3179
+ atomicWriteJson(this.stateDir.statusFile, status);
2667
3180
  }
2668
3181
  readPid() {
2669
3182
  try {
2670
- const raw = readFileSync(this.stateDir.pidFile, "utf-8").trim();
3183
+ const raw = readFileSync3(this.stateDir.pidFile, "utf-8").trim();
2671
3184
  if (!raw)
2672
3185
  return null;
2673
3186
  const pid = Number.parseInt(raw, 10);
@@ -2677,28 +3190,27 @@ class DaemonLifecycle {
2677
3190
  }
2678
3191
  }
2679
3192
  writePid(pid) {
2680
- this.stateDir.ensure();
2681
- writeFileSync(this.stateDir.pidFile, `${pid ?? process.pid}
2682
- `, "utf-8");
3193
+ atomicWriteText(this.stateDir.pidFile, `${pid ?? process.pid}
3194
+ `);
2683
3195
  }
2684
3196
  removePidFile() {
2685
3197
  try {
2686
- unlinkSync2(this.stateDir.pidFile);
3198
+ unlinkSync3(this.stateDir.pidFile);
2687
3199
  } catch {}
2688
3200
  }
2689
3201
  removeStatusFile() {
2690
3202
  try {
2691
- unlinkSync2(this.stateDir.statusFile);
3203
+ unlinkSync3(this.stateDir.statusFile);
2692
3204
  } catch {}
2693
3205
  }
2694
3206
  markKilled() {
2695
3207
  this.stateDir.ensure();
2696
- writeFileSync(this.stateDir.killedFile, `${Date.now()}
3208
+ writeFileSync2(this.stateDir.killedFile, `${Date.now()}
2697
3209
  `, "utf-8");
2698
3210
  }
2699
3211
  clearKilled() {
2700
3212
  try {
2701
- unlinkSync2(this.stateDir.killedFile);
3213
+ unlinkSync3(this.stateDir.killedFile);
2702
3214
  } catch {}
2703
3215
  }
2704
3216
  wasKilled() {
@@ -2720,8 +3232,10 @@ class DaemonLifecycle {
2720
3232
  daemonProc.unref();
2721
3233
  }
2722
3234
  removeStalePidFile() {
2723
- this.log("Removing stale pid file");
3235
+ this.log("Removing stale daemon identity files");
2724
3236
  this.removePidFile();
3237
+ this.removeStatusFile();
3238
+ this.removeDaemonRecord();
2725
3239
  }
2726
3240
  async replaceUnhealthyDaemon(statusPid) {
2727
3241
  await this.withStartupLockStrict(async (locked) => {
@@ -2737,7 +3251,7 @@ class DaemonLifecycle {
2737
3251
  }
2738
3252
  if (isReuseVerdict(classification.verdict)) {
2739
3253
  try {
2740
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3254
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2741
3255
  return;
2742
3256
  } catch {}
2743
3257
  }
@@ -2745,12 +3259,12 @@ class DaemonLifecycle {
2745
3259
  this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
2746
3260
  await this.kill(3000, statusPid);
2747
3261
  this.launch();
2748
- await this.waitForReady();
3262
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
2749
3263
  });
2750
3264
  }
2751
3265
  async waitForContendedStartupLock() {
2752
3266
  this.log("Another process holds the startup lock, waiting for readiness+identity...");
2753
- await this.waitForReadyAndOurs();
3267
+ await this.waitForReadyAndOurs(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
2754
3268
  }
2755
3269
  async withStartupLockStrict(fn) {
2756
3270
  const locked = this.acquireLockStrict();
@@ -2765,15 +3279,15 @@ class DaemonLifecycle {
2765
3279
  this.stateDir.ensure();
2766
3280
  let fd = null;
2767
3281
  try {
2768
- fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
2769
- writeFileSync(fd, `${process.pid}
3282
+ fd = openSync2(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
3283
+ writeFileSync2(fd, `${process.pid}
2770
3284
  `);
2771
- closeSync(fd);
3285
+ closeSync2(fd);
2772
3286
  return true;
2773
3287
  } catch (err) {
2774
3288
  if (fd !== null && err.code !== "EEXIST") {
2775
3289
  try {
2776
- closeSync(fd);
3290
+ closeSync2(fd);
2777
3291
  } catch {}
2778
3292
  this.releaseLock();
2779
3293
  }
@@ -2781,7 +3295,7 @@ class DaemonLifecycle {
2781
3295
  if (reclaimed)
2782
3296
  return false;
2783
3297
  try {
2784
- const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
3298
+ const holderPid = Number.parseInt(readFileSync3(this.stateDir.lockFile, "utf-8").trim(), 10);
2785
3299
  if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
2786
3300
  this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
2787
3301
  this.releaseLock();
@@ -2810,7 +3324,7 @@ class DaemonLifecycle {
2810
3324
  }
2811
3325
  releaseLock() {
2812
3326
  try {
2813
- unlinkSync2(this.stateDir.lockFile);
3327
+ unlinkSync3(this.stateDir.lockFile);
2814
3328
  } catch {}
2815
3329
  }
2816
3330
  async kill(gracefulTimeoutMs = 3000, pidOverride) {
@@ -2856,6 +3370,7 @@ class DaemonLifecycle {
2856
3370
  cleanup() {
2857
3371
  this.removePidFile();
2858
3372
  this.removeStatusFile();
3373
+ this.removeDaemonRecord();
2859
3374
  }
2860
3375
  }
2861
3376
  async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
@@ -2869,8 +3384,8 @@ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
2869
3384
  }
2870
3385
 
2871
3386
  // src/config-service.ts
2872
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
2873
- import { join as join3 } from "path";
3387
+ import { readFileSync as readFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "fs";
3388
+ import { join as join4 } from "path";
2874
3389
  var DEFAULT_BUDGET_CONFIG = {
2875
3390
  enabled: true,
2876
3391
  pollSeconds: 300,
@@ -2886,7 +3401,8 @@ var DEFAULT_BUDGET_CONFIG = {
2886
3401
  full: null,
2887
3402
  balanced: { effort: "medium" },
2888
3403
  eco: { effort: "low" }
2889
- }
3404
+ },
3405
+ strategy: "conserve"
2890
3406
  };
2891
3407
  var DEFAULT_CONFIG = {
2892
3408
  version: "1.0",
@@ -2964,6 +3480,9 @@ function normalizeBoundedInteger(value, fallback, min, max) {
2964
3480
  return fallback;
2965
3481
  return parsed;
2966
3482
  }
3483
+ function normalizeStrategy(value, fallback) {
3484
+ return value === "conserve" || value === "maximize" ? value : fallback;
3485
+ }
2967
3486
  function normalizeBoolean(value, fallback) {
2968
3487
  if (typeof value === "boolean")
2969
3488
  return value;
@@ -3012,7 +3531,8 @@ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
3012
3531
  timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
3013
3532
  },
3014
3533
  codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
3015
- codexTiers
3534
+ codexTiers,
3535
+ strategy: normalizeStrategy(budget.strategy, fallback.strategy)
3016
3536
  };
3017
3537
  }
3018
3538
  function applyBudgetEnvOverrides(budget, env = process.env) {
@@ -3027,7 +3547,8 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
3027
3547
  timeWindowSec: env.AGENTBRIDGE_BUDGET_PARALLEL_TIME_WINDOW_SEC ?? budget.parallel.timeWindowSec
3028
3548
  },
3029
3549
  codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
3030
- codexTiers: budget.codexTiers
3550
+ codexTiers: budget.codexTiers,
3551
+ strategy: env.AGENTBRIDGE_BUDGET_STRATEGY ?? budget.strategy
3031
3552
  };
3032
3553
  return normalizeBudgetConfig(overlay, budget);
3033
3554
  }
@@ -3041,13 +3562,13 @@ function normalizeConfig(raw) {
3041
3562
  return {
3042
3563
  version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version,
3043
3564
  codex: {
3044
- appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
3045
- proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
3565
+ appPort: normalizeBoundedInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort, 1, 65535),
3566
+ proxyPort: normalizeBoundedInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort, 1, 65535)
3046
3567
  },
3047
3568
  turnCoordination: {
3048
- attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
3569
+ attentionWindowSeconds: normalizeBoundedInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds, 0, Number.MAX_SAFE_INTEGER)
3049
3570
  },
3050
- idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
3571
+ idleShutdownSeconds: normalizeBoundedInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds, 1, Number.MAX_SAFE_INTEGER),
3051
3572
  budget: normalizeBudgetConfig(config.budget)
3052
3573
  };
3053
3574
  }
@@ -3057,8 +3578,8 @@ class ConfigService {
3057
3578
  configPath;
3058
3579
  constructor(projectRoot) {
3059
3580
  const root = projectRoot ?? process.cwd();
3060
- this.configDir = join3(root, CONFIG_DIR);
3061
- this.configPath = join3(this.configDir, CONFIG_FILE);
3581
+ this.configDir = join4(root, CONFIG_DIR);
3582
+ this.configPath = join4(this.configDir, CONFIG_FILE);
3062
3583
  }
3063
3584
  hasConfig() {
3064
3585
  return existsSync4(this.configPath);
@@ -3066,7 +3587,7 @@ class ConfigService {
3066
3587
  load() {
3067
3588
  let raw;
3068
3589
  try {
3069
- raw = readFileSync2(this.configPath, "utf-8");
3590
+ raw = readFileSync4(this.configPath, "utf-8");
3070
3591
  } catch (err) {
3071
3592
  if (err?.code === "ENOENT") {
3072
3593
  return { state: "absent" };
@@ -3119,9 +3640,7 @@ class ConfigService {
3119
3640
  };
3120
3641
  }
3121
3642
  save(config) {
3122
- this.ensureConfigDir();
3123
- writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
3124
- `, "utf-8");
3643
+ atomicWriteJson(this.configPath, config);
3125
3644
  }
3126
3645
  initDefaults() {
3127
3646
  this.ensureConfigDir();
@@ -3137,11 +3656,32 @@ class ConfigService {
3137
3656
  }
3138
3657
  ensureConfigDir() {
3139
3658
  if (!existsSync4(this.configDir)) {
3140
- mkdirSync3(this.configDir, { recursive: true });
3659
+ mkdirSync4(this.configDir, { recursive: true });
3141
3660
  }
3142
3661
  }
3143
3662
  }
3144
3663
 
3664
+ // src/budget/budget-gate.ts
3665
+ function matchingGateReset(usage) {
3666
+ if (!usage)
3667
+ return 0;
3668
+ const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
3669
+ const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
3670
+ const candidates = matching.length > 0 ? matching : windows;
3671
+ if (candidates.length === 0)
3672
+ return 0;
3673
+ return Math.min(...candidates.map((window) => window.resetEpoch));
3674
+ }
3675
+ function resumeBlockingEpoch(usage, cfg, now) {
3676
+ if (!usage)
3677
+ return 0;
3678
+ if (usage.rateLimitedUntil > now)
3679
+ return usage.rateLimitedUntil;
3680
+ if (usage.gateUtil >= cfg.resumeBelow)
3681
+ return matchingGateReset(usage);
3682
+ return 0;
3683
+ }
3684
+
3145
3685
  // src/budget/types.ts
3146
3686
  var STALE_MAX_AGE_SEC = 600;
3147
3687
 
@@ -3166,25 +3706,6 @@ function usageSummary(name, usage) {
3166
3706
  return `${AGENT_LABEL[name]} \u672A\u77E5`;
3167
3707
  return `${AGENT_LABEL[name]} gate=${pct(usage.gateUtil)} warn=${pct(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
3168
3708
  }
3169
- function matchingGateReset(usage) {
3170
- if (!usage)
3171
- return 0;
3172
- const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
3173
- const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
3174
- const candidates = matching.length > 0 ? matching : windows;
3175
- if (candidates.length === 0)
3176
- return 0;
3177
- return Math.min(...candidates.map((window) => window.resetEpoch));
3178
- }
3179
- function resumeBlockingEpoch(usage, cfg, now) {
3180
- if (!usage)
3181
- return 0;
3182
- if (usage.rateLimitedUntil > now)
3183
- return usage.rateLimitedUntil;
3184
- if (usage.gateUtil >= cfg.resumeBelow)
3185
- return matchingGateReset(usage);
3186
- return 0;
3187
- }
3188
3709
  function resumeAfterEpoch(claude, codex, cfg, now) {
3189
3710
  const epochs = [
3190
3711
  resumeBlockingEpoch(claude, cfg, now),
@@ -3367,8 +3888,211 @@ function computeBudgetState(claude, codex, cfg, now) {
3367
3888
  };
3368
3889
  }
3369
3890
 
3370
- // src/budget/budget-coordinator.ts
3891
+ // src/budget/budget-fingerprint.ts
3371
3892
  var RESET_FINGERPRINT_BUCKET_SEC = 600;
3893
+ var AGENT_LABEL2 = {
3894
+ claude: "Claude",
3895
+ codex: "Codex"
3896
+ };
3897
+ function pct2(value) {
3898
+ return `${Math.round(value * 10) / 10}%`;
3899
+ }
3900
+ function formatEpoch2(epoch) {
3901
+ return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
3902
+ }
3903
+ var INITIAL_FINGERPRINT_STATE = {
3904
+ side: null,
3905
+ fingerprint: null,
3906
+ resumeEpoch: null,
3907
+ reason: null
3908
+ };
3909
+ function sideToAgents(side) {
3910
+ if (side === "both")
3911
+ return ["claude", "codex"];
3912
+ if (side === "claude")
3913
+ return ["claude"];
3914
+ if (side === "codex")
3915
+ return ["codex"];
3916
+ return [];
3917
+ }
3918
+ function agentsToSide(agents) {
3919
+ const claude = agents.has("claude");
3920
+ const codex = agents.has("codex");
3921
+ if (claude && codex)
3922
+ return "both";
3923
+ if (claude)
3924
+ return "claude";
3925
+ if (codex)
3926
+ return "codex";
3927
+ return null;
3928
+ }
3929
+ function shouldEnter(usage, cfg, now) {
3930
+ if (!isDecisionGrade(usage, now))
3931
+ return false;
3932
+ return usage.gateUtil >= cfg.pauseAt;
3933
+ }
3934
+ function canAgentResume(usage, cfg, now) {
3935
+ if (!isDecisionGrade(usage, now))
3936
+ return false;
3937
+ if (usage.rateLimitedUntil > now)
3938
+ return false;
3939
+ return usage.gateUtil < cfg.resumeBelow;
3940
+ }
3941
+ function nextActiveSide(prevSide, state, cfg) {
3942
+ const active = new Set(sideToAgents(prevSide));
3943
+ for (const agent of ["claude", "codex"]) {
3944
+ const usage = state.perAgent[agent];
3945
+ if (shouldEnter(usage, cfg, state.now)) {
3946
+ active.add(agent);
3947
+ } else if (active.has(agent) && canAgentResume(usage, cfg, state.now)) {
3948
+ active.delete(agent);
3949
+ }
3950
+ }
3951
+ return agentsToSide(active);
3952
+ }
3953
+ function activeSideReason(agent, usage, cfg, now) {
3954
+ if (!usage)
3955
+ return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
3956
+ if (usage.rateLimitedUntil > now) {
3957
+ return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${formatEpoch2(usage.rateLimitedUntil)}`;
3958
+ }
3959
+ if (usage.gateUtil >= cfg.pauseAt) {
3960
+ return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(cfg.pauseAt)}`;
3961
+ }
3962
+ return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(cfg.resumeBelow)}`;
3963
+ }
3964
+ function interventionReason(side, state, cfg) {
3965
+ return sideToAgents(side).map((agent) => activeSideReason(agent, state.perAgent[agent], cfg, state.now)).join("\uFF1B");
3966
+ }
3967
+ function resumeAfterEpoch2(side, state, cfg) {
3968
+ const epochs = sideToAgents(side).map((agent) => resumeBlockingEpoch(state.perAgent[agent], cfg, state.now)).filter((epoch) => epoch > 0);
3969
+ if (epochs.length === 0)
3970
+ return null;
3971
+ return Math.max(...epochs);
3972
+ }
3973
+ function activeSideProbeUncertain(side, state) {
3974
+ return sideToAgents(side).some((agent) => {
3975
+ const usage = state.perAgent[agent];
3976
+ return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
3977
+ });
3978
+ }
3979
+ function directiveFingerprint(state, activeSide) {
3980
+ const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
3981
+ let reset = 0;
3982
+ if (activeSide === "claude") {
3983
+ reset = state.pause.resetEpochs.claude;
3984
+ } else if (activeSide === "codex") {
3985
+ reset = state.pause.resetEpochs.codex;
3986
+ } else if (activeSide === "both") {
3987
+ reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3988
+ } else if (state.phase === "balance" && state.drift.lighter) {
3989
+ reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
3990
+ }
3991
+ const heavier = activeSide ? "" : state.drift.heavier ?? "none";
3992
+ return [
3993
+ activeSide ? "paused" : state.phase,
3994
+ heavier,
3995
+ side,
3996
+ Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
3997
+ ].join("|");
3998
+ }
3999
+ function classifyPoll(prev, state, cfg) {
4000
+ const previousSide = prev.side;
4001
+ const currentSide = nextActiveSide(previousSide, state, cfg);
4002
+ if (currentSide) {
4003
+ const reason = interventionReason(currentSide, state, cfg);
4004
+ const nextResumeRaw = resumeAfterEpoch2(currentSide, state, cfg);
4005
+ const resumeEpoch = previousSide === currentSide ? nextResumeRaw ?? prev.resumeEpoch : nextResumeRaw;
4006
+ const uncertain = previousSide === currentSide && activeSideProbeUncertain(currentSide, state) && prev.fingerprint;
4007
+ const fingerprint2 = uncertain ? prev.fingerprint : directiveFingerprint(state, currentSide);
4008
+ const pauseChanged = !previousSide;
4009
+ const emit = !previousSide || previousSide !== currentSide || fingerprint2 !== prev.fingerprint;
4010
+ return {
4011
+ next: { side: currentSide, fingerprint: fingerprint2, resumeEpoch, reason },
4012
+ effect: {
4013
+ kind: uncertain ? "hold-uncertain" : "enter",
4014
+ side: currentSide,
4015
+ reason,
4016
+ resumeEpoch,
4017
+ emit,
4018
+ pauseChanged
4019
+ }
4020
+ };
4021
+ }
4022
+ if (previousSide) {
4023
+ return {
4024
+ next: { side: null, fingerprint: null, resumeEpoch: null, reason: null },
4025
+ effect: { kind: "exit", previousSide }
4026
+ };
4027
+ }
4028
+ if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
4029
+ return { next: prev, effect: { kind: "none" } };
4030
+ }
4031
+ if (!state.directiveToClaude) {
4032
+ return {
4033
+ next: { side: null, fingerprint: null, resumeEpoch: null, reason: null },
4034
+ effect: { kind: "none" }
4035
+ };
4036
+ }
4037
+ const fingerprint = directiveFingerprint(state);
4038
+ if (fingerprint !== prev.fingerprint) {
4039
+ return {
4040
+ next: { side: null, fingerprint, resumeEpoch: null, reason: null },
4041
+ effect: { kind: "advise", phase: state.phase }
4042
+ };
4043
+ }
4044
+ return { next: prev, effect: { kind: "none" } };
4045
+ }
4046
+
4047
+ // src/budget/burn-view.ts
4048
+ function windowBurnRate(window) {
4049
+ if (!window || window.burnRate === undefined)
4050
+ return null;
4051
+ return {
4052
+ pctPerHour: window.burnRate,
4053
+ confident: window.burnConfident === true
4054
+ };
4055
+ }
4056
+ function agentBurnRates(usage) {
4057
+ if (!usage)
4058
+ return { fiveHour: null, weekly: null };
4059
+ return {
4060
+ fiveHour: windowBurnRate(usage.fiveHour),
4061
+ weekly: windowBurnRate(usage.weekly)
4062
+ };
4063
+ }
4064
+ function agentRunway(usage, now) {
4065
+ if (!usage || usage.stale || !usage.ok)
4066
+ return null;
4067
+ if (!isDecisionGrade(usage, now))
4068
+ return null;
4069
+ let best = null;
4070
+ const candidates = [
4071
+ ["fiveHour", usage.fiveHour],
4072
+ ["weekly", usage.weekly]
4073
+ ];
4074
+ for (const [basis, window] of candidates) {
4075
+ if (!window || window.resetEpoch <= now)
4076
+ continue;
4077
+ if (window.burnConfident !== true)
4078
+ continue;
4079
+ if (window.runwaySeconds === undefined)
4080
+ continue;
4081
+ if (best === null || window.runwaySeconds < best.seconds) {
4082
+ best = {
4083
+ seconds: window.runwaySeconds,
4084
+ basis,
4085
+ depletedAtEpoch: window.depletedAtEpoch ?? null
4086
+ };
4087
+ }
4088
+ }
4089
+ return best;
4090
+ }
4091
+ function hasAnyBurnSignal(rates, runway) {
4092
+ return rates.claude.fiveHour !== null || rates.claude.weekly !== null || rates.codex.fiveHour !== null || rates.codex.weekly !== null || runway.claude !== null || runway.codex !== null;
4093
+ }
4094
+
4095
+ // src/budget/budget-coordinator.ts
3372
4096
  var LOW_UTIL_PCT = 50;
3373
4097
  var NEAR_PAUSE_MARGIN_PCT = 10;
3374
4098
  var NEAR_WARN_UTIL_PCT = 75;
@@ -3386,27 +4110,17 @@ var REAL_BUDGET_POLL_SCHEDULER = {
3386
4110
  clearTimeout(timer);
3387
4111
  }
3388
4112
  };
3389
- var AGENT_LABEL2 = {
4113
+ var AGENT_LABEL3 = {
3390
4114
  claude: "Claude",
3391
4115
  codex: "Codex"
3392
4116
  };
3393
- function pct2(value) {
4117
+ function pct3(value) {
3394
4118
  return `${Math.round(value * 10) / 10}%`;
3395
4119
  }
3396
4120
  function usageLine(agent, usage) {
3397
4121
  if (!usage)
3398
- return `${AGENT_LABEL2[agent]} \u672A\u77E5`;
3399
- return `${AGENT_LABEL2[agent]} gate=${pct2(usage.gateUtil)} warn=${pct2(usage.warnUtil)}`;
3400
- }
3401
- function matchingGateReset2(usage) {
3402
- if (!usage)
3403
- return 0;
3404
- const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
3405
- const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
3406
- const candidates = matching.length > 0 ? matching : windows;
3407
- if (candidates.length === 0)
3408
- return 0;
3409
- return Math.min(...candidates.map((window) => window.resetEpoch));
4122
+ return `${AGENT_LABEL3[agent]} \u672A\u77E5`;
4123
+ return `${AGENT_LABEL3[agent]} gate=${pct3(usage.gateUtil)} warn=${pct3(usage.warnUtil)}`;
3410
4124
  }
3411
4125
  function maxPollDelayMs(config) {
3412
4126
  return Math.max(0, config.pollSeconds * 1000);
@@ -3463,16 +4177,14 @@ class BudgetCoordinator {
3463
4177
  config;
3464
4178
  emit;
3465
4179
  onPauseChange;
4180
+ onSnapshot;
3466
4181
  now;
3467
4182
  scheduler;
3468
4183
  log;
3469
4184
  timer = null;
3470
4185
  running = false;
3471
- activeSides = new Set;
3472
- lastDirectiveFingerprint = null;
4186
+ fpState = INITIAL_FINGERPRINT_STATE;
3473
4187
  latestSnapshot = null;
3474
- pauseReason = null;
3475
- pauseResumeAfterEpoch = null;
3476
4188
  pendingOverrideTier = null;
3477
4189
  pendingOverrides = null;
3478
4190
  lastAppliedTier = "full";
@@ -3483,6 +4195,7 @@ class BudgetCoordinator {
3483
4195
  this.config = options.config;
3484
4196
  this.emit = options.emit;
3485
4197
  this.onPauseChange = options.onPauseChange;
4198
+ this.onSnapshot = options.onSnapshot ?? (() => {});
3486
4199
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
3487
4200
  this.scheduler = options.scheduler ?? REAL_BUDGET_POLL_SCHEDULER;
3488
4201
  this.log = options.log ?? (() => {});
@@ -3503,10 +4216,10 @@ class BudgetCoordinator {
3503
4216
  }
3504
4217
  }
3505
4218
  isPaused() {
3506
- return this.activeSides.size > 0;
4219
+ return this.fpState.side !== null;
3507
4220
  }
3508
4221
  isGateClosed() {
3509
- return this.activeSides.has("codex");
4222
+ return this.fpState.side === "codex" || this.fpState.side === "both";
3510
4223
  }
3511
4224
  getSnapshot() {
3512
4225
  return this.latestSnapshot;
@@ -3560,7 +4273,7 @@ class BudgetCoordinator {
3560
4273
  }
3561
4274
  if (!usage) {
3562
4275
  if (!this.isPaused())
3563
- this.latestSnapshot = null;
4276
+ this.setSnapshot(null);
3564
4277
  return;
3565
4278
  }
3566
4279
  if (!this.running) {
@@ -3569,85 +4282,39 @@ class BudgetCoordinator {
3569
4282
  const state = computeBudgetState(usage.claude, usage.codex, this.config, this.now());
3570
4283
  this.updatePendingOverrides(state.effort.codexTier);
3571
4284
  this.applyState(state);
3572
- this.latestSnapshot = this.toSnapshot(state);
4285
+ this.setSnapshot(this.toSnapshot(state));
4286
+ }
4287
+ setSnapshot(snapshot) {
4288
+ this.latestSnapshot = snapshot;
4289
+ this.onSnapshot(snapshot);
3573
4290
  }
3574
4291
  applyState(state) {
3575
- const previousSide = this.pauseSide();
3576
- this.updateActiveSides(state);
3577
- const currentSide = this.pauseSide();
3578
- if (currentSide) {
3579
- this.pauseReason = this.interventionReason(state);
3580
- const nextResumeAfterEpoch = this.resumeAfterEpoch(state);
3581
- this.pauseResumeAfterEpoch = previousSide === currentSide ? nextResumeAfterEpoch ?? this.pauseResumeAfterEpoch : nextResumeAfterEpoch;
3582
- const fingerprint2 = previousSide === currentSide && this.activeSideProbeUncertain(state) && this.lastDirectiveFingerprint ? this.lastDirectiveFingerprint : this.directiveFingerprint(state, currentSide);
3583
- if (!previousSide) {
3584
- this.onPauseChange(true);
4292
+ const { next, effect } = classifyPoll(this.fpState, state, this.config);
4293
+ this.fpState = next;
4294
+ switch (effect.kind) {
4295
+ case "enter":
4296
+ case "hold-uncertain": {
4297
+ if (effect.pauseChanged)
4298
+ this.onPauseChange(true);
4299
+ if (effect.emit) {
4300
+ this.emitDirective(this.interventionPrefix(effect.side), this.interventionDirective(state, effect.side, effect.reason, effect.resumeEpoch));
4301
+ }
4302
+ return;
3585
4303
  }
3586
- if (!previousSide || previousSide !== currentSide || fingerprint2 !== this.lastDirectiveFingerprint) {
3587
- this.emitDirective(this.interventionPrefix(currentSide), this.interventionDirective(state, currentSide));
4304
+ case "exit": {
4305
+ this.onPauseChange(false);
4306
+ this.emitDirective(this.recoveryPrefix(effect.previousSide), this.recoveryDirective(state, effect.previousSide));
4307
+ return;
3588
4308
  }
3589
- this.lastDirectiveFingerprint = fingerprint2;
3590
- return;
3591
- }
3592
- if (previousSide) {
3593
- this.pauseReason = null;
3594
- this.pauseResumeAfterEpoch = null;
3595
- this.lastDirectiveFingerprint = null;
3596
- this.onPauseChange(false);
3597
- this.emitDirective(this.recoveryPrefix(previousSide), this.recoveryDirective(state, previousSide));
3598
- return;
3599
- }
3600
- if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
3601
- return;
3602
- }
3603
- if (!state.directiveToClaude) {
3604
- this.lastDirectiveFingerprint = null;
3605
- return;
3606
- }
3607
- const fingerprint = this.directiveFingerprint(state);
3608
- if (fingerprint !== this.lastDirectiveFingerprint) {
3609
- const prefix = state.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
3610
- this.emitDirective(prefix, state.directiveToClaude);
3611
- this.lastDirectiveFingerprint = fingerprint;
3612
- }
3613
- }
3614
- updateActiveSides(state) {
3615
- for (const agent of ["claude", "codex"]) {
3616
- const usage = state.perAgent[agent];
3617
- if (this.shouldEnter(usage, state.now)) {
3618
- this.activeSides.add(agent);
3619
- } else if (this.activeSides.has(agent) && this.canAgentResume(usage, state.now)) {
3620
- this.activeSides.delete(agent);
4309
+ case "advise": {
4310
+ const prefix = effect.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
4311
+ this.emitDirective(prefix, state.directiveToClaude);
4312
+ return;
3621
4313
  }
4314
+ case "none":
4315
+ return;
3622
4316
  }
3623
4317
  }
3624
- shouldEnter(usage, now) {
3625
- if (!isDecisionGrade(usage, now))
3626
- return false;
3627
- return usage.gateUtil >= this.config.pauseAt;
3628
- }
3629
- canAgentResume(usage, now) {
3630
- if (!isDecisionGrade(usage, now))
3631
- return false;
3632
- if (usage.rateLimitedUntil > now)
3633
- return false;
3634
- return usage.gateUtil < this.config.resumeBelow;
3635
- }
3636
- resumeAfterEpoch(state) {
3637
- const epochs = ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.resumeBlockingEpoch(state.perAgent[agent], state.now)).filter((epoch) => epoch > 0);
3638
- if (epochs.length === 0)
3639
- return null;
3640
- return Math.max(...epochs);
3641
- }
3642
- resumeBlockingEpoch(usage, now) {
3643
- if (!usage)
3644
- return 0;
3645
- if (usage.rateLimitedUntil > now)
3646
- return usage.rateLimitedUntil;
3647
- if (usage.gateUtil >= this.config.resumeBelow)
3648
- return matchingGateReset2(usage);
3649
- return 0;
3650
- }
3651
4318
  tierControlEnabled() {
3652
4319
  if (!this.config.codexTierControl)
3653
4320
  return false;
@@ -3681,82 +4348,24 @@ class BudgetCoordinator {
3681
4348
  this.pendingOverrideTier = tier;
3682
4349
  this.pendingOverrides = { ...overrides };
3683
4350
  }
3684
- directiveFingerprint(state, activeSide) {
3685
- const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
3686
- let reset = 0;
3687
- if (activeSide === "claude") {
3688
- reset = state.pause.resetEpochs.claude;
3689
- } else if (activeSide === "codex") {
3690
- reset = state.pause.resetEpochs.codex;
3691
- } else if (activeSide === "both") {
3692
- reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3693
- } else if (state.phase === "balance" && state.drift.lighter) {
3694
- reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
3695
- } else if (side === "claude") {
3696
- reset = state.pause.resetEpochs.claude;
3697
- } else if (side === "codex") {
3698
- reset = state.pause.resetEpochs.codex;
3699
- } else if (side === "both") {
3700
- reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3701
- }
3702
- return [
3703
- activeSide ? "paused" : state.phase,
3704
- state.drift.heavier ?? "none",
3705
- side,
3706
- Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
3707
- ].join("|");
3708
- }
3709
4351
  emitDirective(prefix, content) {
3710
4352
  this.emit(`${prefix}_${this.sequence++}`, content);
3711
4353
  }
3712
- pauseSide() {
3713
- const claude = this.activeSides.has("claude");
3714
- const codex = this.activeSides.has("codex");
3715
- if (claude && codex)
3716
- return "both";
3717
- if (claude)
3718
- return "claude";
3719
- if (codex)
3720
- return "codex";
3721
- return null;
3722
- }
3723
4354
  interventionPrefix(side) {
3724
4355
  return side === "claude" ? "system_budget_handoff" : "system_budget_pause";
3725
4356
  }
3726
4357
  recoveryPrefix(previousSide) {
3727
4358
  return previousSide === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
3728
4359
  }
3729
- interventionDirective(state, side) {
3730
- return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, this.pauseReason ?? "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", this.pauseResumeAfterEpoch, this.config);
3731
- }
3732
- interventionReason(state) {
3733
- return ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.activeSideReason(agent, state.perAgent[agent], state.now)).join("\uFF1B");
3734
- }
3735
- activeSideProbeUncertain(state) {
3736
- return ["claude", "codex"].some((agent) => {
3737
- if (!this.activeSides.has(agent))
3738
- return false;
3739
- const usage = state.perAgent[agent];
3740
- return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
3741
- });
3742
- }
3743
- activeSideReason(agent, usage, now) {
3744
- if (!usage)
3745
- return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
3746
- if (usage.rateLimitedUntil > now) {
3747
- return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${this.formatEpoch(usage.rateLimitedUntil)}`;
3748
- }
3749
- if (usage.gateUtil >= this.config.pauseAt) {
3750
- return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(this.config.pauseAt)}`;
3751
- }
3752
- return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(this.config.resumeBelow)}`;
4360
+ interventionDirective(state, side, reason, resumeEpoch) {
4361
+ return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, reason || "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", resumeEpoch, this.config);
3753
4362
  }
3754
4363
  recoveryDirective(state, previousSide) {
3755
4364
  if (previousSide === "claude") {
3756
4365
  return [
3757
4366
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u9884\u7B97\u5DF2\u6062\u590D\u3002",
3758
4367
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3759
- `Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4368
+ `Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3760
4369
  "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"
3761
4370
  ].join(`
3762
4371
  `);
@@ -3765,7 +4374,7 @@ class BudgetCoordinator {
3765
4374
  return [
3766
4375
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
3767
4376
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3768
- `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4377
+ `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3769
4378
  "\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"
3770
4379
  ].join(`
3771
4380
  `);
@@ -3773,14 +4382,11 @@ class BudgetCoordinator {
3773
4382
  return [
3774
4383
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8054\u5408\u6682\u505C\u89E3\u9664\u3002",
3775
4384
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3776
- `\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`,
4385
+ `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1A\u53CC\u65B9 gateUtil \u5747\u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3777
4386
  "\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"
3778
4387
  ].join(`
3779
4388
  `);
3780
4389
  }
3781
- formatEpoch(epoch) {
3782
- return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
3783
- }
3784
4390
  toSnapshot(state) {
3785
4391
  const paused = this.isPaused();
3786
4392
  return {
@@ -3791,13 +4397,27 @@ class BudgetCoordinator {
3791
4397
  driftPct: state.drift.pct,
3792
4398
  paused,
3793
4399
  gateClosed: this.isGateClosed(),
3794
- pauseSide: this.pauseSide(),
3795
- pauseReason: paused ? this.pauseReason ?? state.pause.reason : null,
3796
- resumeAfterEpoch: paused ? this.pauseResumeAfterEpoch ?? state.pause.resumeAfterEpoch : null,
4400
+ pauseSide: this.fpState.side,
4401
+ pauseReason: paused ? this.fpState.reason ?? state.pause.reason : null,
4402
+ resumeAfterEpoch: paused ? this.fpState.resumeEpoch ?? state.pause.resumeAfterEpoch : null,
3797
4403
  parallelRecommended: paused ? false : state.parallel.recommended,
3798
4404
  codexTier: state.effort.codexTier,
3799
- claudeAdvice: state.effort.claudeAdvice
4405
+ claudeAdvice: state.effort.claudeAdvice,
4406
+ ...this.burnRateSnapshotFields(state)
4407
+ };
4408
+ }
4409
+ burnRateSnapshotFields(state) {
4410
+ const rates = {
4411
+ claude: agentBurnRates(state.perAgent.claude),
4412
+ codex: agentBurnRates(state.perAgent.codex)
4413
+ };
4414
+ const runway = {
4415
+ claude: agentRunway(state.perAgent.claude, state.now),
4416
+ codex: agentRunway(state.perAgent.codex, state.now)
3800
4417
  };
4418
+ if (!hasAnyBurnSignal(rates, runway))
4419
+ return {};
4420
+ return { burnRate: rates, runway };
3801
4421
  }
3802
4422
  }
3803
4423
 
@@ -3805,7 +4425,58 @@ class BudgetCoordinator {
3805
4425
  import { execFile } from "child_process";
3806
4426
  import { existsSync as existsSync5 } from "fs";
3807
4427
  import { homedir as homedir2 } from "os";
3808
- import { basename, join as join4 } from "path";
4428
+ import { basename, join as join5 } from "path";
4429
+ function parseBurnFields(record) {
4430
+ const group = {};
4431
+ let any = false;
4432
+ const takeNumber = (value, min) => {
4433
+ if (value === undefined)
4434
+ return "absent";
4435
+ if (typeof value !== "number" || !Number.isFinite(value))
4436
+ return "invalid";
4437
+ if (min === "zero" && value < 0)
4438
+ return "invalid";
4439
+ if (min === "positive" && value <= 0)
4440
+ return "invalid";
4441
+ return value;
4442
+ };
4443
+ const burnRate = takeNumber(record.burn_rate_pct_per_hour ?? record.burnRatePctPerHour, "zero");
4444
+ if (burnRate === "invalid")
4445
+ return null;
4446
+ if (burnRate !== "absent") {
4447
+ group.burnRate = burnRate;
4448
+ any = true;
4449
+ }
4450
+ const confidentRaw = record.burn_confident ?? record.burnConfident;
4451
+ if (confidentRaw !== undefined) {
4452
+ if (typeof confidentRaw !== "boolean")
4453
+ return null;
4454
+ group.burnConfident = confidentRaw;
4455
+ any = true;
4456
+ }
4457
+ const runwaySeconds = takeNumber(record.runway_seconds ?? record.runwaySeconds, "zero");
4458
+ if (runwaySeconds === "invalid")
4459
+ return null;
4460
+ if (runwaySeconds !== "absent") {
4461
+ group.runwaySeconds = runwaySeconds;
4462
+ any = true;
4463
+ }
4464
+ const depletedAtEpoch = takeNumber(record.depleted_at_epoch ?? record.depletedAtEpoch, "positive");
4465
+ if (depletedAtEpoch === "invalid")
4466
+ return null;
4467
+ if (depletedAtEpoch !== "absent") {
4468
+ group.depletedAtEpoch = depletedAtEpoch;
4469
+ any = true;
4470
+ }
4471
+ const fiveHourWindowsLeft = takeNumber(record.five_hour_windows_left ?? record.fiveHourWindowsLeft, "zero");
4472
+ if (fiveHourWindowsLeft === "invalid")
4473
+ return null;
4474
+ if (fiveHourWindowsLeft !== "absent") {
4475
+ group.fiveHourWindowsLeft = fiveHourWindowsLeft;
4476
+ any = true;
4477
+ }
4478
+ return any ? group : null;
4479
+ }
3809
4480
  var DEFAULT_TIMEOUT_MS = 1e4;
3810
4481
  var MAX_BUFFER = 1024 * 1024;
3811
4482
  function defaultRunner(command, args, options) {
@@ -3867,7 +4538,8 @@ function normalizeBucket(value, fetchedAt) {
3867
4538
  id,
3868
4539
  util: clamp(util, 0, 100),
3869
4540
  resetEpoch: Math.max(0, resetEpoch),
3870
- resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
4541
+ resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter),
4542
+ burn: parseBurnFields(bucket)
3871
4543
  };
3872
4544
  }
3873
4545
  function normalizeTopLevelBucket(record, util, fetchedAt) {
@@ -3880,13 +4552,27 @@ function normalizeTopLevelBucket(record, util, fetchedAt) {
3880
4552
  id: "top_level",
3881
4553
  util: clamp(util, 0, 100),
3882
4554
  resetEpoch: Math.max(0, resetEpoch),
3883
- resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
4555
+ resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter),
4556
+ burn: parseBurnFields(record)
3884
4557
  };
3885
4558
  }
3886
4559
  function toWindow(bucket) {
3887
4560
  if (!bucket)
3888
4561
  return null;
3889
- return { util: bucket.util, resetEpoch: bucket.resetEpoch };
4562
+ const window = { util: bucket.util, resetEpoch: bucket.resetEpoch };
4563
+ if (bucket.burn) {
4564
+ if (bucket.burn.burnRate !== undefined)
4565
+ window.burnRate = bucket.burn.burnRate;
4566
+ if (bucket.burn.burnConfident !== undefined)
4567
+ window.burnConfident = bucket.burn.burnConfident;
4568
+ if (bucket.burn.runwaySeconds !== undefined)
4569
+ window.runwaySeconds = bucket.burn.runwaySeconds;
4570
+ if (bucket.burn.depletedAtEpoch !== undefined)
4571
+ window.depletedAtEpoch = bucket.burn.depletedAtEpoch;
4572
+ if (bucket.burn.fiveHourWindowsLeft !== undefined)
4573
+ window.fiveHourWindowsLeft = bucket.burn.fiveHourWindowsLeft;
4574
+ }
4575
+ return window;
3890
4576
  }
3891
4577
  function bucketSortKey(bucket) {
3892
4578
  if (bucket.resetAfterSeconds !== null)
@@ -3966,10 +4652,11 @@ function normalizeTolerantProbeRecord(record) {
3966
4652
  };
3967
4653
  }
3968
4654
  var PROBE_SCHEMA_PARSERS = {
3969
- "1": normalizeTolerantProbeRecord
4655
+ "1": normalizeTolerantProbeRecord,
4656
+ "2": normalizeTolerantProbeRecord
3970
4657
  };
3971
4658
  function schemaVersionKey(record) {
3972
- const value = record.schema_version ?? record.schemaVersion;
4659
+ const value = record.schema_version ?? record.schemaVersion ?? record.probe_schema ?? record.probeSchema;
3973
4660
  if (typeof value === "number" && Number.isFinite(value))
3974
4661
  return String(value);
3975
4662
  if (typeof value === "string" && value.trim() !== "")
@@ -4053,11 +4740,11 @@ class QuotaSource {
4053
4740
  add(command, commandKind(command));
4054
4741
  return candidates;
4055
4742
  }
4056
- const binDir = join4(this.homeDir, ".budget-guard/bin");
4057
- const installedBudgetProbe = join4(binDir, "budget-probe");
4743
+ const binDir = join5(this.homeDir, ".budget-guard/bin");
4744
+ const installedBudgetProbe = join5(binDir, "budget-probe");
4058
4745
  if (existsSync5(installedBudgetProbe))
4059
4746
  add(installedBudgetProbe, "budget-probe");
4060
- const installedProbeMjs = join4(binDir, "probe.mjs");
4747
+ const installedProbeMjs = join5(binDir, "probe.mjs");
4061
4748
  if (existsSync5(installedProbeMjs))
4062
4749
  add(installedProbeMjs, "probe-mjs");
4063
4750
  return candidates;
@@ -4120,6 +4807,27 @@ function createQuotaSource(options) {
4120
4807
  return new QuotaSource(options);
4121
4808
  }
4122
4809
 
4810
+ // src/daemon-identity-ownership.ts
4811
+ import { readFileSync as readFileSync5 } from "fs";
4812
+ var defaultRead2 = (path) => readFileSync5(path, "utf-8");
4813
+ function pidFileOwnedByUs(pidFilePath, ourPid, read = defaultRead2) {
4814
+ let raw;
4815
+ try {
4816
+ raw = read(pidFilePath);
4817
+ } catch {
4818
+ return false;
4819
+ }
4820
+ const trimmed = raw.trim();
4821
+ if (trimmed.length === 0)
4822
+ return false;
4823
+ if (!/^[+-]?\d+$/.test(trimmed))
4824
+ return false;
4825
+ const pid = Number.parseInt(trimmed, 10);
4826
+ if (!Number.isFinite(pid))
4827
+ return false;
4828
+ return pid === ourPid;
4829
+ }
4830
+
4123
4831
  // src/idempotency-tracker.ts
4124
4832
  var DEFAULT_TOMBSTONE_TTL_MS = 20 * 60 * 1000;
4125
4833
 
@@ -4270,14 +4978,11 @@ class ReplyRequiredTracker {
4270
4978
  // src/thread-state.ts
4271
4979
  import {
4272
4980
  existsSync as existsSync6,
4273
- mkdirSync as mkdirSync4,
4274
4981
  readdirSync,
4275
- readFileSync as readFileSync3,
4276
- renameSync as renameSync2,
4277
- writeFileSync as writeFileSync3
4982
+ readFileSync as readFileSync6
4278
4983
  } from "fs";
4279
4984
  import { homedir as homedir3 } from "os";
4280
- import { basename as basename2, dirname as dirname2, join as join5 } from "path";
4985
+ import { basename as basename2, join as join6 } from "path";
4281
4986
  function nowIso() {
4282
4987
  return new Date().toISOString();
4283
4988
  }
@@ -4286,18 +4991,11 @@ function threadTag(identity) {
4286
4991
  return `abg:${name}:${identity.cwd}`;
4287
4992
  }
4288
4993
  function codexHome(env = process.env) {
4289
- return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join5(homedir3(), ".codex");
4290
- }
4291
- function atomicWriteJson(path, value) {
4292
- mkdirSync4(dirname2(path), { recursive: true });
4293
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
4294
- writeFileSync3(tmp, JSON.stringify(value, null, 2) + `
4295
- `, "utf-8");
4296
- renameSync2(tmp, path);
4994
+ return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join6(homedir3(), ".codex");
4297
4995
  }
4298
4996
  function readRawCurrentThread(stateDir) {
4299
4997
  try {
4300
- const parsed = JSON.parse(readFileSync3(stateDir.currentThreadFile, "utf-8"));
4998
+ const parsed = JSON.parse(readFileSync6(stateDir.currentThreadFile, "utf-8"));
4301
4999
  if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
4302
5000
  return parsed;
4303
5001
  }
@@ -4305,7 +5003,7 @@ function readRawCurrentThread(stateDir) {
4305
5003
  return null;
4306
5004
  }
4307
5005
  function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
4308
- const sessionsDir = join5(codexHome(env), "sessions");
5006
+ const sessionsDir = join6(codexHome(env), "sessions");
4309
5007
  if (!threadId || !existsSync6(sessionsDir))
4310
5008
  return null;
4311
5009
  const exactName = `rollout-${threadId}.jsonl`;
@@ -4321,7 +5019,7 @@ function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
4321
5019
  }
4322
5020
  for (const entry of entries) {
4323
5021
  visited++;
4324
- const path = join5(dir, entry.name);
5022
+ const path = join6(dir, entry.name);
4325
5023
  if (entry.isDirectory()) {
4326
5024
  stack.push(path);
4327
5025
  continue;
@@ -4415,6 +5113,7 @@ function formatWaitingForCodexTuiMessage(options) {
4415
5113
  // src/pair-registry.ts
4416
5114
  var PAIR_BASE_PORT = 4500;
4417
5115
  var PAIR_SLOT_STRIDE = 10;
5116
+ var RECLAIMABLE_MIN_AGE_MS = 24 * 60 * 60 * 1000;
4418
5117
  var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
4419
5118
 
4420
5119
  // src/liveness-probe.ts
@@ -4445,10 +5144,55 @@ async function probeLiveness(target, options) {
4445
5144
  return target.pongCount > baseline;
4446
5145
  }
4447
5146
 
5147
+ // src/delivery-buffer.ts
5148
+ class BoundedMessageBuffer {
5149
+ messages = [];
5150
+ cap;
5151
+ overflowLabel;
5152
+ overflowNoun;
5153
+ log;
5154
+ constructor(options) {
5155
+ this.cap = options.cap;
5156
+ this.overflowLabel = options.overflowLabel;
5157
+ this.overflowNoun = options.overflowNoun ?? "message(s)";
5158
+ this.log = options.log;
5159
+ }
5160
+ get length() {
5161
+ return this.messages.length;
5162
+ }
5163
+ push(message) {
5164
+ this.messages.push(message);
5165
+ this.enforceCap();
5166
+ }
5167
+ unshiftMany(messages) {
5168
+ if (messages.length === 0)
5169
+ return;
5170
+ this.messages.unshift(...messages);
5171
+ this.enforceCap();
5172
+ }
5173
+ drainAll() {
5174
+ return this.messages.splice(0, this.messages.length);
5175
+ }
5176
+ clear() {
5177
+ this.messages.length = 0;
5178
+ }
5179
+ enforceCap() {
5180
+ if (this.messages.length > this.cap) {
5181
+ const dropped = this.messages.length - this.cap;
5182
+ this.messages.splice(0, dropped);
5183
+ this.log(`${this.overflowLabel}: dropped ${dropped} oldest ${this.overflowNoun}, ${this.cap} remaining`);
5184
+ }
5185
+ }
5186
+ }
5187
+
4448
5188
  // src/daemon.ts
4449
5189
  var stateDir = new StateDirResolver;
4450
5190
  stateDir.ensure();
4451
5191
  var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
5192
+ var controlTokenPath = resolveControlTokenPath(stateDir.dir);
5193
+ var controlToken = generateControlToken();
5194
+ var weWroteToken = false;
5195
+ var weWrotePid = false;
4452
5196
  var configService = new ConfigService;
4453
5197
  var config = configService.loadOrDefault(processLogger.log);
4454
5198
  var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
@@ -4465,12 +5209,16 @@ var CODEX_BOOT_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_CODEX_BOOT_RETRIES", 2
4465
5209
  var ALLOW_IDENTITYLESS_CLIENT = process.env.AGENTBRIDGE_COMPAT_IDENTITYLESS === "1";
4466
5210
  var BUDGET_CONFIG = applyBudgetEnvOverrides(config.budget);
4467
5211
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
5212
+ var DAEMON_NONCE = randomUUID4();
5213
+ var DAEMON_STARTED_AT = Date.now();
4468
5214
  var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
4469
5215
  var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
4470
5216
  var controlServer = null;
5217
+ var boundControlPort = false;
4471
5218
  var attachedClaude = null;
4472
5219
  var nextControlClientId = 0;
4473
5220
  var nextSystemMessageId = 0;
5221
+ var SYSTEM_MSG_SALT = randomUUID4().slice(0, 8);
4474
5222
  var codexBootstrapped = false;
4475
5223
  var attentionWindowTimer = null;
4476
5224
  var inAttentionWindow = false;
@@ -4488,40 +5236,42 @@ var ATTACH_STATUS_COOLDOWN_MS = 30000;
4488
5236
  var LIVENESS_PROBE_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_LIVENESS_PROBE_TIMEOUT_MS", 3000, log);
4489
5237
  var LIVENESS_PROBE_POLL_MS = 50;
4490
5238
  var challengeInProgress = false;
4491
- var bufferedMessages = [];
5239
+ var bufferedMessages = new BoundedMessageBuffer({
5240
+ cap: MAX_BUFFERED_MESSAGES,
5241
+ overflowLabel: "Message buffer overflow",
5242
+ log
5243
+ });
5244
+ function createPendingBackpressureBuffer() {
5245
+ return new BoundedMessageBuffer({
5246
+ cap: MAX_BUFFERED_MESSAGES,
5247
+ overflowLabel: "Backpressure overflow",
5248
+ overflowNoun: "tracked message(s)",
5249
+ log
5250
+ });
5251
+ }
4492
5252
  var budgetCoordinator = null;
4493
- var budgetStatusTimer = null;
4494
5253
  function ensureBudgetCoordinatorStarted() {
4495
5254
  if (!BUDGET_CONFIG.enabled)
4496
5255
  return;
4497
5256
  if (!budgetCoordinator) {
4498
- 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"}`);
5257
+ 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"} ` + `strategy=${BUDGET_CONFIG.strategy}`);
4499
5258
  budgetCoordinator = new BudgetCoordinator({
4500
5259
  source: createQuotaSource({ log }),
4501
5260
  config: BUDGET_CONFIG,
4502
5261
  emit: (id, content) => {
4503
5262
  emitToClaude(systemMessage(id, content));
4504
- queueMicrotask(() => broadcastStatus());
4505
5263
  },
4506
5264
  onPauseChange: (paused) => {
4507
5265
  log(`Budget intervention ${paused ? "ACTIVE" : "CLEARED"} ` + `(gate ${budgetCoordinator?.isGateClosed() ? "CLOSED" : "OPEN"})`);
4508
- queueMicrotask(() => broadcastStatus());
4509
5266
  },
5267
+ onSnapshot: () => broadcastStatus(),
4510
5268
  log
4511
5269
  });
4512
5270
  }
4513
5271
  budgetCoordinator.start();
4514
- if (!budgetStatusTimer) {
4515
- budgetStatusTimer = setInterval(() => broadcastStatus(), BUDGET_CONFIG.pollSeconds * 1000);
4516
- budgetStatusTimer.unref?.();
4517
- }
4518
5272
  }
4519
5273
  function stopBudgetCoordinator() {
4520
5274
  budgetCoordinator?.stop();
4521
- if (budgetStatusTimer) {
4522
- clearInterval(budgetStatusTimer);
4523
- budgetStatusTimer = null;
4524
- }
4525
5275
  }
4526
5276
  function budgetPauseGateError() {
4527
5277
  const snapshot = budgetCoordinator?.getSnapshot() ?? null;
@@ -4625,29 +5375,22 @@ codex.on("turnStarted", () => {
4625
5375
  codex.on("agentMessage", (msg) => {
4626
5376
  if (msg.source !== "codex")
4627
5377
  return;
4628
- const result = classifyMessage(msg.content, FILTER_MODE);
4629
- if (replyTracker.isArmed) {
4630
- log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
5378
+ const route = routeCodexMessage(msg.content, {
5379
+ mode: FILTER_MODE,
5380
+ replyArmed: replyTracker.isArmed,
5381
+ inAttentionWindow
5382
+ });
5383
+ log(`Codex \u2192 Claude [${route.marker}/${route.reason}] (${msg.content.length} chars)`);
5384
+ if (route.noteReplyForwarded) {
4631
5385
  replyTracker.noteForwarded();
4632
- if (statusBuffer.size > 0) {
4633
- statusBuffer.flush("reply-required message arrived");
4634
- }
4635
- emitToClaude(msg);
4636
- return;
4637
5386
  }
4638
- if (inAttentionWindow && result.marker === "status") {
4639
- log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
4640
- statusBuffer.add(msg);
4641
- return;
5387
+ if (route.flushStatusBuffer) {
5388
+ statusBuffer.flush(route.noteReplyForwarded ? "reply-required message arrived" : "important message arrived");
4642
5389
  }
4643
- log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
4644
- switch (result.action) {
5390
+ switch (route.action) {
4645
5391
  case "forward":
4646
- if (result.marker === "important" && statusBuffer.size > 0) {
4647
- statusBuffer.flush("important message arrived");
4648
- }
4649
5392
  emitToClaude(msg);
4650
- if (result.marker === "important") {
5393
+ if (route.startAttentionWindow) {
4651
5394
  startAttentionWindow();
4652
5395
  }
4653
5396
  break;
@@ -4722,6 +5465,7 @@ codex.on("error", (err) => {
4722
5465
  });
4723
5466
  codex.on("exit", (code) => {
4724
5467
  log(`Codex process exited (code ${code})`);
5468
+ const wasBootstrapped = codexBootstrapped;
4725
5469
  codexBootstrapped = false;
4726
5470
  replyTracker.reset();
4727
5471
  idempotencyTracker.terminateAll("aborted");
@@ -4730,65 +5474,77 @@ codex.on("exit", (code) => {
4730
5474
  statusBuffer.flush("codex exited");
4731
5475
  tuiConnectionState.handleCodexExit();
4732
5476
  clearPendingClaudeDisconnect("Codex process exited");
4733
- 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.`));
5477
+ if (wasBootstrapped) {
5478
+ 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.`));
5479
+ }
4734
5480
  broadcastStatus();
4735
- armBootDeadline();
5481
+ if (wasBootstrapped) {
5482
+ armBootDeadline();
5483
+ }
4736
5484
  });
4737
5485
  function startControlServer() {
4738
- controlServer = Bun.serve({
4739
- port: CONTROL_PORT,
4740
- hostname: "127.0.0.1",
4741
- fetch(req, server) {
4742
- const url = new URL(req.url);
4743
- if (url.pathname === "/healthz") {
4744
- return Response.json(currentStatus());
4745
- }
4746
- if (url.pathname === "/readyz") {
4747
- return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
4748
- }
4749
- if (url.pathname === "/ws") {
4750
- if (!isAllowedWsUpgrade(req)) {
4751
- log("Rejected WS upgrade on control port: Origin header present (possible CSWSH)");
4752
- return wsOriginRejectedResponse();
5486
+ let server;
5487
+ try {
5488
+ server = Bun.serve({
5489
+ port: CONTROL_PORT,
5490
+ hostname: "127.0.0.1",
5491
+ fetch(req, server2) {
5492
+ const url = new URL(req.url);
5493
+ if (url.pathname === "/healthz") {
5494
+ return Response.json(currentStatus());
4753
5495
  }
4754
- if (server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
4755
- return;
5496
+ if (url.pathname === "/readyz") {
5497
+ return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
4756
5498
  }
4757
- }
4758
- return new Response("AgentBridge daemon");
4759
- },
4760
- websocket: {
4761
- idleTimeout: 960,
4762
- sendPings: true,
4763
- open: (ws) => {
4764
- ws.data.clientId = ++nextControlClientId;
4765
- ws.data.lastPongAt = Date.now();
4766
- ws.data.pendingBackpressure = [];
4767
- log(`Frontend socket opened (#${ws.data.clientId})`);
4768
- },
4769
- close: (ws, code, reason) => {
4770
- log(`Frontend socket closed (#${ws.data.clientId}, code=${code}, reason=${reason || "none"}, wasAttached=${attachedClaude === ws})`);
4771
- if (attachedClaude === ws) {
4772
- detachClaude(ws, "frontend socket closed");
5499
+ if (url.pathname === "/ws") {
5500
+ if (!isAllowedWsUpgrade(req)) {
5501
+ log("Rejected WS upgrade on control port: Origin header present (possible CSWSH)");
5502
+ return wsOriginRejectedResponse();
5503
+ }
5504
+ if (server2.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: createPendingBackpressureBuffer() } })) {
5505
+ return;
5506
+ }
4773
5507
  }
5508
+ return new Response("AgentBridge daemon");
4774
5509
  },
4775
- message: (ws, raw) => {
4776
- handleControlMessage(ws, raw);
4777
- },
4778
- pong: (ws) => {
4779
- ws.data.lastPongAt = Date.now();
4780
- ws.data.pongCount++;
4781
- },
4782
- drain: (ws) => {
4783
- if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
4784
- ws.data.pendingBackpressure = [];
4785
- }
4786
- if (ws === attachedClaude && bufferedMessages.length > 0) {
4787
- flushBufferedMessages(ws);
5510
+ websocket: {
5511
+ idleTimeout: 960,
5512
+ sendPings: true,
5513
+ open: (ws) => {
5514
+ ws.data.clientId = ++nextControlClientId;
5515
+ ws.data.lastPongAt = Date.now();
5516
+ ws.data.pendingBackpressure = createPendingBackpressureBuffer();
5517
+ log(`Frontend socket opened (#${ws.data.clientId})`);
5518
+ },
5519
+ close: (ws, code, reason) => {
5520
+ log(`Frontend socket closed (#${ws.data.clientId}, code=${code}, reason=${reason || "none"}, wasAttached=${attachedClaude === ws})`);
5521
+ if (attachedClaude === ws) {
5522
+ detachClaude(ws, "frontend socket closed");
5523
+ }
5524
+ },
5525
+ message: (ws, raw) => {
5526
+ handleControlMessage(ws, raw);
5527
+ },
5528
+ pong: (ws) => {
5529
+ ws.data.lastPongAt = Date.now();
5530
+ ws.data.pongCount++;
5531
+ },
5532
+ drain: (ws) => {
5533
+ if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
5534
+ ws.data.pendingBackpressure.clear();
5535
+ }
5536
+ if (ws === attachedClaude && bufferedMessages.length > 0) {
5537
+ flushBufferedMessages(ws);
5538
+ }
4788
5539
  }
4789
5540
  }
4790
- }
4791
- });
5541
+ });
5542
+ } catch (err) {
5543
+ log(`Control port ${CONTROL_PORT} bind failed (${err?.code ?? err?.message ?? err}) \u2014 ` + `another daemon owns it; exiting without touching shared identity files`);
5544
+ process.exit(0);
5545
+ }
5546
+ controlServer = server;
5547
+ boundControlPort = true;
4792
5548
  }
4793
5549
  function handleControlMessage(ws, raw) {
4794
5550
  let message;
@@ -4805,7 +5561,9 @@ function handleControlMessage(ws, raw) {
4805
5561
  expectedPairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
4806
5562
  daemonCwd: process.cwd(),
4807
5563
  identity: message.identity,
4808
- allowIdentityless: ALLOW_IDENTITYLESS_CLIENT
5564
+ allowIdentityless: ALLOW_IDENTITYLESS_CLIENT,
5565
+ expectedControlToken: controlToken,
5566
+ expectedContractVersion: BUILD_INFO.contractVersion
4809
5567
  });
4810
5568
  if (!admission.ok) {
4811
5569
  log(`Rejecting Claude frontend #${ws.data.clientId}: ${admission.reason}`);
@@ -4884,6 +5642,16 @@ function waitForInterruptOutcome(turnIds) {
4884
5642
  });
4885
5643
  }
4886
5644
  async function handleClaudeToCodex(ws, message) {
5645
+ const attachGuard = evaluateInjectionAttachGuard(attachedClaude, ws);
5646
+ if (!attachGuard.allowed) {
5647
+ log(`Rejecting claude_to_codex from non-attached socket #${ws.data.clientId} ` + `(request ${message.requestId}, attached=${attachedClaude ? "#" + attachedClaude.data.clientId : "none"})`);
5648
+ sendClaudeToCodexResult(ws, message.requestId, {
5649
+ success: false,
5650
+ code: attachGuard.code,
5651
+ error: attachGuard.reason
5652
+ });
5653
+ return;
5654
+ }
4887
5655
  if (message.message.source !== "claude") {
4888
5656
  sendClaudeToCodexResult(ws, message.requestId, {
4889
5657
  success: false,
@@ -4916,8 +5684,8 @@ async function handleClaudeToCodex(ws, message) {
4916
5684
  if (budgetCoordinator?.isGateClosed()) {
4917
5685
  const reason = budgetPauseGateError();
4918
5686
  log(`Injection rejected by budget pause gate`);
4919
- const resumeAfterEpoch2 = budgetCoordinator?.getSnapshot()?.resumeAfterEpoch ?? null;
4920
- const retryAfterMs = resumeAfterEpoch2 !== null ? Math.max(0, resumeAfterEpoch2 * 1000 - Date.now()) : undefined;
5687
+ const resumeAfterEpoch3 = budgetCoordinator?.getSnapshot()?.resumeAfterEpoch ?? null;
5688
+ const retryAfterMs = resumeAfterEpoch3 !== null ? Math.max(0, resumeAfterEpoch3 * 1000 - Date.now()) : undefined;
4921
5689
  sendClaudeToCodexResult(ws, message.requestId, {
4922
5690
  success: false,
4923
5691
  code: "budget_paused",
@@ -4999,6 +5767,17 @@ async function handleClaudeToCodex(ws, message) {
4999
5767
  return;
5000
5768
  }
5001
5769
  log("Interrupt reached terminal boundary \u2014 injecting the message as a new turn");
5770
+ const postWaitAttachGuard = evaluateInjectionAttachGuard(attachedClaude, ws);
5771
+ if (!postWaitAttachGuard.allowed) {
5772
+ releaseInterruptKey();
5773
+ log(`Rejecting interrupt-path injection from socket #${ws.data.clientId} that lost the attach ` + `slot during the terminal-boundary wait (request ${message.requestId}, ` + `attached=${attachedClaude ? "#" + attachedClaude.data.clientId : "none"})`);
5774
+ sendClaudeToCodexResult(ws, message.requestId, {
5775
+ success: false,
5776
+ code: "not_attached",
5777
+ error: "The original Claude session disconnected (or was replaced by a newer session) while " + "the interrupt was waiting to take effect. Your message was NOT injected \u2014 this avoids " + "delivering it into a different session's thread. Reconnect and resend if still needed."
5778
+ });
5779
+ return;
5780
+ }
5002
5781
  if (interruptThreadId && codex.activeThreadId !== interruptThreadId) {
5003
5782
  releaseInterruptKey();
5004
5783
  }
@@ -5106,14 +5885,9 @@ function detachClaude(ws, reason) {
5106
5885
  ws.data.attached = false;
5107
5886
  log(`Claude frontend detached (#${ws.data.clientId}, ${reason})`);
5108
5887
  if (ws.data.pendingBackpressure.length > 0) {
5109
- bufferedMessages.unshift(...ws.data.pendingBackpressure);
5110
- log(`Re-buffered ${ws.data.pendingBackpressure.length} backpressured message(s) for redelivery on reconnect`);
5111
- ws.data.pendingBackpressure = [];
5112
- if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
5113
- const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
5114
- bufferedMessages.splice(0, dropped);
5115
- log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
5116
- }
5888
+ const reBuffered = ws.data.pendingBackpressure.drainAll();
5889
+ log(`Re-buffered ${reBuffered.length} backpressured message(s) for redelivery on reconnect`);
5890
+ bufferedMessages.unshiftMany(reBuffered);
5117
5891
  }
5118
5892
  scheduleClaudeDisconnectNotification(ws.data.clientId);
5119
5893
  scheduleIdleShutdown();
@@ -5236,11 +6010,6 @@ function emitToClaude(message) {
5236
6010
  log("Send to Claude failed, buffering message for retry on reconnect");
5237
6011
  }
5238
6012
  bufferedMessages.push(message);
5239
- if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
5240
- const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
5241
- bufferedMessages.splice(0, dropped);
5242
- log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
5243
- }
5244
6013
  }
5245
6014
  function trySendBridgeMessage(ws, message) {
5246
6015
  try {
@@ -5251,11 +6020,6 @@ function trySendBridgeMessage(ws, message) {
5251
6020
  }
5252
6021
  if (typeof result === "number" && result === -1) {
5253
6022
  ws.data.pendingBackpressure.push(message);
5254
- if (ws.data.pendingBackpressure.length > MAX_BUFFERED_MESSAGES) {
5255
- const dropped = ws.data.pendingBackpressure.length - MAX_BUFFERED_MESSAGES;
5256
- ws.data.pendingBackpressure.splice(0, dropped);
5257
- log(`Backpressure overflow: dropped ${dropped} oldest tracked message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
5258
- }
5259
6023
  }
5260
6024
  return true;
5261
6025
  } catch (err) {
@@ -5264,11 +6028,11 @@ function trySendBridgeMessage(ws, message) {
5264
6028
  }
5265
6029
  }
5266
6030
  function flushBufferedMessages(ws) {
5267
- const messages = bufferedMessages.splice(0, bufferedMessages.length);
6031
+ const messages = bufferedMessages.drainAll();
5268
6032
  for (let i = 0;i < messages.length; i++) {
5269
6033
  if (!trySendBridgeMessage(ws, messages[i])) {
5270
6034
  const remaining = messages.slice(i);
5271
- bufferedMessages.unshift(...remaining);
6035
+ bufferedMessages.unshiftMany(remaining);
5272
6036
  log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
5273
6037
  return;
5274
6038
  }
@@ -5312,7 +6076,8 @@ function currentStatus() {
5312
6076
  budget: budgetCoordinator?.getSnapshot() ?? undefined,
5313
6077
  turnInProgress: codex.turnInProgress,
5314
6078
  turnPhase: codex.turnPhase,
5315
- attentionWindowActive: inAttentionWindow
6079
+ attentionWindowActive: inAttentionWindow,
6080
+ appServerInfo: codex.capturedAppServerInfo
5316
6081
  };
5317
6082
  }
5318
6083
  function currentWaitingMessage() {
@@ -5333,7 +6098,7 @@ function currentReadyMessage() {
5333
6098
  }
5334
6099
  function systemMessage(idPrefix, content) {
5335
6100
  return {
5336
- id: `${idPrefix}_${++nextSystemMessageId}`,
6101
+ id: `${idPrefix}_${SYSTEM_MSG_SALT}_${++nextSystemMessageId}`,
5337
6102
  source: "codex",
5338
6103
  content,
5339
6104
  timestamp: Date.now()
@@ -5341,9 +6106,47 @@ function systemMessage(idPrefix, content) {
5341
6106
  }
5342
6107
  function writePidFile() {
5343
6108
  daemonLifecycle.writePid();
6109
+ daemonLifecycle.writeDaemonRecord(buildDaemonRecord("booting"));
6110
+ weWrotePid = true;
6111
+ }
6112
+ function writeControlTokenPostBind() {
6113
+ if (controlToken === null)
6114
+ return;
6115
+ try {
6116
+ writeControlToken(controlTokenPath, controlToken);
6117
+ weWroteToken = true;
6118
+ } catch (err) {
6119
+ controlToken = null;
6120
+ processLogger.log(`Failed to write control token (${controlTokenPath}): ${err?.message ?? err} \u2014 ` + `token layer DISABLED for this daemon (attach guard + Origin guard still active)`);
6121
+ }
5344
6122
  }
5345
6123
  function removePidFile() {
6124
+ if (!weWrotePid || !pidFileOwnedByUs(stateDir.pidFile, process.pid))
6125
+ return;
5346
6126
  daemonLifecycle.removePidFile();
6127
+ daemonLifecycle.removeDaemonRecord();
6128
+ }
6129
+ function buildDaemonRecord(phase) {
6130
+ return {
6131
+ pid: process.pid,
6132
+ phase,
6133
+ startedAt: DAEMON_STARTED_AT,
6134
+ nonce: DAEMON_NONCE,
6135
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
6136
+ cwd: process.cwd(),
6137
+ stateDir: stateDir.dir,
6138
+ proxyUrl: codex.proxyUrl,
6139
+ appServerUrl: codex.appServerUrl,
6140
+ ports: {
6141
+ appPort: portFromUrl(codex.appServerUrl) ?? CODEX_APP_PORT,
6142
+ proxyPort: portFromUrl(codex.proxyUrl) ?? CODEX_PROXY_PORT,
6143
+ controlPort: CONTROL_PORT
6144
+ },
6145
+ build: daemonStatusBuildInfo(),
6146
+ turnInProgress: codex.turnInProgress,
6147
+ turnPhase: codex.turnPhase,
6148
+ attentionWindowActive: inAttentionWindow
6149
+ };
5347
6150
  }
5348
6151
  function writeStatusFile() {
5349
6152
  daemonLifecycle.writeStatus({
@@ -5357,11 +6160,16 @@ function writeStatusFile() {
5357
6160
  build: daemonStatusBuildInfo(),
5358
6161
  turnInProgress: codex.turnInProgress,
5359
6162
  turnPhase: codex.turnPhase,
5360
- attentionWindowActive: inAttentionWindow
6163
+ attentionWindowActive: inAttentionWindow,
6164
+ appServerInfo: codex.capturedAppServerInfo
5361
6165
  });
6166
+ daemonLifecycle.writeDaemonRecord(buildDaemonRecord("ready"));
5362
6167
  }
5363
6168
  function removeStatusFile() {
6169
+ if (!boundControlPort)
6170
+ return;
5364
6171
  daemonLifecycle.removeStatusFile();
6172
+ daemonLifecycle.removeDaemonRecord();
5365
6173
  }
5366
6174
  function armBootDeadline() {
5367
6175
  if (bootDeadlineTimer)
@@ -5434,20 +6242,41 @@ function shutdown(reason, exitCode = 0) {
5434
6242
  codex.stop();
5435
6243
  removePidFile();
5436
6244
  removeStatusFile();
6245
+ removeControlToken();
5437
6246
  process.exit(exitCode);
5438
6247
  }
6248
+ function removeControlToken() {
6249
+ if (!weWroteToken)
6250
+ return;
6251
+ try {
6252
+ rmSync2(controlTokenPath, { force: true });
6253
+ } catch {}
6254
+ }
5439
6255
  process.on("SIGINT", () => shutdown("SIGINT"));
5440
6256
  process.on("SIGTERM", () => shutdown("SIGTERM"));
5441
6257
  process.on("exit", () => {
5442
6258
  codex.forceKillAppServerSync();
5443
6259
  removePidFile();
5444
6260
  removeStatusFile();
6261
+ removeControlToken();
5445
6262
  });
5446
6263
  process.on("uncaughtException", (err) => {
5447
- processLogger.fatal("UNCAUGHT EXCEPTION", err);
6264
+ processLogger.fatal("UNCAUGHT EXCEPTION \u2014 auto-shutting down daemon", err);
6265
+ try {
6266
+ shutdown("uncaught exception", 1);
6267
+ } catch (shutdownErr) {
6268
+ processLogger.fatal("shutdown during uncaughtException failed", shutdownErr);
6269
+ }
6270
+ process.exit(1);
5448
6271
  });
5449
6272
  process.on("unhandledRejection", (reason) => {
5450
- processLogger.fatal("UNHANDLED REJECTION", reason);
6273
+ processLogger.fatal("UNHANDLED REJECTION \u2014 auto-shutting down daemon", reason);
6274
+ try {
6275
+ shutdown("unhandled rejection", 1);
6276
+ } catch (shutdownErr) {
6277
+ processLogger.fatal("shutdown during unhandledRejection failed", shutdownErr);
6278
+ }
6279
+ process.exit(1);
5451
6280
  });
5452
6281
  function log(msg) {
5453
6282
  processLogger.log(msg);
@@ -5456,7 +6285,8 @@ if (daemonLifecycle.wasKilled()) {
5456
6285
  log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
5457
6286
  process.exit(0);
5458
6287
  }
5459
- writePidFile();
5460
6288
  startControlServer();
6289
+ writePidFile();
6290
+ writeControlTokenPostBind();
5461
6291
  armBootDeadline();
5462
6292
  bootCodex();