@raysonmeng/agentbridge 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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.11", "0.0.0-source"),
21
- commit: defineString("48eb0ed", "source"),
29
+ version: defineString("0.1.13", "0.0.0-source"),
30
+ commit: defineString("7a71869", "source"),
22
31
  bundle: defineBundle("plugin"),
23
- contractVersion: defineNumber(1, CONTRACT_VERSION)
32
+ contractVersion: defineNumber(1, CONTRACT_VERSION),
33
+ codeHash: defineString("e1fd67d07c62", "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,17 +393,18 @@ 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
- function appendRotatingLog(path, content, options = {}) {
400
+ var REAL_FS_OPS = { statSync, renameSync: renameSync2, unlinkSync: unlinkSync2, appendFileSync, existsSync: existsSync2 };
401
+ function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) {
213
402
  const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
214
403
  const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
215
- if (!existsSync2(dirname(path)))
404
+ if (!fsOps.existsSync(dirname2(path)))
216
405
  return;
217
- rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
218
- appendFileSync(path, content, "utf-8");
406
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep, fsOps);
407
+ fsOps.appendFileSync(path, content, "utf-8");
219
408
  }
220
409
  function positiveIntFromEnv(name, fallback) {
221
410
  const value = process.env[name];
@@ -224,26 +413,48 @@ function positiveIntFromEnv(name, fallback) {
224
413
  const parsed = Number(value);
225
414
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
226
415
  }
227
- function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
416
+ function isEnoent(error) {
417
+ return !!error && error.code === "ENOENT";
418
+ }
419
+ function renameIfPresent(from, to, fsOps) {
420
+ try {
421
+ fsOps.renameSync(from, to);
422
+ } catch (error) {
423
+ if (!isEnoent(error))
424
+ throw error;
425
+ }
426
+ }
427
+ function unlinkIfPresent(path, fsOps) {
428
+ try {
429
+ fsOps.unlinkSync(path);
430
+ } catch (error) {
431
+ if (!isEnoent(error))
432
+ throw error;
433
+ }
434
+ }
435
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep, fsOps) {
228
436
  if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
229
437
  return;
230
- if (!existsSync2(path))
231
- return;
232
- const size = statSync(path).size;
438
+ let size;
439
+ try {
440
+ size = fsOps.statSync(path).size;
441
+ } catch (error) {
442
+ if (isEnoent(error))
443
+ return;
444
+ throw error;
445
+ }
233
446
  if (size + incomingBytes <= maxBytes)
234
447
  return;
235
448
  for (let index = keep;index >= 1; index--) {
236
449
  const current = `${path}.${index}`;
237
450
  const next = `${path}.${index + 1}`;
238
- if (!existsSync2(current))
239
- continue;
240
451
  if (index === keep) {
241
- unlinkSync(current);
452
+ unlinkIfPresent(current, fsOps);
242
453
  } else {
243
- renameSync(current, next);
454
+ renameIfPresent(current, next, fsOps);
244
455
  }
245
456
  }
246
- renameSync(path, `${path}.1`);
457
+ renameIfPresent(path, `${path}.1`, fsOps);
247
458
  }
248
459
 
249
460
  // src/process-log.ts
@@ -340,6 +551,16 @@ var APP_SERVER_NOTIFICATION_METHODS = [
340
551
  var TRACKED_REQUEST_METHOD_SET = new Set(APP_SERVER_TRACKED_REQUEST_METHODS);
341
552
  var SERVER_REQUEST_METHOD_SET = new Set(APP_SERVER_SERVER_REQUEST_METHODS);
342
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
+ ]);
343
564
  function isObjectRecord(value) {
344
565
  return typeof value === "object" && value !== null && !Array.isArray(value);
345
566
  }
@@ -362,10 +583,32 @@ function isAppServerResponseMessage(value) {
362
583
  return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
363
584
  }
364
585
 
586
+ // src/env-utils.ts
587
+ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
588
+ const raw = env[name];
589
+ if (raw == null || raw === "")
590
+ return fallback;
591
+ const parsed = Number(raw);
592
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
593
+ log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
594
+ return fallback;
595
+ }
596
+ return parsed;
597
+ }
598
+
599
+ // src/interrupt-timing.ts
600
+ var CLIENT_REPLY_TIMEOUT_MS = 15000;
601
+ var INTERRUPT_CLIENT_MARGIN_MS = 2000;
602
+ var DEFAULT_INTERRUPT_TIMEOUT_MS = 1e4;
603
+ var MAX_INTERRUPT_TIMEOUT_MS = CLIENT_REPLY_TIMEOUT_MS - INTERRUPT_CLIENT_MARGIN_MS;
604
+ function clampInterruptTimeoutMs(requested) {
605
+ return Math.min(requested, MAX_INTERRUPT_TIMEOUT_MS);
606
+ }
607
+
365
608
  // src/codex-transport.ts
366
609
  import { createServer, connect } from "net";
367
610
  import { spawnSync } from "child_process";
368
- import { mkdirSync as mkdirSync2, rmSync, chmodSync } from "fs";
611
+ import { mkdirSync as mkdirSync3, rmSync, chmodSync } from "fs";
369
612
  import { join as join2 } from "path";
370
613
  import { tmpdir } from "os";
371
614
  var CODEX_TRANSPORT_ENV = "AGENTBRIDGE_CODEX_TRANSPORT";
@@ -426,7 +669,7 @@ function ensureSocketDir(socketPath) {
426
669
  const dir = socketPath.slice(0, socketPath.lastIndexOf("/"));
427
670
  if (!dir)
428
671
  return;
429
- mkdirSync2(dir, { recursive: true, mode: 448 });
672
+ mkdirSync3(dir, { recursive: true, mode: 448 });
430
673
  try {
431
674
  chmodSync(dir, 448);
432
675
  } catch (err) {
@@ -556,10 +799,13 @@ async function waitForUnixWsReady(socketPath, maxRetries = 40, delayMs = 250) {
556
799
  function attemptUnixWsUpgrade(socketPath) {
557
800
  return new Promise((resolve) => {
558
801
  let settled = false;
802
+ let timeout;
559
803
  const done = (ok) => {
560
804
  if (settled)
561
805
  return;
562
806
  settled = true;
807
+ if (timeout !== undefined)
808
+ clearTimeout(timeout);
563
809
  try {
564
810
  socket.destroy();
565
811
  } catch {}
@@ -584,7 +830,8 @@ Sec-WebSocket-Version: 13\r
584
830
  });
585
831
  socket.on("error", () => done(false));
586
832
  socket.on("close", () => done(false));
587
- setTimeout(() => done(false), 1500);
833
+ timeout = setTimeout(() => done(false), 1500);
834
+ timeout.unref?.();
588
835
  });
589
836
  }
590
837
 
@@ -602,6 +849,95 @@ function buildTurnAbortedNotice(reason, replyWasRequired) {
602
849
  return `\u26A0\uFE0F Codex's current turn ended without completing (${reason}). ` + "This usually means Codex hit an error (e.g. a rate limit / 429), the app-server connection dropped, or the turn was interrupted." + tail;
603
850
  }
604
851
 
852
+ // src/ws-origin-guard.ts
853
+ var ALLOWED_ORIGINS_ENV = "AGENTBRIDGE_WS_ALLOWED_ORIGINS";
854
+ function parseAllowedWsOrigins(env = process.env) {
855
+ const raw = env[ALLOWED_ORIGINS_ENV];
856
+ if (raw == null || raw === "")
857
+ return new Set;
858
+ const origins = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
859
+ return new Set(origins);
860
+ }
861
+ function isAllowedWsUpgrade(req, allowedOrigins = parseAllowedWsOrigins()) {
862
+ const origin = req.headers.get("origin");
863
+ if (origin == null || origin === "")
864
+ return true;
865
+ return allowedOrigins.has(origin);
866
+ }
867
+ function wsOriginRejectedResponse() {
868
+ return new Response("Forbidden: WebSocket Origin not allowed", { status: 403 });
869
+ }
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
+
605
941
  // src/codex-adapter.ts
606
942
  class CodexAdapter extends EventEmitter {
607
943
  static RESPONSE_TRACKING_TTL_MS = 30000;
@@ -651,8 +987,13 @@ class CodexAdapter extends EventEmitter {
651
987
  static OUTAGE_TIMEOUT_MS = 1e4;
652
988
  lastInitializeRaw = null;
653
989
  lastInitializedRaw = null;
990
+ pendingInitializeProxyIds = new Set;
991
+ appServerInfo = null;
992
+ warnedAppServerVersions = new Set;
993
+ warnedFragileRateLimitMessages = new Set;
654
994
  sessionRestoreInProgress = false;
655
- replayPending = new Map;
995
+ replayPending = new PendingRequestRegistry;
996
+ replayMethods = new Map;
656
997
  static SESSION_REPLAY_TIMEOUT_MS = 5000;
657
998
  constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) {
658
999
  super();
@@ -670,16 +1011,40 @@ class CodexAdapter extends EventEmitter {
670
1011
  get activeThreadId() {
671
1012
  return this.threadId;
672
1013
  }
1014
+ get capturedAppServerInfo() {
1015
+ return this.appServerInfo;
1016
+ }
673
1017
  async start() {
674
1018
  this.intentionalDisconnect = false;
675
1019
  await this.checkPorts();
676
- this.resolveTransport();
677
- const listen = codexListenArg(this.transport, this.appPort, this.socketPath ?? "");
678
- if (this.transport === "unix" && this.socketPath) {
679
- ensureSocketDir(this.socketPath);
680
- 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;
681
1045
  }
682
- this.log(`Spawning codex app-server (transport=${this.transport}) --listen ${listen}`);
1046
+ }
1047
+ spawnAppServer(listen) {
683
1048
  this.proc = spawn("codex", ["app-server", "--listen", listen], {
684
1049
  stdio: ["pipe", "pipe", "pipe"]
685
1050
  });
@@ -693,17 +1058,25 @@ class CodexAdapter extends EventEmitter {
693
1058
  stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
694
1059
  const stdoutRl = createInterface({ input: this.proc.stdout });
695
1060
  stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
696
- if (this.transport === "unix" && this.socketPath) {
697
- await waitForUnixWsReady(this.socketPath);
698
- this.relay = new TcpToUnixRelay("127.0.0.1", this.appPort, this.socketPath, (m) => this.log(`[relay] ${m}`));
699
- await this.relay.start();
700
- this.log(`Transport relay ready: ws://127.0.0.1:${this.appPort} \u2192 unix://${this.socketPath}`);
701
- } else {
702
- 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;
1068
+ }
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}`);
703
1077
  }
704
- await this.connectToAppServer();
705
- this.startProxy();
706
- this.log(`Proxy ready on ${this.proxyUrl}`);
1078
+ this.forceKillAppServerSync();
1079
+ this.proc = null;
707
1080
  }
708
1081
  resolveTransport() {
709
1082
  const mode = parseTransportMode(process.env[CODEX_TRANSPORT_ENV]);
@@ -727,14 +1100,7 @@ class CodexAdapter extends EventEmitter {
727
1100
  } catch {}
728
1101
  this.secondaryConnections.delete(id);
729
1102
  }
730
- this.proxyServer?.stop();
731
- this.proxyServer = null;
732
- if (this.relay) {
733
- this.relay.stop();
734
- this.relay = null;
735
- }
736
- if (this.socketPath)
737
- removeSocketFile(this.socketPath);
1103
+ this.teardownTransport();
738
1104
  this.clearResponseTrackingState();
739
1105
  this.resetTurnState(ADAPTER_DISCONNECT_REASON);
740
1106
  }
@@ -764,15 +1130,15 @@ class CodexAdapter extends EventEmitter {
764
1130
  injectMessage(text, overrides) {
765
1131
  if (!this.threadId) {
766
1132
  this.log("Cannot inject: no active thread");
767
- return false;
1133
+ return null;
768
1134
  }
769
1135
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
770
1136
  this.log("Cannot inject: app-server WebSocket not connected");
771
- return false;
1137
+ return null;
772
1138
  }
773
1139
  if (this.turnInProgress) {
774
1140
  this.log(`Rejected injection: Codex turn is in progress (thread ${this.threadId})`);
775
- return false;
1141
+ return null;
776
1142
  }
777
1143
  this.log(`Injecting message into Codex (${text.length} chars)`);
778
1144
  const requestId = this.nextInjectionId--;
@@ -791,30 +1157,30 @@ class CodexAdapter extends EventEmitter {
791
1157
  id: requestId,
792
1158
  params
793
1159
  }));
794
- return true;
1160
+ return requestId;
795
1161
  } catch (err) {
796
1162
  this.untrackBridgeRequestId(requestId);
797
1163
  this.log(`Injection send failed: ${err.message}`);
798
- return false;
1164
+ return null;
799
1165
  }
800
1166
  }
801
1167
  steerMessage(text) {
802
1168
  if (!this.threadId) {
803
1169
  this.log("Cannot steer: no active thread");
804
- return false;
1170
+ return null;
805
1171
  }
806
1172
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
807
1173
  this.log("Cannot steer: app-server WebSocket not connected");
808
- return false;
1174
+ return null;
809
1175
  }
810
1176
  if (!this.turnInProgress) {
811
1177
  this.log("Cannot steer: no turn in progress (use injectMessage)");
812
- return false;
1178
+ return null;
813
1179
  }
814
1180
  const expectedTurnId = this.currentSteerableTurnId();
815
1181
  if (!expectedTurnId) {
816
1182
  this.log("Cannot steer: no addressable active turn id (turn/started carried no id)");
817
- return false;
1183
+ return null;
818
1184
  }
819
1185
  this.log(`Steering message into active Codex turn ${expectedTurnId} (${text.length} chars)`);
820
1186
  const requestId = this.nextInjectionId--;
@@ -830,12 +1196,96 @@ class CodexAdapter extends EventEmitter {
830
1196
  id: requestId,
831
1197
  params
832
1198
  }));
833
- return true;
1199
+ return requestId;
834
1200
  } catch (err) {
835
1201
  this.untrackBridgeRequestId(requestId);
836
1202
  this.log(`Steer send failed: ${err.message}`);
837
- return false;
1203
+ return null;
1204
+ }
1205
+ }
1206
+ interruptActiveTurns() {
1207
+ if (!this.threadId) {
1208
+ this.log("Cannot interrupt: no active thread");
1209
+ return { ok: false, code: "interrupt_unavailable", error: "no active thread" };
1210
+ }
1211
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
1212
+ this.log("Cannot interrupt: app-server WebSocket not connected");
1213
+ return { ok: false, code: "interrupt_unavailable", error: "app-server WebSocket not connected" };
1214
+ }
1215
+ const addressable = [...this.activeTurnIds].filter((id) => !id.startsWith("unknown:"));
1216
+ if (addressable.length === 0) {
1217
+ this.log("Cannot interrupt: no addressable active turn id (turn/started carried no id)");
1218
+ return {
1219
+ ok: false,
1220
+ code: "interrupt_unavailable",
1221
+ error: "no addressable active turn id (turn/started carried no id)"
1222
+ };
1223
+ }
1224
+ for (const turnId of addressable) {
1225
+ const requestId = this.nextInjectionId--;
1226
+ this.trackBridgeRequestId(requestId, "interrupt");
1227
+ const params = { threadId: this.threadId, turnId };
1228
+ try {
1229
+ this.appServerWs.send(JSON.stringify({
1230
+ method: "turn/interrupt",
1231
+ id: requestId,
1232
+ params
1233
+ }));
1234
+ this.log(`Sent turn/interrupt for active turn ${turnId} (request ${requestId})`);
1235
+ } catch (err) {
1236
+ this.untrackBridgeRequestId(requestId);
1237
+ this.log(`turn/interrupt send failed for ${turnId}: ${err.message}`);
1238
+ return {
1239
+ ok: false,
1240
+ code: "interrupt_unavailable",
1241
+ error: `turn/interrupt send failed (${err.message}); earlier interrupts may still land`
1242
+ };
1243
+ }
838
1244
  }
1245
+ return { ok: true, turnIds: addressable };
1246
+ }
1247
+ interruptTimeoutMs() {
1248
+ const requested = parsePositiveIntEnv("AGENTBRIDGE_INTERRUPT_TIMEOUT_MS", DEFAULT_INTERRUPT_TIMEOUT_MS, (m) => this.log(m));
1249
+ const clamped = clampInterruptTimeoutMs(requested);
1250
+ if (clamped !== requested) {
1251
+ this.log(`AGENTBRIDGE_INTERRUPT_TIMEOUT_MS=${requested}ms exceeds the safe ceiling \u2014 ` + `clamped to ${clamped}ms (must resolve before the client reply timeout to avoid a double-turn)`);
1252
+ }
1253
+ return clamped;
1254
+ }
1255
+ waitForTurnsTerminal(turnIds, timeoutMs = this.interruptTimeoutMs(), signal) {
1256
+ const satisfied = () => turnIds.every((id) => !this.activeTurnIds.has(id) && !this.currentlyStalledTurnIds.has(id));
1257
+ if (satisfied())
1258
+ return Promise.resolve({ ok: true });
1259
+ if (signal?.aborted)
1260
+ return Promise.resolve({ ok: false, code: "interrupt_aborted" });
1261
+ return new Promise((resolve) => {
1262
+ let settled = false;
1263
+ const finish = (result) => {
1264
+ if (settled)
1265
+ return;
1266
+ settled = true;
1267
+ clearTimeout(timer);
1268
+ this.off("turnIdCompleted", check);
1269
+ this.off("turnTrackingReset", check);
1270
+ this.off("turnPhaseChanged", check);
1271
+ signal?.removeEventListener("abort", onAbort);
1272
+ resolve(result);
1273
+ };
1274
+ const check = () => {
1275
+ if (satisfied())
1276
+ finish({ ok: true });
1277
+ };
1278
+ const onAbort = () => finish({ ok: false, code: "interrupt_aborted" });
1279
+ const timer = setTimeout(() => {
1280
+ this.log(`waitForTurnsTerminal timed out after ${timeoutMs}ms (still active: ` + `${turnIds.filter((id) => this.activeTurnIds.has(id)).join(", ") || "none"}, phase=${this.turnPhase})`);
1281
+ finish({ ok: false, code: "interrupt_timeout" });
1282
+ }, timeoutMs);
1283
+ timer.unref?.();
1284
+ this.on("turnIdCompleted", check);
1285
+ this.on("turnTrackingReset", check);
1286
+ this.on("turnPhaseChanged", check);
1287
+ signal?.addEventListener("abort", onAbort, { once: true });
1288
+ });
839
1289
  }
840
1290
  async waitForHealthy(maxRetries = 20, delayMs = 500) {
841
1291
  for (let i = 0;i < maxRetries; i++) {
@@ -1077,36 +1527,38 @@ class CodexAdapter extends EventEmitter {
1077
1527
  const m = e instanceof Error ? e.message : String(e);
1078
1528
  return Promise.reject(new Error(`replay parse failed for ${method}: ${m}`));
1079
1529
  }
1080
- return new Promise((resolve, reject) => {
1081
- const timer = setTimeout(() => {
1082
- this.replayPending.delete(id);
1083
- reject(new Error(`replay timeout (${CodexAdapter.SESSION_REPLAY_TIMEOUT_MS}ms) for ${method} id=${JSON.stringify(id)}`));
1084
- }, CodexAdapter.SESSION_REPLAY_TIMEOUT_MS);
1085
- this.replayPending.set(id, { method, resolve, reject, timer });
1086
- try {
1087
- this.appServerWs.send(raw);
1088
- } catch (e) {
1089
- clearTimeout(timer);
1090
- this.replayPending.delete(id);
1091
- const m = e instanceof Error ? e.message : String(e);
1092
- 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)}`));
1093
1537
  }
1094
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;
1095
1547
  }
1096
1548
  tryConsumeReplayResponse(payload) {
1097
1549
  const id = payload.id;
1098
1550
  if (id === undefined)
1099
1551
  return false;
1100
- const pending = this.replayPending.get(id);
1101
- if (!pending)
1552
+ const key = id;
1553
+ if (!this.replayPending.has(key))
1102
1554
  return false;
1103
- clearTimeout(pending.timer);
1104
- this.replayPending.delete(id);
1555
+ const method = this.replayMethods.get(key) ?? "replay";
1556
+ this.replayMethods.delete(key);
1105
1557
  if (payload.error !== undefined) {
1106
1558
  const errMsg = typeof payload.error === "object" && payload.error !== null && "message" in payload.error ? String(payload.error.message ?? "unknown") : JSON.stringify(payload.error);
1107
- pending.reject(new Error(`${pending.method} rejected: ${errMsg}`));
1559
+ this.replayPending.reject(key, new Error(`${method} rejected: ${errMsg}`));
1108
1560
  } else {
1109
- pending.resolve(payload);
1561
+ this.replayPending.settle(key, payload);
1110
1562
  }
1111
1563
  return true;
1112
1564
  }
@@ -1151,6 +1603,10 @@ class CodexAdapter extends EventEmitter {
1151
1603
  }
1152
1604
  return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
1153
1605
  }
1606
+ if (isUpgrade && !isAllowedWsUpgrade(req)) {
1607
+ self.log("Rejected WS upgrade on proxy port: Origin header present (possible CSWSH)");
1608
+ return wsOriginRejectedResponse();
1609
+ }
1154
1610
  if (server.upgrade(req, { data: { connId: 0 } }))
1155
1611
  return;
1156
1612
  self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
@@ -1397,6 +1853,9 @@ class CodexAdapter extends EventEmitter {
1397
1853
  const proxyId = this.nextProxyId++;
1398
1854
  this.upstreamToClient.set(proxyId, { connId, clientId: parsed.id });
1399
1855
  this.trackPendingRequest(parsed, connId, proxyId);
1856
+ if (parsed.method === "initialize") {
1857
+ this.pendingInitializeProxyIds.add(proxyId);
1858
+ }
1400
1859
  parsed.id = proxyId;
1401
1860
  forwarded = JSON.stringify(parsed);
1402
1861
  } else {
@@ -1549,6 +2008,9 @@ class CodexAdapter extends EventEmitter {
1549
2008
  const mapping = !isNaN(numericId) ? this.upstreamToClient.get(numericId) : undefined;
1550
2009
  if (mapping) {
1551
2010
  this.upstreamToClient.delete(numericId);
2011
+ if (!isNaN(numericId) && this.pendingInitializeProxyIds.delete(numericId)) {
2012
+ this.captureAppServerInfo(parsed.result);
2013
+ }
1552
2014
  if (mapping.connId !== this.tuiConnId) {
1553
2015
  this.log(`Dropping stale response (upstream id ${responseId}, from conn #${mapping.connId}, current #${this.tuiConnId})`);
1554
2016
  return null;
@@ -1564,16 +2026,30 @@ class CodexAdapter extends EventEmitter {
1564
2026
  if (parsed.error) {
1565
2027
  this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1566
2028
  if (bridgeKind === "steer") {
1567
- this.emit("steerFailed", parsed.error.message ?? "unknown error");
2029
+ this.emit("steerFailed", { requestId: numericId, reason: parsed.error.message ?? "unknown error" });
2030
+ } else if (bridgeKind === "interrupt") {
2031
+ this.emit("interruptFailed", parsed.error.message ?? "unknown error");
1568
2032
  } else {
1569
2033
  this.lastTurnEndedAbnormally = true;
1570
2034
  this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
2035
+ this.emit("bridgeTurnRejected", {
2036
+ requestId: numericId,
2037
+ error: parsed.error.message ?? "unknown error"
2038
+ });
1571
2039
  this.notifyPhaseIfChanged();
1572
2040
  }
1573
2041
  } else {
1574
2042
  this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
1575
2043
  if (bridgeKind === "steer") {
1576
- this.emit("steerAccepted");
2044
+ this.emit("steerAccepted", { requestId: numericId });
2045
+ } else if (bridgeKind === "turn-start") {
2046
+ const result = parsed.result;
2047
+ const turnId = result?.turn?.id;
2048
+ if (typeof turnId === "string" && turnId.length > 0) {
2049
+ this.emit("bridgeTurnStarted", { requestId: numericId, turnId });
2050
+ } else {
2051
+ this.log(`Bridge-originated turn/start response carried no turn id (id ${responseId}) \u2014 turn_started ACK skipped`);
2052
+ }
1577
2053
  }
1578
2054
  }
1579
2055
  return null;
@@ -1585,11 +2061,40 @@ class CodexAdapter extends EventEmitter {
1585
2061
  this.log(`Dropping unmatched app-server response id ${String(responseId)}`);
1586
2062
  return null;
1587
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
+ }
1588
2084
  patchResponse(parsed, raw) {
1589
2085
  if (isAppServerResponseMessage(parsed) && parsed.error && parsed.id !== undefined) {
1590
2086
  const errMsg = parsed.error.message ?? "";
1591
- if (errMsg.includes("rate limits") || errMsg.includes("rateLimits")) {
1592
- 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
+ }
1593
2098
  return JSON.stringify({
1594
2099
  id: parsed.id,
1595
2100
  result: {
@@ -1608,6 +2113,12 @@ class CodexAdapter extends EventEmitter {
1608
2113
  }
1609
2114
  return raw;
1610
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
+ }
1611
2122
  interceptServerMessage(msg, connId) {
1612
2123
  this.handleTrackedResponse(msg, connId);
1613
2124
  if ("method" in msg && typeof msg.method === "string" && isAppServerNotification(msg)) {
@@ -1775,6 +2286,9 @@ class CodexAdapter extends EventEmitter {
1775
2286
  }
1776
2287
  return newest;
1777
2288
  }
2289
+ get steerableTurnId() {
2290
+ return this.currentSteerableTurnId();
2291
+ }
1778
2292
  get turnPhase() {
1779
2293
  if (this.activeTurnIds.size > 0) {
1780
2294
  const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
@@ -1806,19 +2320,33 @@ class CodexAdapter extends EventEmitter {
1806
2320
  this.notifyPhaseIfChanged();
1807
2321
  }
1808
2322
  markTurnCompleted(turnId) {
1809
- if (typeof turnId === "string" && turnId.length > 0) {
1810
- this.activeTurnIds.delete(turnId);
1811
- this.clearTurnWatchdog(turnId);
1812
- this.stalledTurnIds.delete(turnId);
1813
- this.currentlyStalledTurnIds.delete(turnId);
2323
+ const completedId = typeof turnId === "string" && turnId.length > 0 ? turnId : null;
2324
+ if (completedId !== null) {
2325
+ const idWasTracked = this.activeTurnIds.has(completedId);
2326
+ this.activeTurnIds.delete(completedId);
2327
+ this.clearTurnWatchdog(completedId);
2328
+ this.stalledTurnIds.delete(completedId);
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
+ }
1814
2340
  } else {
1815
2341
  this.activeTurnIds.clear();
1816
2342
  this.clearAllTurnWatchdogs();
1817
2343
  this.stalledTurnIds.clear();
1818
2344
  this.currentlyStalledTurnIds.clear();
2345
+ this.agentMessageBuffers.clear();
1819
2346
  }
1820
2347
  this.lastTurnEndedAbnormally = false;
1821
2348
  this.turnInProgress = this.activeTurnIds.size > 0;
2349
+ this.emit("turnIdCompleted", completedId);
1822
2350
  this.notifyPhaseIfChanged();
1823
2351
  }
1824
2352
  turnWatchdogMs() {
@@ -1877,6 +2405,7 @@ class CodexAdapter extends EventEmitter {
1877
2405
  this.clearAllTurnWatchdogs();
1878
2406
  this.stalledTurnIds.clear();
1879
2407
  this.currentlyStalledTurnIds.clear();
2408
+ this.agentMessageBuffers.clear();
1880
2409
  this.turnInProgress = false;
1881
2410
  if (wasInProgress) {
1882
2411
  this.lastTurnEndedAbnormally = !emitCompleted;
@@ -1888,6 +2417,7 @@ class CodexAdapter extends EventEmitter {
1888
2417
  this.log(`Turn state reset (${reason})`);
1889
2418
  }
1890
2419
  this.notifyPhaseIfChanged();
2420
+ this.emit("turnTrackingReset", reason);
1891
2421
  }
1892
2422
  requestKey(id) {
1893
2423
  if (typeof id === "number" || typeof id === "string")
@@ -1964,6 +2494,7 @@ class CodexAdapter extends EventEmitter {
1964
2494
  clearTransientResponseTrackingState() {
1965
2495
  this.pendingRequests.clear();
1966
2496
  this.upstreamToClient.clear();
2497
+ this.pendingInitializeProxyIds.clear();
1967
2498
  for (const timer of this.staleProxyIds.values()) {
1968
2499
  clearTimeout(timer);
1969
2500
  }
@@ -2019,11 +2550,65 @@ var CLOSE_CODE_REPLACED = 4001;
2019
2550
  var CLOSE_CODE_EVICTED_STALE = 4002;
2020
2551
  var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
2021
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
+ }
2022
2593
 
2023
2594
  // src/daemon-identity.ts
2024
2595
  function validateClaudeClientIdentity(input) {
2025
- if (!input.expectedPairId)
2026
- 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
+ }
2027
2612
  if (!input.identity) {
2028
2613
  return input.allowIdentityless ? { ok: true } : { ok: false, closeCode: CLOSE_CODE_PAIR_MISMATCH, reason: "missing client identity" };
2029
2614
  }
@@ -2041,10 +2626,43 @@ function validateClaudeClientIdentity(input) {
2041
2626
  reason: `cwd mismatch: expected ${input.daemonCwd}, got ${input.identity.cwd ?? "<none>"}`
2042
2627
  };
2043
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
+ }
2044
2649
  return { ok: true };
2045
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
+ }
2046
2661
 
2047
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;
2048
2666
  var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
2049
2667
  function parseMarker(content) {
2050
2668
  const match = content.match(MARKER_REGEX);
@@ -2070,6 +2688,37 @@ function classifyMessage(content, mode) {
2070
2688
  return { action: "forward", marker };
2071
2689
  }
2072
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
+ }
2073
2722
  var REPLY_REQUIRED_INSTRUCTION = `
2074
2723
 
2075
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.`;
@@ -2079,11 +2728,14 @@ class StatusBuffer {
2079
2728
  flushTimer = null;
2080
2729
  flushThreshold;
2081
2730
  flushTimeoutMs;
2731
+ maxBuffered;
2082
2732
  paused = false;
2733
+ droppedCount = 0;
2083
2734
  constructor(onFlush, options) {
2084
2735
  this.onFlush = onFlush;
2085
2736
  this.flushThreshold = options?.flushThreshold ?? 3;
2086
2737
  this.flushTimeoutMs = options?.flushTimeoutMs ?? 15000;
2738
+ this.maxBuffered = options?.maxBuffered ?? 200;
2087
2739
  }
2088
2740
  get size() {
2089
2741
  return this.buffer.length;
@@ -2103,6 +2755,10 @@ class StatusBuffer {
2103
2755
  }
2104
2756
  add(message) {
2105
2757
  this.buffer.push(message);
2758
+ while (this.buffer.length > this.maxBuffered) {
2759
+ this.buffer.shift();
2760
+ this.droppedCount++;
2761
+ }
2106
2762
  if (this.paused)
2107
2763
  return;
2108
2764
  this.resetTimer();
@@ -2117,19 +2773,22 @@ class StatusBuffer {
2117
2773
  const combined = this.buffer.map((m) => parseMarker(m.content).body).join(`
2118
2774
  ---
2119
2775
  `);
2776
+ const droppedNote = this.droppedCount > 0 ? `, ${this.droppedCount} older dropped` : "";
2120
2777
  const summary = {
2121
- id: `status_summary_${Date.now()}`,
2778
+ id: `status_summary_${STATUS_SUMMARY_SALT}_${++statusSummaryCounter}`,
2122
2779
  source: "codex",
2123
- content: `[STATUS summary \u2014 ${this.buffer.length} update(s), flushed: ${reason}]
2780
+ content: `[STATUS summary \u2014 ${this.buffer.length} update(s)${droppedNote}, flushed: ${reason}]
2124
2781
  ${combined}`,
2125
2782
  timestamp: Date.now()
2126
2783
  };
2127
2784
  this.onFlush(summary);
2128
2785
  this.buffer = [];
2786
+ this.droppedCount = 0;
2129
2787
  }
2130
2788
  dispose() {
2131
2789
  this.clearTimer();
2132
2790
  this.buffer = [];
2791
+ this.droppedCount = 0;
2133
2792
  }
2134
2793
  clearTimer() {
2135
2794
  if (this.flushTimer) {
@@ -2226,22 +2885,9 @@ class TuiConnectionState {
2226
2885
 
2227
2886
  // src/daemon-lifecycle.ts
2228
2887
  import { spawn as spawn2 } from "child_process";
2229
- 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";
2230
2889
  import { fileURLToPath } from "url";
2231
2890
 
2232
- // src/env-utils.ts
2233
- function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
2234
- const raw = env[name];
2235
- if (raw == null || raw === "")
2236
- return fallback;
2237
- const parsed = Number(raw);
2238
- if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
2239
- log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
2240
- return fallback;
2241
- }
2242
- return parsed;
2243
- }
2244
-
2245
2891
  // src/process-lifecycle.ts
2246
2892
  import { execFileSync as execFileSync2 } from "child_process";
2247
2893
  function commandForPid(pid) {
@@ -2283,19 +2929,74 @@ var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
2283
2929
  var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
2284
2930
  var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
2285
2931
  var REUSE_READY_DELAY_MS = 250;
2932
+ var WAIT_READY_RETRIES = 40;
2933
+ var WAIT_READY_DELAY_MS = 250;
2286
2934
  var HEALTH_FETCH_TIMEOUT_MS = 500;
2287
2935
  var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
2288
-
2289
- class DaemonLifecycle {
2290
- stateDir;
2291
- controlPort;
2292
- log;
2293
- constructor(opts) {
2294
- this.stateDir = opts.stateDir;
2295
- this.controlPort = opts.controlPort;
2296
- this.log = opts.log;
2936
+ function isReuseVerdict(verdict) {
2937
+ return verdict === "reuse" || verdict === "reuse-despite-drift";
2938
+ }
2939
+ function classifyDaemon(expectedPairId, status, buildInfo) {
2940
+ if (!status) {
2941
+ return { verdict: "unreachable", reason: "daemon status is unavailable or unparseable" };
2297
2942
  }
2298
- get healthUrl() {
2943
+ const reportedPairId = status.pairId;
2944
+ if (!expectedPairId && reportedPairId != null) {
2945
+ return {
2946
+ verdict: "manual-conflict",
2947
+ reason: `manual mode must not adopt registered pair ${reportedPairId}`
2948
+ };
2949
+ }
2950
+ if (expectedPairId) {
2951
+ if (reportedPairId == null) {
2952
+ return {
2953
+ verdict: "replace-foreign",
2954
+ reason: `pair ${expectedPairId} found daemon without pair identity`
2955
+ };
2956
+ }
2957
+ if (reportedPairId !== expectedPairId) {
2958
+ return {
2959
+ verdict: "replace-foreign",
2960
+ reason: `pair ${expectedPairId} found daemon for pair ${reportedPairId}`
2961
+ };
2962
+ }
2963
+ }
2964
+ if (!sameRuntimeContract(status.build, buildInfo)) {
2965
+ if (compatibleContractVersion(status.build, buildInfo) && status.tuiConnected === true) {
2966
+ return {
2967
+ verdict: "reuse-despite-drift",
2968
+ reason: "runtime build drift has a compatible contract and a live Codex TUI is attached"
2969
+ };
2970
+ }
2971
+ const basis = runtimeContractComparisonBasis(status.build, buildInfo) === "codeHash" ? "compared by codeHash" : "compared by commit stamp; legacy build without codeHash";
2972
+ return {
2973
+ verdict: "replace-drifted",
2974
+ reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ` + `${formatBuildInfo(buildInfo)} (${basis})`
2975
+ };
2976
+ }
2977
+ return { verdict: "reuse", reason: "daemon pair and runtime contract match" };
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
+ }
2987
+
2988
+ class DaemonLifecycle {
2989
+ stateDir;
2990
+ controlPort;
2991
+ log;
2992
+ timing;
2993
+ constructor(opts) {
2994
+ this.stateDir = opts.stateDir;
2995
+ this.controlPort = opts.controlPort;
2996
+ this.log = opts.log;
2997
+ this.timing = resolveTiming(opts.timing);
2998
+ }
2999
+ get healthUrl() {
2299
3000
  return `http://127.0.0.1:${this.controlPort}/healthz`;
2300
3001
  }
2301
3002
  get readyUrl() {
@@ -2317,55 +3018,40 @@ class DaemonLifecycle {
2317
3018
  return null;
2318
3019
  }
2319
3020
  }
2320
- isForeignDaemon(status) {
2321
- const expected = this.expectedPairId;
2322
- if (!expected)
2323
- return false;
2324
- if (!status)
2325
- return false;
2326
- const reported = status.pairId;
2327
- if (reported == null)
2328
- return true;
2329
- return reported !== expected;
2330
- }
2331
- isRegisteredPairDaemonInManualMode(status) {
2332
- return !this.expectedPairId && status?.pairId != null;
2333
- }
2334
- isBuildDrifted(status) {
2335
- if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
2336
- return false;
2337
- const runtime = status?.build;
2338
- if (!runtime)
2339
- return true;
2340
- return !sameRuntimeContract(runtime, BUILD_INFO);
3021
+ classifyDaemon(status) {
3022
+ const classification = classifyDaemon(this.expectedPairId, status, BUILD_INFO);
3023
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1" && (classification.verdict === "replace-drifted" || classification.verdict === "unreachable")) {
3024
+ return { verdict: "reuse", reason: "build drift replacement disabled by AGENTBRIDGE_ALLOW_BUILD_DRIFT" };
3025
+ }
3026
+ return classification;
2341
3027
  }
2342
- canReuseDespiteDrift(status) {
2343
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
2344
- return false;
2345
- return status?.tuiConnected === true;
3028
+ manualConflictError(status) {
3029
+ return new Error(`Control port ${this.controlPort} is owned by registered pair ${status?.pairId}. ` + `This session has no pair identity (manual mode) and will not reuse or replace it \u2014 ` + `start with \`agentbridge claude\` from that pair's directory, or set AGENTBRIDGE_CONTROL_PORT to a free port.`);
2346
3030
  }
2347
3031
  async ensureRunning() {
2348
3032
  if (await this.isHealthy()) {
2349
3033
  const status = await this.fetchStatus();
2350
- if (this.isRegisteredPairDaemonInManualMode(status)) {
2351
- throw new Error(`Control port ${this.controlPort} is owned by registered pair ${status?.pairId}. ` + `This session has no pair identity (manual mode) and will not reuse or replace it \u2014 ` + `start with \`agentbridge claude\` from that pair's directory, or set AGENTBRIDGE_CONTROL_PORT to a free port.`);
2352
- }
2353
- if (this.isForeignDaemon(status)) {
2354
- this.log(`Control port ${this.controlPort} held by a daemon for pair ${status?.pairId ?? "<none>"}, ` + `but this pair is ${this.expectedPairId} \u2014 replacing foreign daemon`);
2355
- await this.replaceUnhealthyDaemon(status?.pid);
2356
- return;
2357
- }
2358
- if (this.isBuildDrifted(status)) {
2359
- if (this.canReuseDespiteDrift(status)) {
2360
- this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `(launcher ${formatBuildInfo(BUILD_INFO)}) but a live Codex TUI is attached \u2014 reusing instead of ` + `replacing; the new build is picked up at the next restart (abg kill, then relaunch)`);
2361
- } else {
3034
+ const classification = this.classifyDaemon(status);
3035
+ switch (classification.verdict) {
3036
+ case "manual-conflict":
3037
+ throw this.manualConflictError(status);
3038
+ case "replace-foreign":
3039
+ this.log(`Control port ${this.controlPort} held by a daemon for pair ${status?.pairId ?? "<none>"}, ` + `but this pair is ${this.expectedPairId} \u2014 replacing foreign daemon`);
3040
+ await this.replaceUnhealthyDaemon(status?.pid);
3041
+ return;
3042
+ case "replace-drifted":
3043
+ case "unreachable":
2362
3044
  this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
2363
3045
  await this.replaceUnhealthyDaemon(status?.pid);
2364
3046
  return;
2365
- }
3047
+ case "reuse-despite-drift":
3048
+ this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `(launcher ${formatBuildInfo(BUILD_INFO)}) but a live Codex TUI is attached \u2014 reusing instead of ` + `replacing; the new build is picked up at the next restart (abg kill, then relaunch)`);
3049
+ break;
3050
+ case "reuse":
3051
+ break;
2366
3052
  }
2367
3053
  try {
2368
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3054
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2369
3055
  return;
2370
3056
  } catch {
2371
3057
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
@@ -2378,7 +3064,7 @@ class DaemonLifecycle {
2378
3064
  if (isProcessAlive(existingPid)) {
2379
3065
  if (isAgentBridgeDaemon(existingPid)) {
2380
3066
  try {
2381
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3067
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2382
3068
  return;
2383
3069
  } catch {
2384
3070
  this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
@@ -2392,18 +3078,21 @@ class DaemonLifecycle {
2392
3078
  }
2393
3079
  await this.withStartupLockStrict(async (locked) => {
2394
3080
  if (!locked) {
2395
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
2396
- await this.waitForReadyAndOurs();
3081
+ await this.waitForContendedStartupLock();
2397
3082
  return;
2398
3083
  }
2399
3084
  if (await this.isHealthy()) {
2400
3085
  const status = await this.fetchStatus();
2401
- if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
2402
- this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
3086
+ const classification = this.classifyDaemon(status);
3087
+ if (classification.verdict === "manual-conflict") {
3088
+ throw this.manualConflictError(status);
3089
+ }
3090
+ if (!isReuseVerdict(classification.verdict)) {
3091
+ this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}, ` + `reason=${classification.reason}) \u2014 replacing`);
2403
3092
  await this.kill(3000, status?.pid);
2404
3093
  } else {
2405
3094
  try {
2406
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3095
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2407
3096
  return;
2408
3097
  } catch {
2409
3098
  this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
@@ -2412,7 +3101,7 @@ class DaemonLifecycle {
2412
3101
  }
2413
3102
  }
2414
3103
  this.launch();
2415
- await this.waitForReady();
3104
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
2416
3105
  });
2417
3106
  }
2418
3107
  async isHealthy() {
@@ -2439,7 +3128,7 @@ class DaemonLifecycle {
2439
3128
  return false;
2440
3129
  }
2441
3130
  }
2442
- async waitForReady(maxRetries = 40, delayMs = 250) {
3131
+ async waitForReady(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
2443
3132
  for (let attempt = 0;attempt < maxRetries; attempt++) {
2444
3133
  if (await this.isReady())
2445
3134
  return;
@@ -2447,11 +3136,15 @@ class DaemonLifecycle {
2447
3136
  }
2448
3137
  throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
2449
3138
  }
2450
- async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
3139
+ async waitForReadyAndOurs(maxRetries = WAIT_READY_RETRIES, delayMs = WAIT_READY_DELAY_MS) {
2451
3140
  for (let attempt = 0;attempt < maxRetries; attempt++) {
2452
3141
  if (await this.isReady()) {
2453
3142
  const status = await this.fetchStatus();
2454
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
3143
+ const classification = this.classifyDaemon(status);
3144
+ if (classification.verdict === "manual-conflict") {
3145
+ throw this.manualConflictError(status);
3146
+ }
3147
+ if (isReuseVerdict(classification.verdict)) {
2455
3148
  return;
2456
3149
  }
2457
3150
  }
@@ -2459,22 +3152,35 @@ class DaemonLifecycle {
2459
3152
  }
2460
3153
  throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
2461
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
+ }
2462
3170
  readStatus() {
2463
3171
  try {
2464
- const raw = readFileSync(this.stateDir.statusFile, "utf-8");
3172
+ const raw = readFileSync3(this.stateDir.statusFile, "utf-8");
2465
3173
  return JSON.parse(raw);
2466
3174
  } catch {
2467
3175
  return null;
2468
3176
  }
2469
3177
  }
2470
3178
  writeStatus(status) {
2471
- this.stateDir.ensure();
2472
- writeFileSync(this.stateDir.statusFile, JSON.stringify(status, null, 2) + `
2473
- `, "utf-8");
3179
+ atomicWriteJson(this.stateDir.statusFile, status);
2474
3180
  }
2475
3181
  readPid() {
2476
3182
  try {
2477
- const raw = readFileSync(this.stateDir.pidFile, "utf-8").trim();
3183
+ const raw = readFileSync3(this.stateDir.pidFile, "utf-8").trim();
2478
3184
  if (!raw)
2479
3185
  return null;
2480
3186
  const pid = Number.parseInt(raw, 10);
@@ -2484,28 +3190,27 @@ class DaemonLifecycle {
2484
3190
  }
2485
3191
  }
2486
3192
  writePid(pid) {
2487
- this.stateDir.ensure();
2488
- writeFileSync(this.stateDir.pidFile, `${pid ?? process.pid}
2489
- `, "utf-8");
3193
+ atomicWriteText(this.stateDir.pidFile, `${pid ?? process.pid}
3194
+ `);
2490
3195
  }
2491
3196
  removePidFile() {
2492
3197
  try {
2493
- unlinkSync2(this.stateDir.pidFile);
3198
+ unlinkSync3(this.stateDir.pidFile);
2494
3199
  } catch {}
2495
3200
  }
2496
3201
  removeStatusFile() {
2497
3202
  try {
2498
- unlinkSync2(this.stateDir.statusFile);
3203
+ unlinkSync3(this.stateDir.statusFile);
2499
3204
  } catch {}
2500
3205
  }
2501
3206
  markKilled() {
2502
3207
  this.stateDir.ensure();
2503
- writeFileSync(this.stateDir.killedFile, `${Date.now()}
3208
+ writeFileSync2(this.stateDir.killedFile, `${Date.now()}
2504
3209
  `, "utf-8");
2505
3210
  }
2506
3211
  clearKilled() {
2507
3212
  try {
2508
- unlinkSync2(this.stateDir.killedFile);
3213
+ unlinkSync3(this.stateDir.killedFile);
2509
3214
  } catch {}
2510
3215
  }
2511
3216
  wasKilled() {
@@ -2527,21 +3232,26 @@ class DaemonLifecycle {
2527
3232
  daemonProc.unref();
2528
3233
  }
2529
3234
  removeStalePidFile() {
2530
- this.log("Removing stale pid file");
3235
+ this.log("Removing stale daemon identity files");
2531
3236
  this.removePidFile();
3237
+ this.removeStatusFile();
3238
+ this.removeDaemonRecord();
2532
3239
  }
2533
3240
  async replaceUnhealthyDaemon(statusPid) {
2534
3241
  await this.withStartupLockStrict(async (locked) => {
2535
3242
  if (!locked) {
2536
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
2537
- await this.waitForReadyAndOurs();
3243
+ await this.waitForContendedStartupLock();
2538
3244
  return;
2539
3245
  }
2540
3246
  if (await this.isHealthy()) {
2541
3247
  const status = await this.fetchStatus();
2542
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
3248
+ const classification = this.classifyDaemon(status);
3249
+ if (classification.verdict === "manual-conflict") {
3250
+ throw this.manualConflictError(status);
3251
+ }
3252
+ if (isReuseVerdict(classification.verdict)) {
2543
3253
  try {
2544
- await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
3254
+ await this.waitForReady(this.timing.reuseReadyRetries, this.timing.reuseReadyDelayMs);
2545
3255
  return;
2546
3256
  } catch {}
2547
3257
  }
@@ -2549,9 +3259,13 @@ class DaemonLifecycle {
2549
3259
  this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
2550
3260
  await this.kill(3000, statusPid);
2551
3261
  this.launch();
2552
- await this.waitForReady();
3262
+ await this.waitForReady(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
2553
3263
  });
2554
3264
  }
3265
+ async waitForContendedStartupLock() {
3266
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
3267
+ await this.waitForReadyAndOurs(this.timing.waitReadyRetries, this.timing.waitReadyDelayMs);
3268
+ }
2555
3269
  async withStartupLockStrict(fn) {
2556
3270
  const locked = this.acquireLockStrict();
2557
3271
  try {
@@ -2565,15 +3279,15 @@ class DaemonLifecycle {
2565
3279
  this.stateDir.ensure();
2566
3280
  let fd = null;
2567
3281
  try {
2568
- fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
2569
- writeFileSync(fd, `${process.pid}
3282
+ fd = openSync2(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
3283
+ writeFileSync2(fd, `${process.pid}
2570
3284
  `);
2571
- closeSync(fd);
3285
+ closeSync2(fd);
2572
3286
  return true;
2573
3287
  } catch (err) {
2574
3288
  if (fd !== null && err.code !== "EEXIST") {
2575
3289
  try {
2576
- closeSync(fd);
3290
+ closeSync2(fd);
2577
3291
  } catch {}
2578
3292
  this.releaseLock();
2579
3293
  }
@@ -2581,7 +3295,7 @@ class DaemonLifecycle {
2581
3295
  if (reclaimed)
2582
3296
  return false;
2583
3297
  try {
2584
- 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);
2585
3299
  if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
2586
3300
  this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
2587
3301
  this.releaseLock();
@@ -2610,7 +3324,7 @@ class DaemonLifecycle {
2610
3324
  }
2611
3325
  releaseLock() {
2612
3326
  try {
2613
- unlinkSync2(this.stateDir.lockFile);
3327
+ unlinkSync3(this.stateDir.lockFile);
2614
3328
  } catch {}
2615
3329
  }
2616
3330
  async kill(gracefulTimeoutMs = 3000, pidOverride) {
@@ -2656,6 +3370,7 @@ class DaemonLifecycle {
2656
3370
  cleanup() {
2657
3371
  this.removePidFile();
2658
3372
  this.removeStatusFile();
3373
+ this.removeDaemonRecord();
2659
3374
  }
2660
3375
  }
2661
3376
  async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
@@ -2669,11 +3384,11 @@ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
2669
3384
  }
2670
3385
 
2671
3386
  // src/config-service.ts
2672
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
2673
- 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";
2674
3389
  var DEFAULT_BUDGET_CONFIG = {
2675
3390
  enabled: true,
2676
- pollSeconds: 60,
3391
+ pollSeconds: 300,
2677
3392
  pauseAt: 90,
2678
3393
  resumeBelow: 30,
2679
3394
  syncDriftPct: 10,
@@ -2702,9 +3417,52 @@ var DEFAULT_CONFIG = {
2702
3417
  };
2703
3418
  var CONFIG_DIR = ".agentbridge";
2704
3419
  var CONFIG_FILE = "config.json";
3420
+ var NOOP_LOGGER = () => {};
2705
3421
  function isRecord(value) {
2706
3422
  return typeof value === "object" && value !== null && !Array.isArray(value);
2707
3423
  }
3424
+ function isCoercibleNumber(value) {
3425
+ if (typeof value === "number")
3426
+ return Number.isFinite(value);
3427
+ if (typeof value === "string")
3428
+ return Number.isFinite(Number(value));
3429
+ return false;
3430
+ }
3431
+ function findShapeViolation(raw) {
3432
+ if ("idleShutdownSeconds" in raw && !isCoercibleNumber(raw.idleShutdownSeconds)) {
3433
+ return "idleShutdownSeconds is present but not a number";
3434
+ }
3435
+ if ("budget" in raw) {
3436
+ const budget = raw.budget;
3437
+ if (!isRecord(budget)) {
3438
+ return "budget is present but not an object";
3439
+ }
3440
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
3441
+ for (const key of numericKeys) {
3442
+ if (key in budget && !isCoercibleNumber(budget[key])) {
3443
+ return `budget.${key} is present but not a number`;
3444
+ }
3445
+ }
3446
+ if ("parallel" in budget) {
3447
+ const parallel = budget.parallel;
3448
+ if (!isRecord(parallel)) {
3449
+ return "budget.parallel is present but not an object";
3450
+ }
3451
+ for (const key of ["minRemainingPct", "timeWindowSec"]) {
3452
+ if (key in parallel && !isCoercibleNumber(parallel[key])) {
3453
+ return `budget.parallel.${key} is present but not a number`;
3454
+ }
3455
+ }
3456
+ }
3457
+ }
3458
+ return null;
3459
+ }
3460
+ function hasCustomDecisionValues(config) {
3461
+ const d = DEFAULT_CONFIG;
3462
+ const b = config.budget;
3463
+ const db = d.budget;
3464
+ return config.idleShutdownSeconds !== d.idleShutdownSeconds || config.turnCoordination.attentionWindowSeconds !== d.turnCoordination.attentionWindowSeconds || config.codex.appPort !== d.codex.appPort || config.codex.proxyPort !== d.codex.proxyPort || b.enabled !== db.enabled || b.pollSeconds !== db.pollSeconds || b.pauseAt !== db.pauseAt || b.resumeBelow !== db.resumeBelow || b.syncDriftPct !== db.syncDriftPct || b.parallel.minRemainingPct !== db.parallel.minRemainingPct || b.parallel.timeWindowSec !== db.parallel.timeWindowSec || b.codexTierControl !== db.codexTierControl;
3465
+ }
2708
3466
  function normalizeInteger(value, fallback) {
2709
3467
  if (typeof value === "number" && Number.isFinite(value))
2710
3468
  return value;
@@ -2740,35 +3498,35 @@ function normalizeCodexOverride(raw) {
2740
3498
  override.effort = raw.effort.trim();
2741
3499
  return Object.keys(override).length > 0 ? override : null;
2742
3500
  }
2743
- function normalizeCodexTiers(raw) {
3501
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
2744
3502
  const tiers = isRecord(raw) ? raw : {};
2745
3503
  return {
2746
3504
  full: normalizeCodexOverride(tiers.full),
2747
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
2748
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
3505
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
3506
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
2749
3507
  };
2750
3508
  }
2751
- function normalizeBudgetConfig(raw) {
3509
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
2752
3510
  const budget = isRecord(raw) ? raw : {};
2753
3511
  const parallel = isRecord(budget.parallel) ? budget.parallel : {};
2754
- const codexTiers = normalizeCodexTiers(budget.codexTiers);
2755
- let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
2756
- let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
3512
+ const codexTiers = normalizeCodexTiers(budget.codexTiers, fallback.codexTiers);
3513
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, fallback.pauseAt, 1, 100);
3514
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, fallback.resumeBelow, 0, 99);
2757
3515
  if (pauseAt <= resumeBelow) {
2758
3516
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
2759
3517
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
2760
3518
  }
2761
3519
  return {
2762
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
2763
- pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
3520
+ enabled: normalizeBoolean(budget.enabled, fallback.enabled),
3521
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
2764
3522
  pauseAt,
2765
3523
  resumeBelow,
2766
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
3524
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
2767
3525
  parallel: {
2768
- minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
2769
- timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
3526
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
3527
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
2770
3528
  },
2771
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
3529
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
2772
3530
  codexTiers
2773
3531
  };
2774
3532
  }
@@ -2786,7 +3544,7 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
2786
3544
  codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
2787
3545
  codexTiers: budget.codexTiers
2788
3546
  };
2789
- return normalizeBudgetConfig(overlay);
3547
+ return normalizeBudgetConfig(overlay, budget);
2790
3548
  }
2791
3549
  function normalizeConfig(raw) {
2792
3550
  if (!isRecord(raw))
@@ -2798,13 +3556,13 @@ function normalizeConfig(raw) {
2798
3556
  return {
2799
3557
  version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version,
2800
3558
  codex: {
2801
- appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
2802
- proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
3559
+ appPort: normalizeBoundedInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort, 1, 65535),
3560
+ proxyPort: normalizeBoundedInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort, 1, 65535)
2803
3561
  },
2804
3562
  turnCoordination: {
2805
- attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
3563
+ attentionWindowSeconds: normalizeBoundedInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds, 0, Number.MAX_SAFE_INTEGER)
2806
3564
  },
2807
- idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
3565
+ idleShutdownSeconds: normalizeBoundedInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds, 1, Number.MAX_SAFE_INTEGER),
2808
3566
  budget: normalizeBudgetConfig(config.budget)
2809
3567
  };
2810
3568
  }
@@ -2814,27 +3572,69 @@ class ConfigService {
2814
3572
  configPath;
2815
3573
  constructor(projectRoot) {
2816
3574
  const root = projectRoot ?? process.cwd();
2817
- this.configDir = join3(root, CONFIG_DIR);
2818
- this.configPath = join3(this.configDir, CONFIG_FILE);
3575
+ this.configDir = join4(root, CONFIG_DIR);
3576
+ this.configPath = join4(this.configDir, CONFIG_FILE);
2819
3577
  }
2820
3578
  hasConfig() {
2821
3579
  return existsSync4(this.configPath);
2822
3580
  }
2823
3581
  load() {
3582
+ let raw;
2824
3583
  try {
2825
- const raw = readFileSync2(this.configPath, "utf-8");
2826
- return normalizeConfig(JSON.parse(raw));
2827
- } catch {
2828
- return null;
3584
+ raw = readFileSync4(this.configPath, "utf-8");
3585
+ } catch (err) {
3586
+ if (err?.code === "ENOENT") {
3587
+ return { state: "absent" };
3588
+ }
3589
+ return { state: "corrupt", reason: `config.json is unreadable: ${err.message}` };
2829
3590
  }
3591
+ let parsed;
3592
+ try {
3593
+ parsed = JSON.parse(raw);
3594
+ } catch (err) {
3595
+ return {
3596
+ state: "corrupt",
3597
+ reason: `config.json is not valid JSON: ${err.message}`
3598
+ };
3599
+ }
3600
+ if (!isRecord(parsed)) {
3601
+ return { state: "corrupt", reason: "config.json is not a JSON object" };
3602
+ }
3603
+ const violation = findShapeViolation(parsed);
3604
+ if (violation) {
3605
+ return { state: "corrupt", reason: `config.json is shape-invalid: ${violation}` };
3606
+ }
3607
+ const config = normalizeConfig(parsed);
3608
+ if (!config) {
3609
+ return { state: "corrupt", reason: "config.json could not be normalized" };
3610
+ }
3611
+ return { state: "parsed", config };
3612
+ }
3613
+ loadOrDefault(log = NOOP_LOGGER) {
3614
+ const result = this.load();
3615
+ if (result.state === "parsed")
3616
+ return result.config;
3617
+ if (result.state === "corrupt") {
3618
+ log(`config.json at ${this.configPath} is unusable (${result.reason}); ` + "falling back to defaults \u2014 your custom budget thresholds / idle-shutdown settings are NOT in effect. " + "Fix the file and restart to re-apply them.");
3619
+ }
3620
+ return structuredClone(DEFAULT_CONFIG);
2830
3621
  }
2831
- loadOrDefault() {
2832
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
3622
+ describeConfig() {
3623
+ const result = this.load();
3624
+ if (result.state === "absent") {
3625
+ return { state: "absent", path: this.configPath, customValues: false };
3626
+ }
3627
+ if (result.state === "corrupt") {
3628
+ return { state: "corrupt", path: this.configPath, reason: result.reason, customValues: false };
3629
+ }
3630
+ return {
3631
+ state: "parsed",
3632
+ path: this.configPath,
3633
+ customValues: hasCustomDecisionValues(result.config)
3634
+ };
2833
3635
  }
2834
3636
  save(config) {
2835
- this.ensureConfigDir();
2836
- writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
2837
- `, "utf-8");
3637
+ atomicWriteJson(this.configPath, config);
2838
3638
  }
2839
3639
  initDefaults() {
2840
3640
  this.ensureConfigDir();
@@ -2850,11 +3650,32 @@ class ConfigService {
2850
3650
  }
2851
3651
  ensureConfigDir() {
2852
3652
  if (!existsSync4(this.configDir)) {
2853
- mkdirSync3(this.configDir, { recursive: true });
3653
+ mkdirSync4(this.configDir, { recursive: true });
2854
3654
  }
2855
3655
  }
2856
3656
  }
2857
3657
 
3658
+ // src/budget/budget-gate.ts
3659
+ function matchingGateReset(usage) {
3660
+ if (!usage)
3661
+ return 0;
3662
+ const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
3663
+ const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
3664
+ const candidates = matching.length > 0 ? matching : windows;
3665
+ if (candidates.length === 0)
3666
+ return 0;
3667
+ return Math.min(...candidates.map((window) => window.resetEpoch));
3668
+ }
3669
+ function resumeBlockingEpoch(usage, cfg, now) {
3670
+ if (!usage)
3671
+ return 0;
3672
+ if (usage.rateLimitedUntil > now)
3673
+ return usage.rateLimitedUntil;
3674
+ if (usage.gateUtil >= cfg.resumeBelow)
3675
+ return matchingGateReset(usage);
3676
+ return 0;
3677
+ }
3678
+
2858
3679
  // src/budget/types.ts
2859
3680
  var STALE_MAX_AGE_SEC = 600;
2860
3681
 
@@ -2879,25 +3700,6 @@ function usageSummary(name, usage) {
2879
3700
  return `${AGENT_LABEL[name]} \u672A\u77E5`;
2880
3701
  return `${AGENT_LABEL[name]} gate=${pct(usage.gateUtil)} warn=${pct(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
2881
3702
  }
2882
- function matchingGateReset(usage) {
2883
- if (!usage)
2884
- return 0;
2885
- const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
2886
- const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
2887
- const candidates = matching.length > 0 ? matching : windows;
2888
- if (candidates.length === 0)
2889
- return 0;
2890
- return Math.min(...candidates.map((window) => window.resetEpoch));
2891
- }
2892
- function resumeBlockingEpoch(usage, cfg, now) {
2893
- if (!usage)
2894
- return 0;
2895
- if (usage.rateLimitedUntil > now)
2896
- return usage.rateLimitedUntil;
2897
- if (usage.gateUtil >= cfg.resumeBelow)
2898
- return matchingGateReset(usage);
2899
- return 0;
2900
- }
2901
3703
  function resumeAfterEpoch(claude, codex, cfg, now) {
2902
3704
  const epochs = [
2903
3705
  resumeBlockingEpoch(claude, cfg, now),
@@ -3080,7 +3882,7 @@ function computeBudgetState(claude, codex, cfg, now) {
3080
3882
  };
3081
3883
  }
3082
3884
 
3083
- // src/budget/budget-coordinator.ts
3885
+ // src/budget/budget-fingerprint.ts
3084
3886
  var RESET_FINGERPRINT_BUCKET_SEC = 600;
3085
3887
  var AGENT_LABEL2 = {
3086
3888
  claude: "Claude",
@@ -3089,20 +3891,231 @@ var AGENT_LABEL2 = {
3089
3891
  function pct2(value) {
3090
3892
  return `${Math.round(value * 10) / 10}%`;
3091
3893
  }
3092
- function usageLine(agent, usage) {
3894
+ function formatEpoch2(epoch) {
3895
+ return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
3896
+ }
3897
+ var INITIAL_FINGERPRINT_STATE = {
3898
+ side: null,
3899
+ fingerprint: null,
3900
+ resumeEpoch: null,
3901
+ reason: null
3902
+ };
3903
+ function sideToAgents(side) {
3904
+ if (side === "both")
3905
+ return ["claude", "codex"];
3906
+ if (side === "claude")
3907
+ return ["claude"];
3908
+ if (side === "codex")
3909
+ return ["codex"];
3910
+ return [];
3911
+ }
3912
+ function agentsToSide(agents) {
3913
+ const claude = agents.has("claude");
3914
+ const codex = agents.has("codex");
3915
+ if (claude && codex)
3916
+ return "both";
3917
+ if (claude)
3918
+ return "claude";
3919
+ if (codex)
3920
+ return "codex";
3921
+ return null;
3922
+ }
3923
+ function shouldEnter(usage, cfg, now) {
3924
+ if (!isDecisionGrade(usage, now))
3925
+ return false;
3926
+ return usage.gateUtil >= cfg.pauseAt;
3927
+ }
3928
+ function canAgentResume(usage, cfg, now) {
3929
+ if (!isDecisionGrade(usage, now))
3930
+ return false;
3931
+ if (usage.rateLimitedUntil > now)
3932
+ return false;
3933
+ return usage.gateUtil < cfg.resumeBelow;
3934
+ }
3935
+ function nextActiveSide(prevSide, state, cfg) {
3936
+ const active = new Set(sideToAgents(prevSide));
3937
+ for (const agent of ["claude", "codex"]) {
3938
+ const usage = state.perAgent[agent];
3939
+ if (shouldEnter(usage, cfg, state.now)) {
3940
+ active.add(agent);
3941
+ } else if (active.has(agent) && canAgentResume(usage, cfg, state.now)) {
3942
+ active.delete(agent);
3943
+ }
3944
+ }
3945
+ return agentsToSide(active);
3946
+ }
3947
+ function activeSideReason(agent, usage, cfg, now) {
3093
3948
  if (!usage)
3094
- return `${AGENT_LABEL2[agent]} \u672A\u77E5`;
3095
- return `${AGENT_LABEL2[agent]} gate=${pct2(usage.gateUtil)} warn=${pct2(usage.warnUtil)}`;
3949
+ return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
3950
+ if (usage.rateLimitedUntil > now) {
3951
+ return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${formatEpoch2(usage.rateLimitedUntil)}`;
3952
+ }
3953
+ if (usage.gateUtil >= cfg.pauseAt) {
3954
+ return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(cfg.pauseAt)}`;
3955
+ }
3956
+ return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(cfg.resumeBelow)}`;
3957
+ }
3958
+ function interventionReason(side, state, cfg) {
3959
+ return sideToAgents(side).map((agent) => activeSideReason(agent, state.perAgent[agent], cfg, state.now)).join("\uFF1B");
3960
+ }
3961
+ function resumeAfterEpoch2(side, state, cfg) {
3962
+ const epochs = sideToAgents(side).map((agent) => resumeBlockingEpoch(state.perAgent[agent], cfg, state.now)).filter((epoch) => epoch > 0);
3963
+ if (epochs.length === 0)
3964
+ return null;
3965
+ return Math.max(...epochs);
3966
+ }
3967
+ function activeSideProbeUncertain(side, state) {
3968
+ return sideToAgents(side).some((agent) => {
3969
+ const usage = state.perAgent[agent];
3970
+ return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
3971
+ });
3972
+ }
3973
+ function directiveFingerprint(state, activeSide) {
3974
+ const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
3975
+ let reset = 0;
3976
+ if (activeSide === "claude") {
3977
+ reset = state.pause.resetEpochs.claude;
3978
+ } else if (activeSide === "codex") {
3979
+ reset = state.pause.resetEpochs.codex;
3980
+ } else if (activeSide === "both") {
3981
+ reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3982
+ } else if (state.phase === "balance" && state.drift.lighter) {
3983
+ reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
3984
+ }
3985
+ const heavier = activeSide ? "" : state.drift.heavier ?? "none";
3986
+ return [
3987
+ activeSide ? "paused" : state.phase,
3988
+ heavier,
3989
+ side,
3990
+ Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
3991
+ ].join("|");
3992
+ }
3993
+ function classifyPoll(prev, state, cfg) {
3994
+ const previousSide = prev.side;
3995
+ const currentSide = nextActiveSide(previousSide, state, cfg);
3996
+ if (currentSide) {
3997
+ const reason = interventionReason(currentSide, state, cfg);
3998
+ const nextResumeRaw = resumeAfterEpoch2(currentSide, state, cfg);
3999
+ const resumeEpoch = previousSide === currentSide ? nextResumeRaw ?? prev.resumeEpoch : nextResumeRaw;
4000
+ const uncertain = previousSide === currentSide && activeSideProbeUncertain(currentSide, state) && prev.fingerprint;
4001
+ const fingerprint2 = uncertain ? prev.fingerprint : directiveFingerprint(state, currentSide);
4002
+ const pauseChanged = !previousSide;
4003
+ const emit = !previousSide || previousSide !== currentSide || fingerprint2 !== prev.fingerprint;
4004
+ return {
4005
+ next: { side: currentSide, fingerprint: fingerprint2, resumeEpoch, reason },
4006
+ effect: {
4007
+ kind: uncertain ? "hold-uncertain" : "enter",
4008
+ side: currentSide,
4009
+ reason,
4010
+ resumeEpoch,
4011
+ emit,
4012
+ pauseChanged
4013
+ }
4014
+ };
4015
+ }
4016
+ if (previousSide) {
4017
+ return {
4018
+ next: { side: null, fingerprint: null, resumeEpoch: null, reason: null },
4019
+ effect: { kind: "exit", previousSide }
4020
+ };
4021
+ }
4022
+ if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
4023
+ return { next: prev, effect: { kind: "none" } };
4024
+ }
4025
+ if (!state.directiveToClaude) {
4026
+ return {
4027
+ next: { side: null, fingerprint: null, resumeEpoch: null, reason: null },
4028
+ effect: { kind: "none" }
4029
+ };
4030
+ }
4031
+ const fingerprint = directiveFingerprint(state);
4032
+ if (fingerprint !== prev.fingerprint) {
4033
+ return {
4034
+ next: { side: null, fingerprint, resumeEpoch: null, reason: null },
4035
+ effect: { kind: "advise", phase: state.phase }
4036
+ };
4037
+ }
4038
+ return { next: prev, effect: { kind: "none" } };
4039
+ }
4040
+
4041
+ // src/budget/budget-coordinator.ts
4042
+ var LOW_UTIL_PCT = 50;
4043
+ var NEAR_PAUSE_MARGIN_PCT = 10;
4044
+ var NEAR_WARN_UTIL_PCT = 75;
4045
+ var NEAR_THRESHOLD_POLL_MS = 60000;
4046
+ var PAUSED_POLL_MS = 15000;
4047
+ var RESET_WAKE_AFTER_SEC = 5;
4048
+ var RESET_RECENTLY_PASSED_WINDOW_SEC = 120;
4049
+ var REAL_BUDGET_POLL_SCHEDULER = {
4050
+ setTimeout(callback, delayMs) {
4051
+ return setTimeout(() => {
4052
+ callback();
4053
+ }, delayMs);
4054
+ },
4055
+ clearTimeout(timer) {
4056
+ clearTimeout(timer);
4057
+ }
4058
+ };
4059
+ var AGENT_LABEL3 = {
4060
+ claude: "Claude",
4061
+ codex: "Codex"
4062
+ };
4063
+ function pct3(value) {
4064
+ return `${Math.round(value * 10) / 10}%`;
3096
4065
  }
3097
- function matchingGateReset2(usage) {
4066
+ function usageLine(agent, usage) {
3098
4067
  if (!usage)
4068
+ return `${AGENT_LABEL3[agent]} \u672A\u77E5`;
4069
+ return `${AGENT_LABEL3[agent]} gate=${pct3(usage.gateUtil)} warn=${pct3(usage.warnUtil)}`;
4070
+ }
4071
+ function maxPollDelayMs(config) {
4072
+ return Math.max(0, config.pollSeconds * 1000);
4073
+ }
4074
+ function capDelay(delayMs, maxDelayMs) {
4075
+ if (maxDelayMs <= 0)
3099
4076
  return 0;
3100
- const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
3101
- const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
3102
- const candidates = matching.length > 0 ? matching : windows;
4077
+ return Math.min(delayMs, maxDelayMs);
4078
+ }
4079
+ function usagePressure(usage) {
4080
+ const readings = [usage?.claude, usage?.codex].filter((agentUsage) => agentUsage !== null && agentUsage !== undefined).flatMap((agentUsage) => [agentUsage.gateUtil, agentUsage.warnUtil]);
4081
+ if (readings.length === 0)
4082
+ return null;
4083
+ return Math.max(...readings);
4084
+ }
4085
+ function usageResetEpochs(usage) {
4086
+ return [usage?.claude, usage?.codex].filter((agentUsage) => agentUsage !== null && agentUsage !== undefined).flatMap((agentUsage) => [agentUsage.fiveHour?.resetEpoch ?? 0, agentUsage.weekly?.resetEpoch ?? 0]).filter((epoch) => epoch > 0);
4087
+ }
4088
+ function adaptiveBudgetPollDelayMs(input) {
4089
+ const maxDelayMs = maxPollDelayMs(input.config);
4090
+ if (input.paused)
4091
+ return capDelay(PAUSED_POLL_MS, maxDelayMs);
4092
+ const pressure = usagePressure(input.usage);
4093
+ if (pressure === null || pressure < LOW_UTIL_PCT)
4094
+ return maxDelayMs;
4095
+ const nearPauseAt = Math.max(0, input.config.pauseAt - NEAR_PAUSE_MARGIN_PCT);
4096
+ if (pressure >= nearPauseAt || pressure >= NEAR_WARN_UTIL_PCT) {
4097
+ return capDelay(NEAR_THRESHOLD_POLL_MS, maxDelayMs);
4098
+ }
4099
+ return capDelay(maxDelayMs / 2, maxDelayMs);
4100
+ }
4101
+ function resetAlignedDelayMs(input, adaptiveDelayMs) {
4102
+ const epochs = usageResetEpochs(input.usage);
4103
+ if (epochs.length === 0)
4104
+ return null;
4105
+ const candidates = epochs.map((epoch) => {
4106
+ if (epoch >= input.now)
4107
+ return (epoch - input.now + RESET_WAKE_AFTER_SEC) * 1000;
4108
+ if (input.now - epoch <= RESET_RECENTLY_PASSED_WINDOW_SEC)
4109
+ return RESET_WAKE_AFTER_SEC * 1000;
4110
+ return null;
4111
+ }).filter((delayMs) => delayMs !== null && delayMs >= 0 && delayMs <= adaptiveDelayMs);
3103
4112
  if (candidates.length === 0)
3104
- return 0;
3105
- return Math.min(...candidates.map((window) => window.resetEpoch));
4113
+ return null;
4114
+ return Math.min(...candidates);
4115
+ }
4116
+ function nextBudgetPollDelayMs(input) {
4117
+ const adaptiveDelayMs = adaptiveBudgetPollDelayMs(input);
4118
+ return resetAlignedDelayMs(input, adaptiveDelayMs) ?? adaptiveDelayMs;
3106
4119
  }
3107
4120
 
3108
4121
  class BudgetCoordinator {
@@ -3110,15 +4123,14 @@ class BudgetCoordinator {
3110
4123
  config;
3111
4124
  emit;
3112
4125
  onPauseChange;
4126
+ onSnapshot;
3113
4127
  now;
4128
+ scheduler;
3114
4129
  log;
3115
4130
  timer = null;
3116
4131
  running = false;
3117
- activeSides = new Set;
3118
- lastDirectiveFingerprint = null;
4132
+ fpState = INITIAL_FINGERPRINT_STATE;
3119
4133
  latestSnapshot = null;
3120
- pauseReason = null;
3121
- pauseResumeAfterEpoch = null;
3122
4134
  pendingOverrideTier = null;
3123
4135
  pendingOverrides = null;
3124
4136
  lastAppliedTier = "full";
@@ -3129,7 +4141,9 @@ class BudgetCoordinator {
3129
4141
  this.config = options.config;
3130
4142
  this.emit = options.emit;
3131
4143
  this.onPauseChange = options.onPauseChange;
4144
+ this.onSnapshot = options.onSnapshot ?? (() => {});
3132
4145
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
4146
+ this.scheduler = options.scheduler ?? REAL_BUDGET_POLL_SCHEDULER;
3133
4147
  this.log = options.log ?? (() => {});
3134
4148
  }
3135
4149
  async start() {
@@ -3143,15 +4157,15 @@ class BudgetCoordinator {
3143
4157
  stop() {
3144
4158
  this.running = false;
3145
4159
  if (this.timer) {
3146
- clearTimeout(this.timer);
4160
+ this.scheduler.clearTimeout(this.timer);
3147
4161
  this.timer = null;
3148
4162
  }
3149
4163
  }
3150
4164
  isPaused() {
3151
- return this.activeSides.size > 0;
4165
+ return this.fpState.side !== null;
3152
4166
  }
3153
4167
  isGateClosed() {
3154
- return this.activeSides.has("codex");
4168
+ return this.fpState.side === "codex" || this.fpState.side === "both";
3155
4169
  }
3156
4170
  getSnapshot() {
3157
4171
  return this.latestSnapshot;
@@ -3177,11 +4191,17 @@ class BudgetCoordinator {
3177
4191
  if (!this.running)
3178
4192
  return;
3179
4193
  if (this.timer)
3180
- clearTimeout(this.timer);
3181
- const delayMs = Math.max(0, this.config.pollSeconds * 1000);
3182
- this.timer = setTimeout(() => {
4194
+ this.scheduler.clearTimeout(this.timer);
4195
+ const snapshotUsage = this.latestSnapshot ? { claude: this.latestSnapshot.claude, codex: this.latestSnapshot.codex } : null;
4196
+ const delayMs = nextBudgetPollDelayMs({
4197
+ config: this.config,
4198
+ usage: snapshotUsage,
4199
+ now: this.now(),
4200
+ paused: this.isPaused()
4201
+ });
4202
+ this.timer = this.scheduler.setTimeout(() => {
3183
4203
  this.timer = null;
3184
- this.pollAndReschedule();
4204
+ return this.pollAndReschedule();
3185
4205
  }, delayMs);
3186
4206
  }
3187
4207
  async pollAndReschedule() {
@@ -3199,7 +4219,7 @@ class BudgetCoordinator {
3199
4219
  }
3200
4220
  if (!usage) {
3201
4221
  if (!this.isPaused())
3202
- this.latestSnapshot = null;
4222
+ this.setSnapshot(null);
3203
4223
  return;
3204
4224
  }
3205
4225
  if (!this.running) {
@@ -3208,85 +4228,39 @@ class BudgetCoordinator {
3208
4228
  const state = computeBudgetState(usage.claude, usage.codex, this.config, this.now());
3209
4229
  this.updatePendingOverrides(state.effort.codexTier);
3210
4230
  this.applyState(state);
3211
- this.latestSnapshot = this.toSnapshot(state);
4231
+ this.setSnapshot(this.toSnapshot(state));
4232
+ }
4233
+ setSnapshot(snapshot) {
4234
+ this.latestSnapshot = snapshot;
4235
+ this.onSnapshot(snapshot);
3212
4236
  }
3213
4237
  applyState(state) {
3214
- const previousSide = this.pauseSide();
3215
- this.updateActiveSides(state);
3216
- const currentSide = this.pauseSide();
3217
- if (currentSide) {
3218
- this.pauseReason = this.interventionReason(state);
3219
- const nextResumeAfterEpoch = this.resumeAfterEpoch(state);
3220
- this.pauseResumeAfterEpoch = previousSide === currentSide ? nextResumeAfterEpoch ?? this.pauseResumeAfterEpoch : nextResumeAfterEpoch;
3221
- const fingerprint2 = previousSide === currentSide && this.activeSideProbeUncertain(state) && this.lastDirectiveFingerprint ? this.lastDirectiveFingerprint : this.directiveFingerprint(state, currentSide);
3222
- if (!previousSide) {
3223
- this.onPauseChange(true);
4238
+ const { next, effect } = classifyPoll(this.fpState, state, this.config);
4239
+ this.fpState = next;
4240
+ switch (effect.kind) {
4241
+ case "enter":
4242
+ case "hold-uncertain": {
4243
+ if (effect.pauseChanged)
4244
+ this.onPauseChange(true);
4245
+ if (effect.emit) {
4246
+ this.emitDirective(this.interventionPrefix(effect.side), this.interventionDirective(state, effect.side, effect.reason, effect.resumeEpoch));
4247
+ }
4248
+ return;
3224
4249
  }
3225
- if (!previousSide || previousSide !== currentSide || fingerprint2 !== this.lastDirectiveFingerprint) {
3226
- this.emitDirective(this.interventionPrefix(currentSide), this.interventionDirective(state, currentSide));
4250
+ case "exit": {
4251
+ this.onPauseChange(false);
4252
+ this.emitDirective(this.recoveryPrefix(effect.previousSide), this.recoveryDirective(state, effect.previousSide));
4253
+ return;
3227
4254
  }
3228
- this.lastDirectiveFingerprint = fingerprint2;
3229
- return;
3230
- }
3231
- if (previousSide) {
3232
- this.pauseReason = null;
3233
- this.pauseResumeAfterEpoch = null;
3234
- this.lastDirectiveFingerprint = null;
3235
- this.onPauseChange(false);
3236
- this.emitDirective(this.recoveryPrefix(previousSide), this.recoveryDirective(state, previousSide));
3237
- return;
3238
- }
3239
- if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
3240
- return;
3241
- }
3242
- if (!state.directiveToClaude) {
3243
- this.lastDirectiveFingerprint = null;
3244
- return;
3245
- }
3246
- const fingerprint = this.directiveFingerprint(state);
3247
- if (fingerprint !== this.lastDirectiveFingerprint) {
3248
- const prefix = state.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
3249
- this.emitDirective(prefix, state.directiveToClaude);
3250
- this.lastDirectiveFingerprint = fingerprint;
3251
- }
3252
- }
3253
- updateActiveSides(state) {
3254
- for (const agent of ["claude", "codex"]) {
3255
- const usage = state.perAgent[agent];
3256
- if (this.shouldEnter(usage, state.now)) {
3257
- this.activeSides.add(agent);
3258
- } else if (this.activeSides.has(agent) && this.canAgentResume(usage, state.now)) {
3259
- this.activeSides.delete(agent);
4255
+ case "advise": {
4256
+ const prefix = effect.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
4257
+ this.emitDirective(prefix, state.directiveToClaude);
4258
+ return;
3260
4259
  }
4260
+ case "none":
4261
+ return;
3261
4262
  }
3262
4263
  }
3263
- shouldEnter(usage, now) {
3264
- if (!isDecisionGrade(usage, now))
3265
- return false;
3266
- return usage.gateUtil >= this.config.pauseAt;
3267
- }
3268
- canAgentResume(usage, now) {
3269
- if (!isDecisionGrade(usage, now))
3270
- return false;
3271
- if (usage.rateLimitedUntil > now)
3272
- return false;
3273
- return usage.gateUtil < this.config.resumeBelow;
3274
- }
3275
- resumeAfterEpoch(state) {
3276
- const epochs = ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.resumeBlockingEpoch(state.perAgent[agent], state.now)).filter((epoch) => epoch > 0);
3277
- if (epochs.length === 0)
3278
- return null;
3279
- return Math.max(...epochs);
3280
- }
3281
- resumeBlockingEpoch(usage, now) {
3282
- if (!usage)
3283
- return 0;
3284
- if (usage.rateLimitedUntil > now)
3285
- return usage.rateLimitedUntil;
3286
- if (usage.gateUtil >= this.config.resumeBelow)
3287
- return matchingGateReset2(usage);
3288
- return 0;
3289
- }
3290
4264
  tierControlEnabled() {
3291
4265
  if (!this.config.codexTierControl)
3292
4266
  return false;
@@ -3320,82 +4294,24 @@ class BudgetCoordinator {
3320
4294
  this.pendingOverrideTier = tier;
3321
4295
  this.pendingOverrides = { ...overrides };
3322
4296
  }
3323
- directiveFingerprint(state, activeSide) {
3324
- const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
3325
- let reset = 0;
3326
- if (activeSide === "claude") {
3327
- reset = state.pause.resetEpochs.claude;
3328
- } else if (activeSide === "codex") {
3329
- reset = state.pause.resetEpochs.codex;
3330
- } else if (activeSide === "both") {
3331
- reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3332
- } else if (state.phase === "balance" && state.drift.lighter) {
3333
- reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
3334
- } else if (side === "claude") {
3335
- reset = state.pause.resetEpochs.claude;
3336
- } else if (side === "codex") {
3337
- reset = state.pause.resetEpochs.codex;
3338
- } else if (side === "both") {
3339
- reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
3340
- }
3341
- return [
3342
- activeSide ? "paused" : state.phase,
3343
- state.drift.heavier ?? "none",
3344
- side,
3345
- Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
3346
- ].join("|");
3347
- }
3348
4297
  emitDirective(prefix, content) {
3349
4298
  this.emit(`${prefix}_${this.sequence++}`, content);
3350
4299
  }
3351
- pauseSide() {
3352
- const claude = this.activeSides.has("claude");
3353
- const codex = this.activeSides.has("codex");
3354
- if (claude && codex)
3355
- return "both";
3356
- if (claude)
3357
- return "claude";
3358
- if (codex)
3359
- return "codex";
3360
- return null;
3361
- }
3362
4300
  interventionPrefix(side) {
3363
4301
  return side === "claude" ? "system_budget_handoff" : "system_budget_pause";
3364
4302
  }
3365
4303
  recoveryPrefix(previousSide) {
3366
4304
  return previousSide === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
3367
4305
  }
3368
- interventionDirective(state, side) {
3369
- return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, this.pauseReason ?? "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", this.pauseResumeAfterEpoch, this.config);
3370
- }
3371
- interventionReason(state) {
3372
- return ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.activeSideReason(agent, state.perAgent[agent], state.now)).join("\uFF1B");
3373
- }
3374
- activeSideProbeUncertain(state) {
3375
- return ["claude", "codex"].some((agent) => {
3376
- if (!this.activeSides.has(agent))
3377
- return false;
3378
- const usage = state.perAgent[agent];
3379
- return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
3380
- });
3381
- }
3382
- activeSideReason(agent, usage, now) {
3383
- if (!usage)
3384
- return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
3385
- if (usage.rateLimitedUntil > now) {
3386
- return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${this.formatEpoch(usage.rateLimitedUntil)}`;
3387
- }
3388
- if (usage.gateUtil >= this.config.pauseAt) {
3389
- return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(this.config.pauseAt)}`;
3390
- }
3391
- return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(this.config.resumeBelow)}`;
4306
+ interventionDirective(state, side, reason, resumeEpoch) {
4307
+ return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, reason || "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", resumeEpoch, this.config);
3392
4308
  }
3393
4309
  recoveryDirective(state, previousSide) {
3394
4310
  if (previousSide === "claude") {
3395
4311
  return [
3396
4312
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u9884\u7B97\u5DF2\u6062\u590D\u3002",
3397
4313
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3398
- `Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4314
+ `Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3399
4315
  "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"
3400
4316
  ].join(`
3401
4317
  `);
@@ -3404,7 +4320,7 @@ class BudgetCoordinator {
3404
4320
  return [
3405
4321
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
3406
4322
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3407
- `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
4323
+ `\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct3(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
3408
4324
  "\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"
3409
4325
  ].join(`
3410
4326
  `);
@@ -3412,14 +4328,11 @@ class BudgetCoordinator {
3412
4328
  return [
3413
4329
  "\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8054\u5408\u6682\u505C\u89E3\u9664\u3002",
3414
4330
  `${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
3415
- `\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`,
4331
+ `\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`,
3416
4332
  "\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"
3417
4333
  ].join(`
3418
4334
  `);
3419
4335
  }
3420
- formatEpoch(epoch) {
3421
- return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
3422
- }
3423
4336
  toSnapshot(state) {
3424
4337
  const paused = this.isPaused();
3425
4338
  return {
@@ -3430,9 +4343,9 @@ class BudgetCoordinator {
3430
4343
  driftPct: state.drift.pct,
3431
4344
  paused,
3432
4345
  gateClosed: this.isGateClosed(),
3433
- pauseSide: this.pauseSide(),
3434
- pauseReason: paused ? this.pauseReason ?? state.pause.reason : null,
3435
- resumeAfterEpoch: paused ? this.pauseResumeAfterEpoch ?? state.pause.resumeAfterEpoch : null,
4346
+ pauseSide: this.fpState.side,
4347
+ pauseReason: paused ? this.fpState.reason ?? state.pause.reason : null,
4348
+ resumeAfterEpoch: paused ? this.fpState.resumeEpoch ?? state.pause.resumeAfterEpoch : null,
3436
4349
  parallelRecommended: paused ? false : state.parallel.recommended,
3437
4350
  codexTier: state.effort.codexTier,
3438
4351
  claudeAdvice: state.effort.claudeAdvice
@@ -3444,7 +4357,7 @@ class BudgetCoordinator {
3444
4357
  import { execFile } from "child_process";
3445
4358
  import { existsSync as existsSync5 } from "fs";
3446
4359
  import { homedir as homedir2 } from "os";
3447
- import { basename, join as join4 } from "path";
4360
+ import { basename, join as join5 } from "path";
3448
4361
  var DEFAULT_TIMEOUT_MS = 1e4;
3449
4362
  var MAX_BUFFER = 1024 * 1024;
3450
4363
  function defaultRunner(command, args, options) {
@@ -3553,38 +4466,44 @@ function identifyWindows(buckets) {
3553
4466
  const weeklyMatches = buckets.filter((bucket) => bucket.id.includes("seven_day") || bucket.id.includes("secondary_window"));
3554
4467
  let fiveHour = toWindow(pickHighestUtil(fiveHourMatches));
3555
4468
  let weekly = toWindow(pickHighestUtil(weeklyMatches));
4469
+ let parsedVia = "id-match";
3556
4470
  const sorted = [...buckets].sort((a, b) => bucketSortKey(a) - bucketSortKey(b));
3557
4471
  if (!fiveHour && sorted.length > 0) {
3558
4472
  fiveHour = toWindow(sorted[0]);
4473
+ parsedVia = "positional";
3559
4474
  }
3560
4475
  if (!weekly && sorted.length > 1) {
3561
4476
  const latestDistinct = [...sorted].reverse().find((bucket) => !sameBucketWindow(bucket, fiveHour));
3562
4477
  weekly = toWindow(latestDistinct);
4478
+ if (latestDistinct)
4479
+ parsedVia = "positional";
3563
4480
  }
3564
- return { fiveHour, weekly };
4481
+ return { fiveHour, weekly, parsedVia };
3565
4482
  }
3566
- function normalizeProbeResult(raw) {
3567
- const record = asRecord(raw);
3568
- if (!record)
3569
- return null;
4483
+ function normalizeTolerantProbeRecord(record) {
3570
4484
  const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
3571
4485
  const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
3572
4486
  const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
3573
4487
  const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
3574
4488
  const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
3575
4489
  const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
4490
+ let parsedVia = "id-match";
3576
4491
  if (buckets.length === 0 && hasFiniteUtil) {
3577
4492
  const topLevelBucket = normalizeTopLevelBucket(record, gateUtil, fetchedAt);
3578
- if (topLevelBucket)
4493
+ if (topLevelBucket) {
3579
4494
  buckets.push(topLevelBucket);
4495
+ parsedVia = "top-level";
4496
+ }
3580
4497
  }
3581
4498
  const rateLimitedUntil = Math.max(0, numberOr(record.rate_limited_until ?? record.rateLimitedUntil, 0));
3582
4499
  const ok = record.ok === true;
3583
4500
  if (!ok && rateLimitedUntil <= 0 && buckets.length === 0)
3584
4501
  return null;
3585
- const { fiveHour, weekly } = identifyWindows(buckets);
4502
+ const { fiveHour, weekly, parsedVia: bucketParsedVia } = identifyWindows(buckets);
3586
4503
  if (!fiveHour && !weekly && rateLimitedUntil === 0 && !hasFiniteUtil)
3587
4504
  return null;
4505
+ if (parsedVia !== "top-level")
4506
+ parsedVia = bucketParsedVia;
3588
4507
  return {
3589
4508
  ok,
3590
4509
  stale: record.stale === true,
@@ -3594,9 +4513,37 @@ function normalizeProbeResult(raw) {
3594
4513
  weekly,
3595
4514
  remaining: clamp(100 - gateUtil, 0, 100),
3596
4515
  rateLimitedUntil,
3597
- fetchedAt
4516
+ fetchedAt,
4517
+ parsedVia
3598
4518
  };
3599
4519
  }
4520
+ var PROBE_SCHEMA_PARSERS = {
4521
+ "1": normalizeTolerantProbeRecord
4522
+ };
4523
+ function schemaVersionKey(record) {
4524
+ const value = record.schema_version ?? record.schemaVersion;
4525
+ if (typeof value === "number" && Number.isFinite(value))
4526
+ return String(value);
4527
+ if (typeof value === "string" && value.trim() !== "")
4528
+ return value.trim();
4529
+ return null;
4530
+ }
4531
+ function normalizeProbeResultWithDiagnostics(raw) {
4532
+ const record = asRecord(raw);
4533
+ if (!record)
4534
+ return { usage: null, unknownSchemaVersion: null };
4535
+ const schemaVersion = schemaVersionKey(record);
4536
+ if (schemaVersion) {
4537
+ const parser = PROBE_SCHEMA_PARSERS[schemaVersion];
4538
+ if (parser)
4539
+ return { usage: parser(record), unknownSchemaVersion: null };
4540
+ return {
4541
+ usage: normalizeTolerantProbeRecord(record),
4542
+ unknownSchemaVersion: schemaVersion
4543
+ };
4544
+ }
4545
+ return { usage: normalizeTolerantProbeRecord(record), unknownSchemaVersion: null };
4546
+ }
3600
4547
  function withTimeout(promise, timeoutMs) {
3601
4548
  let timer = null;
3602
4549
  const timeout = new Promise((_, reject) => {
@@ -3622,6 +4569,8 @@ class QuotaSource {
3622
4569
  log;
3623
4570
  now;
3624
4571
  degradedLogged = new Map;
4572
+ positionalFallbackLogged = false;
4573
+ unknownSchemaVersionsLogged = new Set;
3625
4574
  constructor(options = {}) {
3626
4575
  this.env = options.env ?? process.env;
3627
4576
  this.homeDir = options.homeDir ?? homedir2();
@@ -3656,11 +4605,11 @@ class QuotaSource {
3656
4605
  add(command, commandKind(command));
3657
4606
  return candidates;
3658
4607
  }
3659
- const binDir = join4(this.homeDir, ".budget-guard/bin");
3660
- const installedBudgetProbe = join4(binDir, "budget-probe");
4608
+ const binDir = join5(this.homeDir, ".budget-guard/bin");
4609
+ const installedBudgetProbe = join5(binDir, "budget-probe");
3661
4610
  if (existsSync5(installedBudgetProbe))
3662
4611
  add(installedBudgetProbe, "budget-probe");
3663
- const installedProbeMjs = join4(binDir, "probe.mjs");
4612
+ const installedProbeMjs = join5(binDir, "probe.mjs");
3664
4613
  if (existsSync5(installedProbeMjs))
3665
4614
  add(installedProbeMjs, "probe-mjs");
3666
4615
  return candidates;
@@ -3683,7 +4632,9 @@ class QuotaSource {
3683
4632
  this.log(`budget probe output unparseable for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
3684
4633
  continue;
3685
4634
  }
3686
- const usage = normalizeProbeResult(parsed);
4635
+ const normalized = normalizeProbeResultWithDiagnostics(parsed);
4636
+ this.noteParserDiagnostics(agent, normalized);
4637
+ const usage = normalized.usage;
3687
4638
  if (usage) {
3688
4639
  this.noteDegradation(agent, usage);
3689
4640
  return usage;
@@ -3695,6 +4646,16 @@ class QuotaSource {
3695
4646
  }
3696
4647
  return null;
3697
4648
  }
4649
+ noteParserDiagnostics(agent, normalized) {
4650
+ if (normalized.unknownSchemaVersion && !this.unknownSchemaVersionsLogged.has(normalized.unknownSchemaVersion)) {
4651
+ this.unknownSchemaVersionsLogged.add(normalized.unknownSchemaVersion);
4652
+ this.log(`unknown budget probe schema_version ${normalized.unknownSchemaVersion} for ${agent}; using tolerant legacy parser`);
4653
+ }
4654
+ if (normalized.usage?.parsedVia === "positional" && !this.positionalFallbackLogged) {
4655
+ this.positionalFallbackLogged = true;
4656
+ this.log(`budget probe positional bucket fallback for ${agent}: bucket ids did not identify quota windows; check probe schema_version/bucket ids`);
4657
+ }
4658
+ }
3698
4659
  noteDegradation(agent, usage) {
3699
4660
  const degraded = isDegradedUsage(usage, this.now());
3700
4661
  const wasDegraded = this.degradedLogged.get(agent) === true;
@@ -3711,6 +4672,148 @@ function createQuotaSource(options) {
3711
4672
  return new QuotaSource(options);
3712
4673
  }
3713
4674
 
4675
+ // src/daemon-identity-ownership.ts
4676
+ import { readFileSync as readFileSync5 } from "fs";
4677
+ var defaultRead2 = (path) => readFileSync5(path, "utf-8");
4678
+ function pidFileOwnedByUs(pidFilePath, ourPid, read = defaultRead2) {
4679
+ let raw;
4680
+ try {
4681
+ raw = read(pidFilePath);
4682
+ } catch {
4683
+ return false;
4684
+ }
4685
+ const trimmed = raw.trim();
4686
+ if (trimmed.length === 0)
4687
+ return false;
4688
+ if (!/^[+-]?\d+$/.test(trimmed))
4689
+ return false;
4690
+ const pid = Number.parseInt(trimmed, 10);
4691
+ if (!Number.isFinite(pid))
4692
+ return false;
4693
+ return pid === ourPid;
4694
+ }
4695
+
4696
+ // src/idempotency-tracker.ts
4697
+ var DEFAULT_TOMBSTONE_TTL_MS = 20 * 60 * 1000;
4698
+
4699
+ class IdempotencyTracker {
4700
+ entries = new Map;
4701
+ ttlMs;
4702
+ now;
4703
+ constructor(options = {}) {
4704
+ this.ttlMs = options.ttlMs ?? DEFAULT_TOMBSTONE_TTL_MS;
4705
+ this.now = options.now ?? Date.now;
4706
+ }
4707
+ get size() {
4708
+ return this.entries.size;
4709
+ }
4710
+ check(threadId, key) {
4711
+ const entry = this.getLive(threadId, key);
4712
+ if (!entry)
4713
+ return { duplicate: false };
4714
+ if (entry.state.phase === "terminal") {
4715
+ return { duplicate: true, code: "duplicate_terminal", state: entry.state };
4716
+ }
4717
+ return { duplicate: true, code: "duplicate_in_flight", state: entry.state };
4718
+ }
4719
+ peek(threadId, key) {
4720
+ return this.getLive(threadId, key)?.state ?? null;
4721
+ }
4722
+ accept(threadId, key) {
4723
+ if (this.getLive(threadId, key))
4724
+ return;
4725
+ this.entries.set(this.compositeKey(threadId, key), {
4726
+ threadId,
4727
+ state: { phase: "accepted" },
4728
+ expiresAtMs: null,
4729
+ timer: null
4730
+ });
4731
+ }
4732
+ release(threadId, key) {
4733
+ const composite = this.compositeKey(threadId, key);
4734
+ const entry = this.entries.get(composite);
4735
+ if (!entry || entry.state.phase === "terminal")
4736
+ return;
4737
+ this.entries.delete(composite);
4738
+ }
4739
+ markStarted(threadId, key, turnId) {
4740
+ const entry = this.getLive(threadId, key);
4741
+ if (!entry || entry.state.phase === "terminal")
4742
+ return;
4743
+ entry.state = { phase: "started", turnId };
4744
+ }
4745
+ markRejected(threadId, key) {
4746
+ const entry = this.getLive(threadId, key);
4747
+ if (!entry || entry.state.phase === "terminal")
4748
+ return;
4749
+ this.terminate(entry, "rejected");
4750
+ }
4751
+ completeTurn(turnId, threadId) {
4752
+ for (const entry of this.entries.values()) {
4753
+ if (entry.state.phase !== "started")
4754
+ continue;
4755
+ if (turnId !== null) {
4756
+ if (entry.state.turnId !== turnId)
4757
+ continue;
4758
+ } else if (threadId !== undefined && entry.threadId !== threadId) {
4759
+ continue;
4760
+ }
4761
+ this.terminate(entry, "completed");
4762
+ }
4763
+ }
4764
+ terminateThread(threadId, outcome) {
4765
+ for (const entry of this.entries.values()) {
4766
+ if (entry.threadId !== threadId || entry.state.phase === "terminal")
4767
+ continue;
4768
+ this.terminate(entry, outcome);
4769
+ }
4770
+ }
4771
+ terminateAll(outcome) {
4772
+ for (const entry of this.entries.values()) {
4773
+ if (entry.state.phase === "terminal")
4774
+ continue;
4775
+ this.terminate(entry, outcome);
4776
+ }
4777
+ }
4778
+ dispose() {
4779
+ for (const entry of this.entries.values()) {
4780
+ if (entry.timer)
4781
+ clearTimeout(entry.timer);
4782
+ }
4783
+ this.entries.clear();
4784
+ }
4785
+ compositeKey(threadId, key) {
4786
+ return `${threadId}\x00${key}`;
4787
+ }
4788
+ getLive(threadId, key) {
4789
+ const composite = this.compositeKey(threadId, key);
4790
+ const entry = this.entries.get(composite);
4791
+ if (!entry)
4792
+ return null;
4793
+ if (entry.expiresAtMs !== null && this.now() >= entry.expiresAtMs) {
4794
+ if (entry.timer)
4795
+ clearTimeout(entry.timer);
4796
+ this.entries.delete(composite);
4797
+ return null;
4798
+ }
4799
+ return entry;
4800
+ }
4801
+ terminate(entry, outcome) {
4802
+ entry.state = { phase: "terminal", outcome };
4803
+ entry.expiresAtMs = this.now() + this.ttlMs;
4804
+ const timer = setTimeout(() => {
4805
+ for (const [composite, candidate] of this.entries.entries()) {
4806
+ if (candidate === entry) {
4807
+ this.entries.delete(composite);
4808
+ break;
4809
+ }
4810
+ }
4811
+ }, this.ttlMs);
4812
+ timer.unref?.();
4813
+ entry.timer = timer;
4814
+ }
4815
+ }
4816
+
3714
4817
  // src/reply-required-tracker.ts
3715
4818
  class ReplyRequiredTracker {
3716
4819
  armed = false;
@@ -3740,14 +4843,11 @@ class ReplyRequiredTracker {
3740
4843
  // src/thread-state.ts
3741
4844
  import {
3742
4845
  existsSync as existsSync6,
3743
- mkdirSync as mkdirSync4,
3744
4846
  readdirSync,
3745
- readFileSync as readFileSync3,
3746
- renameSync as renameSync2,
3747
- writeFileSync as writeFileSync3
4847
+ readFileSync as readFileSync6
3748
4848
  } from "fs";
3749
4849
  import { homedir as homedir3 } from "os";
3750
- import { basename as basename2, dirname as dirname2, join as join5 } from "path";
4850
+ import { basename as basename2, join as join6 } from "path";
3751
4851
  function nowIso() {
3752
4852
  return new Date().toISOString();
3753
4853
  }
@@ -3756,18 +4856,11 @@ function threadTag(identity) {
3756
4856
  return `abg:${name}:${identity.cwd}`;
3757
4857
  }
3758
4858
  function codexHome(env = process.env) {
3759
- return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join5(homedir3(), ".codex");
3760
- }
3761
- function atomicWriteJson(path, value) {
3762
- mkdirSync4(dirname2(path), { recursive: true });
3763
- const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
3764
- writeFileSync3(tmp, JSON.stringify(value, null, 2) + `
3765
- `, "utf-8");
3766
- renameSync2(tmp, path);
4859
+ return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join6(homedir3(), ".codex");
3767
4860
  }
3768
4861
  function readRawCurrentThread(stateDir) {
3769
4862
  try {
3770
- const parsed = JSON.parse(readFileSync3(stateDir.currentThreadFile, "utf-8"));
4863
+ const parsed = JSON.parse(readFileSync6(stateDir.currentThreadFile, "utf-8"));
3771
4864
  if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
3772
4865
  return parsed;
3773
4866
  }
@@ -3775,7 +4868,7 @@ function readRawCurrentThread(stateDir) {
3775
4868
  return null;
3776
4869
  }
3777
4870
  function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
3778
- const sessionsDir = join5(codexHome(env), "sessions");
4871
+ const sessionsDir = join6(codexHome(env), "sessions");
3779
4872
  if (!threadId || !existsSync6(sessionsDir))
3780
4873
  return null;
3781
4874
  const exactName = `rollout-${threadId}.jsonl`;
@@ -3791,7 +4884,7 @@ function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
3791
4884
  }
3792
4885
  for (const entry of entries) {
3793
4886
  visited++;
3794
- const path = join5(dir, entry.name);
4887
+ const path = join6(dir, entry.name);
3795
4888
  if (entry.isDirectory()) {
3796
4889
  stack.push(path);
3797
4890
  continue;
@@ -3885,6 +4978,7 @@ function formatWaitingForCodexTuiMessage(options) {
3885
4978
  // src/pair-registry.ts
3886
4979
  var PAIR_BASE_PORT = 4500;
3887
4980
  var PAIR_SLOT_STRIDE = 10;
4981
+ var RECLAIMABLE_MIN_AGE_MS = 24 * 60 * 60 * 1000;
3888
4982
  var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
3889
4983
 
3890
4984
  // src/liveness-probe.ts
@@ -3915,12 +5009,57 @@ async function probeLiveness(target, options) {
3915
5009
  return target.pongCount > baseline;
3916
5010
  }
3917
5011
 
5012
+ // src/delivery-buffer.ts
5013
+ class BoundedMessageBuffer {
5014
+ messages = [];
5015
+ cap;
5016
+ overflowLabel;
5017
+ overflowNoun;
5018
+ log;
5019
+ constructor(options) {
5020
+ this.cap = options.cap;
5021
+ this.overflowLabel = options.overflowLabel;
5022
+ this.overflowNoun = options.overflowNoun ?? "message(s)";
5023
+ this.log = options.log;
5024
+ }
5025
+ get length() {
5026
+ return this.messages.length;
5027
+ }
5028
+ push(message) {
5029
+ this.messages.push(message);
5030
+ this.enforceCap();
5031
+ }
5032
+ unshiftMany(messages) {
5033
+ if (messages.length === 0)
5034
+ return;
5035
+ this.messages.unshift(...messages);
5036
+ this.enforceCap();
5037
+ }
5038
+ drainAll() {
5039
+ return this.messages.splice(0, this.messages.length);
5040
+ }
5041
+ clear() {
5042
+ this.messages.length = 0;
5043
+ }
5044
+ enforceCap() {
5045
+ if (this.messages.length > this.cap) {
5046
+ const dropped = this.messages.length - this.cap;
5047
+ this.messages.splice(0, dropped);
5048
+ this.log(`${this.overflowLabel}: dropped ${dropped} oldest ${this.overflowNoun}, ${this.cap} remaining`);
5049
+ }
5050
+ }
5051
+ }
5052
+
3918
5053
  // src/daemon.ts
3919
5054
  var stateDir = new StateDirResolver;
3920
5055
  stateDir.ensure();
3921
- var configService = new ConfigService;
3922
- var config = configService.loadOrDefault();
3923
5056
  var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
5057
+ var controlTokenPath = resolveControlTokenPath(stateDir.dir);
5058
+ var controlToken = generateControlToken();
5059
+ var weWroteToken = false;
5060
+ var weWrotePid = false;
5061
+ var configService = new ConfigService;
5062
+ var config = configService.loadOrDefault(processLogger.log);
3924
5063
  var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
3925
5064
  var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
3926
5065
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
@@ -3935,16 +5074,24 @@ var CODEX_BOOT_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_CODEX_BOOT_RETRIES", 2
3935
5074
  var ALLOW_IDENTITYLESS_CLIENT = process.env.AGENTBRIDGE_COMPAT_IDENTITYLESS === "1";
3936
5075
  var BUDGET_CONFIG = applyBudgetEnvOverrides(config.budget);
3937
5076
  var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
5077
+ var DAEMON_NONCE = randomUUID4();
5078
+ var DAEMON_STARTED_AT = Date.now();
3938
5079
  var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
3939
5080
  var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
3940
5081
  var controlServer = null;
5082
+ var boundControlPort = false;
3941
5083
  var attachedClaude = null;
3942
5084
  var nextControlClientId = 0;
3943
5085
  var nextSystemMessageId = 0;
5086
+ var SYSTEM_MSG_SALT = randomUUID4().slice(0, 8);
3944
5087
  var codexBootstrapped = false;
3945
5088
  var attentionWindowTimer = null;
3946
5089
  var inAttentionWindow = false;
3947
5090
  var replyTracker = new ReplyRequiredTracker;
5091
+ var idempotencyTracker = new IdempotencyTracker;
5092
+ var pendingTurnStarts = new Map;
5093
+ var pendingSteerDispatches = new Map;
5094
+ var BUSY_RETRY_ADVISORY_MS = 15000;
3948
5095
  var shuttingDown = false;
3949
5096
  var bootDeadlineTimer = null;
3950
5097
  var idleShutdownTimer = null;
@@ -3954,9 +5101,20 @@ var ATTACH_STATUS_COOLDOWN_MS = 30000;
3954
5101
  var LIVENESS_PROBE_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_LIVENESS_PROBE_TIMEOUT_MS", 3000, log);
3955
5102
  var LIVENESS_PROBE_POLL_MS = 50;
3956
5103
  var challengeInProgress = false;
3957
- var bufferedMessages = [];
5104
+ var bufferedMessages = new BoundedMessageBuffer({
5105
+ cap: MAX_BUFFERED_MESSAGES,
5106
+ overflowLabel: "Message buffer overflow",
5107
+ log
5108
+ });
5109
+ function createPendingBackpressureBuffer() {
5110
+ return new BoundedMessageBuffer({
5111
+ cap: MAX_BUFFERED_MESSAGES,
5112
+ overflowLabel: "Backpressure overflow",
5113
+ overflowNoun: "tracked message(s)",
5114
+ log
5115
+ });
5116
+ }
3958
5117
  var budgetCoordinator = null;
3959
- var budgetStatusTimer = null;
3960
5118
  function ensureBudgetCoordinatorStarted() {
3961
5119
  if (!BUDGET_CONFIG.enabled)
3962
5120
  return;
@@ -3967,27 +5125,18 @@ function ensureBudgetCoordinatorStarted() {
3967
5125
  config: BUDGET_CONFIG,
3968
5126
  emit: (id, content) => {
3969
5127
  emitToClaude(systemMessage(id, content));
3970
- queueMicrotask(() => broadcastStatus());
3971
5128
  },
3972
5129
  onPauseChange: (paused) => {
3973
5130
  log(`Budget intervention ${paused ? "ACTIVE" : "CLEARED"} ` + `(gate ${budgetCoordinator?.isGateClosed() ? "CLOSED" : "OPEN"})`);
3974
- queueMicrotask(() => broadcastStatus());
3975
5131
  },
5132
+ onSnapshot: () => broadcastStatus(),
3976
5133
  log
3977
5134
  });
3978
5135
  }
3979
5136
  budgetCoordinator.start();
3980
- if (!budgetStatusTimer) {
3981
- budgetStatusTimer = setInterval(() => broadcastStatus(), BUDGET_CONFIG.pollSeconds * 1000);
3982
- budgetStatusTimer.unref?.();
3983
- }
3984
5137
  }
3985
5138
  function stopBudgetCoordinator() {
3986
5139
  budgetCoordinator?.stop();
3987
- if (budgetStatusTimer) {
3988
- clearInterval(budgetStatusTimer);
3989
- budgetStatusTimer = null;
3990
- }
3991
5140
  }
3992
5141
  function budgetPauseGateError() {
3993
5142
  const snapshot = budgetCoordinator?.getSnapshot() ?? null;
@@ -4019,13 +5168,70 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
4019
5168
  tryWriteStatusFile(`turnPhase:${phase}`);
4020
5169
  broadcastStatus();
4021
5170
  });
4022
- codex.on("steerFailed", (reason) => {
5171
+ codex.on("steerFailed", ({ requestId, reason }) => {
4023
5172
  log(`Steer rejected by app-server: ${reason}`);
5173
+ const dispatch = pendingSteerDispatches.get(requestId);
5174
+ pendingSteerDispatches.delete(requestId);
5175
+ if (dispatch?.idempotencyKey && dispatch.threadId) {
5176
+ idempotencyTracker.release(dispatch.threadId, dispatch.idempotencyKey);
5177
+ log(`Released idempotency key after steer failure (request ${requestId}) \u2014 same key is retryable again`);
5178
+ }
4024
5179
  const advice = codex.turnInProgress ? "wait for it to finish (\u2705), then send normally" : "the turn has ended \u2014 resend as a normal reply";
4025
5180
  emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 ${advice}.`));
4026
5181
  });
4027
- codex.on("steerAccepted", () => {
5182
+ codex.on("steerAccepted", ({ requestId }) => {
4028
5183
  log("Steer accepted by app-server");
5184
+ const dispatch = pendingSteerDispatches.get(requestId);
5185
+ pendingSteerDispatches.delete(requestId);
5186
+ if (dispatch?.requireReply) {
5187
+ replyTracker.arm();
5188
+ log("Reply required armed on steer-accept (steer-scoped expectation)");
5189
+ }
5190
+ });
5191
+ codex.on("bridgeTurnStarted", ({ requestId, turnId }) => {
5192
+ const pending = pendingTurnStarts.get(requestId);
5193
+ if (!pending) {
5194
+ log(`bridgeTurnStarted for unknown injection ${requestId} (turn ${turnId}) \u2014 correlation dropped`);
5195
+ return;
5196
+ }
5197
+ pendingTurnStarts.delete(requestId);
5198
+ log(`Bridge turn started: injection ${requestId} \u2192 turn ${turnId} (request ${pending.requestId})`);
5199
+ if (pending.idempotencyKey) {
5200
+ idempotencyTracker.markStarted(pending.threadId, pending.idempotencyKey, turnId);
5201
+ }
5202
+ if (attachedClaude) {
5203
+ sendProtocolMessage(attachedClaude, {
5204
+ type: "turn_started",
5205
+ requestId: pending.requestId,
5206
+ ...pending.idempotencyKey ? { idempotencyKey: pending.idempotencyKey } : {},
5207
+ threadId: pending.threadId,
5208
+ turnId
5209
+ });
5210
+ }
5211
+ });
5212
+ codex.on("bridgeTurnRejected", ({ requestId, error }) => {
5213
+ const pending = pendingTurnStarts.get(requestId);
5214
+ if (!pending)
5215
+ return;
5216
+ pendingTurnStarts.delete(requestId);
5217
+ log(`Bridge turn rejected before start: injection ${requestId} (request ${pending.requestId}): ${error}`);
5218
+ if (pending.idempotencyKey) {
5219
+ idempotencyTracker.markRejected(pending.threadId, pending.idempotencyKey);
5220
+ }
5221
+ });
5222
+ codex.on("turnIdCompleted", (turnId) => {
5223
+ idempotencyTracker.completeTurn(turnId, codex.activeThreadId ?? undefined);
5224
+ });
5225
+ codex.on("turnTrackingReset", (reason) => {
5226
+ idempotencyTracker.terminateAll("aborted");
5227
+ if (pendingTurnStarts.size > 0) {
5228
+ log(`Cleared ${pendingTurnStarts.size} pending turn-start correlation(s) on turn tracking reset (${reason})`);
5229
+ }
5230
+ if (pendingSteerDispatches.size > 0) {
5231
+ log(`Cleared ${pendingSteerDispatches.size} pending steer dispatch(es) on turn tracking reset (${reason})`);
5232
+ }
5233
+ pendingTurnStarts.clear();
5234
+ pendingSteerDispatches.clear();
4029
5235
  });
4030
5236
  codex.on("turnStarted", () => {
4031
5237
  log("Codex turn started");
@@ -4034,29 +5240,22 @@ codex.on("turnStarted", () => {
4034
5240
  codex.on("agentMessage", (msg) => {
4035
5241
  if (msg.source !== "codex")
4036
5242
  return;
4037
- const result = classifyMessage(msg.content, FILTER_MODE);
4038
- if (replyTracker.isArmed) {
4039
- log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
5243
+ const route = routeCodexMessage(msg.content, {
5244
+ mode: FILTER_MODE,
5245
+ replyArmed: replyTracker.isArmed,
5246
+ inAttentionWindow
5247
+ });
5248
+ log(`Codex \u2192 Claude [${route.marker}/${route.reason}] (${msg.content.length} chars)`);
5249
+ if (route.noteReplyForwarded) {
4040
5250
  replyTracker.noteForwarded();
4041
- if (statusBuffer.size > 0) {
4042
- statusBuffer.flush("reply-required message arrived");
4043
- }
4044
- emitToClaude(msg);
4045
- return;
4046
5251
  }
4047
- if (inAttentionWindow && result.marker === "status") {
4048
- log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
4049
- statusBuffer.add(msg);
4050
- return;
5252
+ if (route.flushStatusBuffer) {
5253
+ statusBuffer.flush(route.noteReplyForwarded ? "reply-required message arrived" : "important message arrived");
4051
5254
  }
4052
- log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
4053
- switch (result.action) {
5255
+ switch (route.action) {
4054
5256
  case "forward":
4055
- if (result.marker === "important" && statusBuffer.size > 0) {
4056
- statusBuffer.flush("important message arrived");
4057
- }
4058
5257
  emitToClaude(msg);
4059
- if (result.marker === "important") {
5258
+ if (route.startAttentionWindow) {
4060
5259
  startAttentionWindow();
4061
5260
  }
4062
5261
  break;
@@ -4131,64 +5330,86 @@ codex.on("error", (err) => {
4131
5330
  });
4132
5331
  codex.on("exit", (code) => {
4133
5332
  log(`Codex process exited (code ${code})`);
5333
+ const wasBootstrapped = codexBootstrapped;
4134
5334
  codexBootstrapped = false;
4135
5335
  replyTracker.reset();
5336
+ idempotencyTracker.terminateAll("aborted");
5337
+ pendingTurnStarts.clear();
5338
+ pendingSteerDispatches.clear();
4136
5339
  statusBuffer.flush("codex exited");
4137
5340
  tuiConnectionState.handleCodexExit();
4138
5341
  clearPendingClaudeDisconnect("Codex process exited");
4139
- 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.`));
5342
+ if (wasBootstrapped) {
5343
+ 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.`));
5344
+ }
4140
5345
  broadcastStatus();
4141
- armBootDeadline();
5346
+ if (wasBootstrapped) {
5347
+ armBootDeadline();
5348
+ }
4142
5349
  });
4143
5350
  function startControlServer() {
4144
- controlServer = Bun.serve({
4145
- port: CONTROL_PORT,
4146
- hostname: "127.0.0.1",
4147
- fetch(req, server) {
4148
- const url = new URL(req.url);
4149
- if (url.pathname === "/healthz") {
4150
- return Response.json(currentStatus());
4151
- }
4152
- if (url.pathname === "/readyz") {
4153
- return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
4154
- }
4155
- if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
4156
- return;
4157
- }
4158
- return new Response("AgentBridge daemon");
4159
- },
4160
- websocket: {
4161
- idleTimeout: 960,
4162
- sendPings: true,
4163
- open: (ws) => {
4164
- ws.data.clientId = ++nextControlClientId;
4165
- ws.data.lastPongAt = Date.now();
4166
- ws.data.pendingBackpressure = [];
4167
- log(`Frontend socket opened (#${ws.data.clientId})`);
4168
- },
4169
- close: (ws, code, reason) => {
4170
- log(`Frontend socket closed (#${ws.data.clientId}, code=${code}, reason=${reason || "none"}, wasAttached=${attachedClaude === ws})`);
4171
- if (attachedClaude === ws) {
4172
- detachClaude(ws, "frontend socket closed");
5351
+ let server;
5352
+ try {
5353
+ server = Bun.serve({
5354
+ port: CONTROL_PORT,
5355
+ hostname: "127.0.0.1",
5356
+ fetch(req, server2) {
5357
+ const url = new URL(req.url);
5358
+ if (url.pathname === "/healthz") {
5359
+ return Response.json(currentStatus());
4173
5360
  }
4174
- },
4175
- message: (ws, raw) => {
4176
- handleControlMessage(ws, raw);
4177
- },
4178
- pong: (ws) => {
4179
- ws.data.lastPongAt = Date.now();
4180
- ws.data.pongCount++;
4181
- },
4182
- drain: (ws) => {
4183
- if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
4184
- ws.data.pendingBackpressure = [];
5361
+ if (url.pathname === "/readyz") {
5362
+ return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
4185
5363
  }
4186
- if (ws === attachedClaude && bufferedMessages.length > 0) {
4187
- flushBufferedMessages(ws);
5364
+ if (url.pathname === "/ws") {
5365
+ if (!isAllowedWsUpgrade(req)) {
5366
+ log("Rejected WS upgrade on control port: Origin header present (possible CSWSH)");
5367
+ return wsOriginRejectedResponse();
5368
+ }
5369
+ if (server2.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: createPendingBackpressureBuffer() } })) {
5370
+ return;
5371
+ }
5372
+ }
5373
+ return new Response("AgentBridge daemon");
5374
+ },
5375
+ websocket: {
5376
+ idleTimeout: 960,
5377
+ sendPings: true,
5378
+ open: (ws) => {
5379
+ ws.data.clientId = ++nextControlClientId;
5380
+ ws.data.lastPongAt = Date.now();
5381
+ ws.data.pendingBackpressure = createPendingBackpressureBuffer();
5382
+ log(`Frontend socket opened (#${ws.data.clientId})`);
5383
+ },
5384
+ close: (ws, code, reason) => {
5385
+ log(`Frontend socket closed (#${ws.data.clientId}, code=${code}, reason=${reason || "none"}, wasAttached=${attachedClaude === ws})`);
5386
+ if (attachedClaude === ws) {
5387
+ detachClaude(ws, "frontend socket closed");
5388
+ }
5389
+ },
5390
+ message: (ws, raw) => {
5391
+ handleControlMessage(ws, raw);
5392
+ },
5393
+ pong: (ws) => {
5394
+ ws.data.lastPongAt = Date.now();
5395
+ ws.data.pongCount++;
5396
+ },
5397
+ drain: (ws) => {
5398
+ if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
5399
+ ws.data.pendingBackpressure.clear();
5400
+ }
5401
+ if (ws === attachedClaude && bufferedMessages.length > 0) {
5402
+ flushBufferedMessages(ws);
5403
+ }
4188
5404
  }
4189
5405
  }
4190
- }
4191
- });
5406
+ });
5407
+ } catch (err) {
5408
+ log(`Control port ${CONTROL_PORT} bind failed (${err?.code ?? err?.message ?? err}) \u2014 ` + `another daemon owns it; exiting without touching shared identity files`);
5409
+ process.exit(0);
5410
+ }
5411
+ controlServer = server;
5412
+ boundControlPort = true;
4192
5413
  }
4193
5414
  function handleControlMessage(ws, raw) {
4194
5415
  let message;
@@ -4205,7 +5426,9 @@ function handleControlMessage(ws, raw) {
4205
5426
  expectedPairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
4206
5427
  daemonCwd: process.cwd(),
4207
5428
  identity: message.identity,
4208
- allowIdentityless: ALLOW_IDENTITYLESS_CLIENT
5429
+ allowIdentityless: ALLOW_IDENTITYLESS_CLIENT,
5430
+ expectedControlToken: controlToken,
5431
+ expectedContractVersion: BUILD_INFO.contractVersion
4209
5432
  });
4210
5433
  if (!admission.ok) {
4211
5434
  log(`Rejecting Claude frontend #${ws.data.clientId}: ${admission.reason}`);
@@ -4228,98 +5451,238 @@ function handleControlMessage(ws, raw) {
4228
5451
  });
4229
5452
  return;
4230
5453
  case "claude_to_codex": {
4231
- if (message.message.source !== "claude") {
4232
- sendProtocolMessage(ws, {
4233
- type: "claude_to_codex_result",
4234
- requestId: message.requestId,
4235
- success: false,
4236
- error: "Invalid message source"
4237
- });
4238
- return;
4239
- }
4240
- if (!tuiConnectionState.canReply()) {
4241
- sendProtocolMessage(ws, {
4242
- type: "claude_to_codex_result",
4243
- requestId: message.requestId,
4244
- success: false,
4245
- error: "Codex is not ready. Wait for TUI to connect and create a thread."
4246
- });
4247
- return;
4248
- }
4249
- if (budgetCoordinator?.isGateClosed()) {
4250
- const reason = budgetPauseGateError();
4251
- log(`Injection rejected by budget pause gate`);
4252
- sendProtocolMessage(ws, {
4253
- type: "claude_to_codex_result",
4254
- requestId: message.requestId,
5454
+ handleClaudeToCodex(ws, message).catch((err) => {
5455
+ log(`handleClaudeToCodex threw for request ${message.requestId}: ${err?.message ?? err}`);
5456
+ sendClaudeToCodexResult(ws, message.requestId, {
4255
5457
  success: false,
4256
- error: reason
5458
+ code: "internal_error",
5459
+ error: `Internal bridge error: ${err?.message ?? err}`
4257
5460
  });
5461
+ });
5462
+ return;
5463
+ }
5464
+ }
5465
+ }
5466
+ function sendClaudeToCodexResult(ws, requestId, opts) {
5467
+ sendProtocolMessage(ws, {
5468
+ type: "claude_to_codex_result",
5469
+ requestId,
5470
+ success: opts.success,
5471
+ ...opts.error !== undefined ? { error: opts.error } : {},
5472
+ ok: opts.success,
5473
+ ...opts.code !== undefined ? { code: opts.code } : {},
5474
+ phase: codex.turnPhase,
5475
+ ...opts.retryAfterMs !== undefined ? { retryAfterMs: opts.retryAfterMs } : {}
5476
+ });
5477
+ }
5478
+ function describeDuplicate(dup) {
5479
+ if (dup.code === "duplicate_terminal") {
5480
+ const outcome = dup.state.phase === "terminal" ? dup.state.outcome : "unknown";
5481
+ return `Duplicate idempotency_key: the original message already reached a terminal state (${outcome}) ` + `and was NOT re-injected. Use a fresh key to send a genuinely new message.`;
5482
+ }
5483
+ const detail = dup.state.phase === "started" ? `already running as turn ${dup.state.turnId}` : "still in flight";
5484
+ return `Duplicate idempotency_key: a message with this key is ${detail} \u2014 NOT re-injected. ` + `Wait for its outcome, or use a fresh key for a genuinely new message.`;
5485
+ }
5486
+ function waitForInterruptOutcome(turnIds) {
5487
+ return new Promise((resolve) => {
5488
+ let settled = false;
5489
+ const abort = new AbortController;
5490
+ const finish = (result) => {
5491
+ if (settled)
4258
5492
  return;
5493
+ settled = true;
5494
+ codex.off("interruptFailed", onFailed);
5495
+ abort.abort();
5496
+ resolve(result);
5497
+ };
5498
+ const onFailed = (reason) => finish({ ok: false, code: "interrupt_rejected", reason });
5499
+ codex.on("interruptFailed", onFailed);
5500
+ codex.waitForTurnsTerminal(turnIds, undefined, abort.signal).then((result) => {
5501
+ if (result.ok) {
5502
+ finish({ ok: true });
5503
+ } else if (result.code === "interrupt_timeout") {
5504
+ finish({ ok: false, code: "interrupt_timeout" });
4259
5505
  }
4260
- const requireReply = !!message.requireReply;
4261
- let contentToSend = message.message.content;
4262
- if (requireReply) {
4263
- contentToSend += REPLY_REQUIRED_INSTRUCTION;
4264
- }
4265
- log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4266
- const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4267
- if (codex.turnInProgress && message.onBusy === "steer") {
4268
- if (requireReply) {
4269
- sendProtocolMessage(ws, {
4270
- type: "claude_to_codex_result",
4271
- requestId: message.requestId,
4272
- success: false,
4273
- error: 'require_reply is not supported together with on_busy="steer" yet. Send the steer without require_reply, or wait for the turn to finish.'
4274
- });
4275
- return;
4276
- }
4277
- const steerContent = `[STEER from Claude]
5506
+ });
5507
+ });
5508
+ }
5509
+ async function handleClaudeToCodex(ws, message) {
5510
+ const attachGuard = evaluateInjectionAttachGuard(attachedClaude, ws);
5511
+ if (!attachGuard.allowed) {
5512
+ log(`Rejecting claude_to_codex from non-attached socket #${ws.data.clientId} ` + `(request ${message.requestId}, attached=${attachedClaude ? "#" + attachedClaude.data.clientId : "none"})`);
5513
+ sendClaudeToCodexResult(ws, message.requestId, {
5514
+ success: false,
5515
+ code: attachGuard.code,
5516
+ error: attachGuard.reason
5517
+ });
5518
+ return;
5519
+ }
5520
+ if (message.message.source !== "claude") {
5521
+ sendClaudeToCodexResult(ws, message.requestId, {
5522
+ success: false,
5523
+ code: "invalid_source",
5524
+ error: "Invalid message source"
5525
+ });
5526
+ return;
5527
+ }
5528
+ const idempotencyKey = typeof message.idempotencyKey === "string" && message.idempotencyKey.length > 0 ? message.idempotencyKey : undefined;
5529
+ if (idempotencyKey && codex.activeThreadId) {
5530
+ const dup = idempotencyTracker.check(codex.activeThreadId, idempotencyKey);
5531
+ if (dup.duplicate) {
5532
+ log(`Rejected duplicate idempotency key (${dup.code})`);
5533
+ sendClaudeToCodexResult(ws, message.requestId, {
5534
+ success: false,
5535
+ code: dup.code,
5536
+ error: describeDuplicate(dup)
5537
+ });
5538
+ return;
5539
+ }
5540
+ }
5541
+ if (!tuiConnectionState.canReply()) {
5542
+ sendClaudeToCodexResult(ws, message.requestId, {
5543
+ success: false,
5544
+ code: "no_thread",
5545
+ error: "Codex is not ready. Wait for TUI to connect and create a thread."
5546
+ });
5547
+ return;
5548
+ }
5549
+ if (budgetCoordinator?.isGateClosed()) {
5550
+ const reason = budgetPauseGateError();
5551
+ log(`Injection rejected by budget pause gate`);
5552
+ const resumeAfterEpoch3 = budgetCoordinator?.getSnapshot()?.resumeAfterEpoch ?? null;
5553
+ const retryAfterMs = resumeAfterEpoch3 !== null ? Math.max(0, resumeAfterEpoch3 * 1000 - Date.now()) : undefined;
5554
+ sendClaudeToCodexResult(ws, message.requestId, {
5555
+ success: false,
5556
+ code: "budget_paused",
5557
+ error: reason,
5558
+ ...retryAfterMs !== undefined ? { retryAfterMs } : {}
5559
+ });
5560
+ return;
5561
+ }
5562
+ const requireReply = !!message.requireReply;
5563
+ let contentToSend = message.message.content;
5564
+ if (requireReply) {
5565
+ contentToSend += REPLY_REQUIRED_INSTRUCTION;
5566
+ }
5567
+ log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
5568
+ const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
5569
+ if (codex.turnInProgress && message.onBusy === "steer") {
5570
+ const steerContent = `[STEER from Claude]
4278
5571
  ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4279
5572
 
4280
- ` + message.message.content;
4281
- const steered = codex.steerMessage(steerContent);
4282
- log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
4283
- if (steered) {
4284
- clearAttentionWindow();
5573
+ ` + contentToSend;
5574
+ const steerTurnId = codex.steerableTurnId;
5575
+ const steerThreadId = codex.activeThreadId;
5576
+ const steerRequestId = codex.steerMessage(steerContent);
5577
+ const steered = steerRequestId !== null;
5578
+ log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars, requireReply=${requireReply})`);
5579
+ if (steered) {
5580
+ clearAttentionWindow();
5581
+ pendingSteerDispatches.set(steerRequestId, {
5582
+ requireReply,
5583
+ ...idempotencyKey ? { idempotencyKey } : {},
5584
+ ...steerThreadId ? { threadId: steerThreadId } : {}
5585
+ });
5586
+ if (idempotencyKey && steerThreadId) {
5587
+ idempotencyTracker.accept(steerThreadId, idempotencyKey);
5588
+ if (steerTurnId) {
5589
+ idempotencyTracker.markStarted(steerThreadId, idempotencyKey, steerTurnId);
4285
5590
  }
4286
- const steerFailureAdvice = codex.turnInProgress ? "Steer failed: the running turn cannot be steered right now \u2014 wait for it to finish (\u2705), then send normally." : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply.";
4287
- sendProtocolMessage(ws, {
4288
- type: "claude_to_codex_result",
4289
- requestId: message.requestId,
4290
- success: steered,
4291
- error: steered ? undefined : steerFailureAdvice
4292
- });
4293
- return;
4294
- }
4295
- const injected = codex.injectMessage(contentToSend, tierOverrides);
4296
- if (!injected) {
4297
- const reason = codex.turnInProgress ? 'Codex is busy executing a turn. Options: wait for it to finish, or retry with on_busy="steer" to feed this message into the running turn without interrupting it.' : "Injection failed: no active thread or WebSocket not connected.";
4298
- log(`Injection rejected: ${reason}`);
4299
- sendProtocolMessage(ws, {
4300
- type: "claude_to_codex_result",
4301
- requestId: message.requestId,
4302
- success: false,
4303
- error: reason
4304
- });
4305
- return;
4306
5591
  }
4307
- if (tierOverrides) {
4308
- budgetCoordinator?.notifyOverridesDelivered();
4309
- }
4310
- if (requireReply) {
4311
- replyTracker.arm();
4312
- log(`Reply required flag set for this message`);
5592
+ }
5593
+ const steerFailureAdvice = codex.turnInProgress ? "Steer failed: the running turn cannot be steered right now \u2014 wait for it to finish (\u2705), then send normally." : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply.";
5594
+ sendClaudeToCodexResult(ws, message.requestId, {
5595
+ success: steered,
5596
+ ...steered ? {} : { code: "steer_failed", error: steerFailureAdvice }
5597
+ });
5598
+ return;
5599
+ }
5600
+ if (codex.turnInProgress && message.onBusy === "interrupt") {
5601
+ const interruptThreadId = codex.activeThreadId;
5602
+ if (idempotencyKey && interruptThreadId) {
5603
+ idempotencyTracker.accept(interruptThreadId, idempotencyKey);
5604
+ }
5605
+ const releaseInterruptKey = () => {
5606
+ if (idempotencyKey && interruptThreadId) {
5607
+ idempotencyTracker.release(interruptThreadId, idempotencyKey);
4313
5608
  }
4314
- clearAttentionWindow();
4315
- sendProtocolMessage(ws, {
4316
- type: "claude_to_codex_result",
4317
- requestId: message.requestId,
4318
- success: true
5609
+ };
5610
+ const interrupted = codex.interruptActiveTurns();
5611
+ if (!interrupted.ok) {
5612
+ releaseInterruptKey();
5613
+ log(`Interrupt unavailable: ${interrupted.error}`);
5614
+ sendClaudeToCodexResult(ws, message.requestId, {
5615
+ success: false,
5616
+ code: interrupted.code,
5617
+ error: `Interrupt failed (${interrupted.error}). The original turn keeps running \u2014 ` + `your message was NOT injected. Wait for \u2705, or retry with on_busy="steer".`
5618
+ });
5619
+ return;
5620
+ }
5621
+ log(`Interrupt dispatched for turn(s) ${interrupted.turnIds.join(", ")} \u2014 waiting for terminal boundary`);
5622
+ const outcome = await waitForInterruptOutcome(interrupted.turnIds);
5623
+ if (!outcome.ok) {
5624
+ releaseInterruptKey();
5625
+ const error = outcome.code === "interrupt_rejected" ? `Interrupt was rejected by the app-server (${outcome.reason ?? "unknown reason"}). ` + `The original turn keeps running \u2014 your message was NOT injected. ` + `Wait for \u2705, or retry with on_busy="steer".` : `Interrupt did not reach a terminal boundary in time. The turn MAY still be running \u2014 ` + `do not assume it stopped. Your message was NOT injected (this avoids a double-turn race); ` + `check for \u2705/\u26A0\uFE0F notices before retrying.`;
5626
+ log(`Interrupt failed (${outcome.code})`);
5627
+ sendClaudeToCodexResult(ws, message.requestId, {
5628
+ success: false,
5629
+ code: outcome.code,
5630
+ error
5631
+ });
5632
+ return;
5633
+ }
5634
+ log("Interrupt reached terminal boundary \u2014 injecting the message as a new turn");
5635
+ const postWaitAttachGuard = evaluateInjectionAttachGuard(attachedClaude, ws);
5636
+ if (!postWaitAttachGuard.allowed) {
5637
+ releaseInterruptKey();
5638
+ 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"})`);
5639
+ sendClaudeToCodexResult(ws, message.requestId, {
5640
+ success: false,
5641
+ code: "not_attached",
5642
+ 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."
4319
5643
  });
4320
5644
  return;
4321
5645
  }
5646
+ if (interruptThreadId && codex.activeThreadId !== interruptThreadId) {
5647
+ releaseInterruptKey();
5648
+ }
5649
+ }
5650
+ const injectThreadId = codex.activeThreadId;
5651
+ const injectionId = codex.injectMessage(contentToSend, tierOverrides);
5652
+ if (injectionId === null) {
5653
+ if (idempotencyKey && injectThreadId) {
5654
+ idempotencyTracker.release(injectThreadId, idempotencyKey);
5655
+ }
5656
+ const busy = codex.turnInProgress;
5657
+ const reason = busy ? 'Codex is busy executing a turn. Options: wait for it to finish, retry with on_busy="steer" to feed this message into the running turn without interrupting it, or retry with on_busy="interrupt" to stop the current turn and start a new one with this message.' : "Injection failed: no active thread or WebSocket not connected.";
5658
+ log(`Injection rejected: ${reason}`);
5659
+ sendClaudeToCodexResult(ws, message.requestId, {
5660
+ success: false,
5661
+ code: busy ? "busy_reject" : "no_thread",
5662
+ error: reason,
5663
+ ...busy ? { retryAfterMs: BUSY_RETRY_ADVISORY_MS } : {}
5664
+ });
5665
+ return;
5666
+ }
5667
+ if (tierOverrides) {
5668
+ budgetCoordinator?.notifyOverridesDelivered();
5669
+ }
5670
+ if (requireReply) {
5671
+ replyTracker.arm();
5672
+ log(`Reply required flag set for this message`);
5673
+ }
5674
+ clearAttentionWindow();
5675
+ if (injectThreadId) {
5676
+ if (idempotencyKey) {
5677
+ idempotencyTracker.accept(injectThreadId, idempotencyKey);
5678
+ }
5679
+ pendingTurnStarts.set(injectionId, {
5680
+ requestId: message.requestId,
5681
+ ...idempotencyKey ? { idempotencyKey } : {},
5682
+ threadId: injectThreadId
5683
+ });
4322
5684
  }
5685
+ sendClaudeToCodexResult(ws, message.requestId, { success: true });
4323
5686
  }
4324
5687
  async function attachClaude(ws, identity) {
4325
5688
  const occupant = attachedClaude;
@@ -4387,14 +5750,9 @@ function detachClaude(ws, reason) {
4387
5750
  ws.data.attached = false;
4388
5751
  log(`Claude frontend detached (#${ws.data.clientId}, ${reason})`);
4389
5752
  if (ws.data.pendingBackpressure.length > 0) {
4390
- bufferedMessages.unshift(...ws.data.pendingBackpressure);
4391
- log(`Re-buffered ${ws.data.pendingBackpressure.length} backpressured message(s) for redelivery on reconnect`);
4392
- ws.data.pendingBackpressure = [];
4393
- if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
4394
- const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
4395
- bufferedMessages.splice(0, dropped);
4396
- log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
4397
- }
5753
+ const reBuffered = ws.data.pendingBackpressure.drainAll();
5754
+ log(`Re-buffered ${reBuffered.length} backpressured message(s) for redelivery on reconnect`);
5755
+ bufferedMessages.unshiftMany(reBuffered);
4398
5756
  }
4399
5757
  scheduleClaudeDisconnectNotification(ws.data.clientId);
4400
5758
  scheduleIdleShutdown();
@@ -4517,11 +5875,6 @@ function emitToClaude(message) {
4517
5875
  log("Send to Claude failed, buffering message for retry on reconnect");
4518
5876
  }
4519
5877
  bufferedMessages.push(message);
4520
- if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
4521
- const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
4522
- bufferedMessages.splice(0, dropped);
4523
- log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
4524
- }
4525
5878
  }
4526
5879
  function trySendBridgeMessage(ws, message) {
4527
5880
  try {
@@ -4532,11 +5885,6 @@ function trySendBridgeMessage(ws, message) {
4532
5885
  }
4533
5886
  if (typeof result === "number" && result === -1) {
4534
5887
  ws.data.pendingBackpressure.push(message);
4535
- if (ws.data.pendingBackpressure.length > MAX_BUFFERED_MESSAGES) {
4536
- const dropped = ws.data.pendingBackpressure.length - MAX_BUFFERED_MESSAGES;
4537
- ws.data.pendingBackpressure.splice(0, dropped);
4538
- log(`Backpressure overflow: dropped ${dropped} oldest tracked message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
4539
- }
4540
5888
  }
4541
5889
  return true;
4542
5890
  } catch (err) {
@@ -4545,11 +5893,11 @@ function trySendBridgeMessage(ws, message) {
4545
5893
  }
4546
5894
  }
4547
5895
  function flushBufferedMessages(ws) {
4548
- const messages = bufferedMessages.splice(0, bufferedMessages.length);
5896
+ const messages = bufferedMessages.drainAll();
4549
5897
  for (let i = 0;i < messages.length; i++) {
4550
5898
  if (!trySendBridgeMessage(ws, messages[i])) {
4551
5899
  const remaining = messages.slice(i);
4552
- bufferedMessages.unshift(...remaining);
5900
+ bufferedMessages.unshiftMany(remaining);
4553
5901
  log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
4554
5902
  return;
4555
5903
  }
@@ -4593,7 +5941,8 @@ function currentStatus() {
4593
5941
  budget: budgetCoordinator?.getSnapshot() ?? undefined,
4594
5942
  turnInProgress: codex.turnInProgress,
4595
5943
  turnPhase: codex.turnPhase,
4596
- attentionWindowActive: inAttentionWindow
5944
+ attentionWindowActive: inAttentionWindow,
5945
+ appServerInfo: codex.capturedAppServerInfo
4597
5946
  };
4598
5947
  }
4599
5948
  function currentWaitingMessage() {
@@ -4614,7 +5963,7 @@ function currentReadyMessage() {
4614
5963
  }
4615
5964
  function systemMessage(idPrefix, content) {
4616
5965
  return {
4617
- id: `${idPrefix}_${++nextSystemMessageId}`,
5966
+ id: `${idPrefix}_${SYSTEM_MSG_SALT}_${++nextSystemMessageId}`,
4618
5967
  source: "codex",
4619
5968
  content,
4620
5969
  timestamp: Date.now()
@@ -4622,9 +5971,47 @@ function systemMessage(idPrefix, content) {
4622
5971
  }
4623
5972
  function writePidFile() {
4624
5973
  daemonLifecycle.writePid();
5974
+ daemonLifecycle.writeDaemonRecord(buildDaemonRecord("booting"));
5975
+ weWrotePid = true;
5976
+ }
5977
+ function writeControlTokenPostBind() {
5978
+ if (controlToken === null)
5979
+ return;
5980
+ try {
5981
+ writeControlToken(controlTokenPath, controlToken);
5982
+ weWroteToken = true;
5983
+ } catch (err) {
5984
+ controlToken = null;
5985
+ processLogger.log(`Failed to write control token (${controlTokenPath}): ${err?.message ?? err} \u2014 ` + `token layer DISABLED for this daemon (attach guard + Origin guard still active)`);
5986
+ }
4625
5987
  }
4626
5988
  function removePidFile() {
5989
+ if (!weWrotePid || !pidFileOwnedByUs(stateDir.pidFile, process.pid))
5990
+ return;
4627
5991
  daemonLifecycle.removePidFile();
5992
+ daemonLifecycle.removeDaemonRecord();
5993
+ }
5994
+ function buildDaemonRecord(phase) {
5995
+ return {
5996
+ pid: process.pid,
5997
+ phase,
5998
+ startedAt: DAEMON_STARTED_AT,
5999
+ nonce: DAEMON_NONCE,
6000
+ pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
6001
+ cwd: process.cwd(),
6002
+ stateDir: stateDir.dir,
6003
+ proxyUrl: codex.proxyUrl,
6004
+ appServerUrl: codex.appServerUrl,
6005
+ ports: {
6006
+ appPort: portFromUrl(codex.appServerUrl) ?? CODEX_APP_PORT,
6007
+ proxyPort: portFromUrl(codex.proxyUrl) ?? CODEX_PROXY_PORT,
6008
+ controlPort: CONTROL_PORT
6009
+ },
6010
+ build: daemonStatusBuildInfo(),
6011
+ turnInProgress: codex.turnInProgress,
6012
+ turnPhase: codex.turnPhase,
6013
+ attentionWindowActive: inAttentionWindow
6014
+ };
4628
6015
  }
4629
6016
  function writeStatusFile() {
4630
6017
  daemonLifecycle.writeStatus({
@@ -4638,11 +6025,16 @@ function writeStatusFile() {
4638
6025
  build: daemonStatusBuildInfo(),
4639
6026
  turnInProgress: codex.turnInProgress,
4640
6027
  turnPhase: codex.turnPhase,
4641
- attentionWindowActive: inAttentionWindow
6028
+ attentionWindowActive: inAttentionWindow,
6029
+ appServerInfo: codex.capturedAppServerInfo
4642
6030
  });
6031
+ daemonLifecycle.writeDaemonRecord(buildDaemonRecord("ready"));
4643
6032
  }
4644
6033
  function removeStatusFile() {
6034
+ if (!boundControlPort)
6035
+ return;
4645
6036
  daemonLifecycle.removeStatusFile();
6037
+ daemonLifecycle.removeDaemonRecord();
4646
6038
  }
4647
6039
  function armBootDeadline() {
4648
6040
  if (bootDeadlineTimer)
@@ -4707,6 +6099,7 @@ function shutdown(reason, exitCode = 0) {
4707
6099
  log(`Shutting down daemon (${reason})...`);
4708
6100
  clearBootDeadline();
4709
6101
  stopBudgetCoordinator();
6102
+ idempotencyTracker.dispose();
4710
6103
  tuiConnectionState.dispose(`daemon shutdown (${reason})`);
4711
6104
  clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
4712
6105
  controlServer?.stop();
@@ -4714,20 +6107,41 @@ function shutdown(reason, exitCode = 0) {
4714
6107
  codex.stop();
4715
6108
  removePidFile();
4716
6109
  removeStatusFile();
6110
+ removeControlToken();
4717
6111
  process.exit(exitCode);
4718
6112
  }
6113
+ function removeControlToken() {
6114
+ if (!weWroteToken)
6115
+ return;
6116
+ try {
6117
+ rmSync2(controlTokenPath, { force: true });
6118
+ } catch {}
6119
+ }
4719
6120
  process.on("SIGINT", () => shutdown("SIGINT"));
4720
6121
  process.on("SIGTERM", () => shutdown("SIGTERM"));
4721
6122
  process.on("exit", () => {
4722
6123
  codex.forceKillAppServerSync();
4723
6124
  removePidFile();
4724
6125
  removeStatusFile();
6126
+ removeControlToken();
4725
6127
  });
4726
6128
  process.on("uncaughtException", (err) => {
4727
- processLogger.fatal("UNCAUGHT EXCEPTION", err);
6129
+ processLogger.fatal("UNCAUGHT EXCEPTION \u2014 auto-shutting down daemon", err);
6130
+ try {
6131
+ shutdown("uncaught exception", 1);
6132
+ } catch (shutdownErr) {
6133
+ processLogger.fatal("shutdown during uncaughtException failed", shutdownErr);
6134
+ }
6135
+ process.exit(1);
4728
6136
  });
4729
6137
  process.on("unhandledRejection", (reason) => {
4730
- processLogger.fatal("UNHANDLED REJECTION", reason);
6138
+ processLogger.fatal("UNHANDLED REJECTION \u2014 auto-shutting down daemon", reason);
6139
+ try {
6140
+ shutdown("unhandled rejection", 1);
6141
+ } catch (shutdownErr) {
6142
+ processLogger.fatal("shutdown during unhandledRejection failed", shutdownErr);
6143
+ }
6144
+ process.exit(1);
4731
6145
  });
4732
6146
  function log(msg) {
4733
6147
  processLogger.log(msg);
@@ -4736,7 +6150,8 @@ if (daemonLifecycle.wasKilled()) {
4736
6150
  log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
4737
6151
  process.exit(0);
4738
6152
  }
4739
- writePidFile();
4740
6153
  startControlServer();
6154
+ writePidFile();
6155
+ writeControlTokenPostBind();
4741
6156
  armBootDeadline();
4742
6157
  bootCodex();