@raysonmeng/agentbridge 0.1.10 → 0.1.12

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.
@@ -17,8 +17,8 @@ function defineNumber(value, fallback) {
17
17
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
18
18
  }
19
19
  var BUILD_INFO = Object.freeze({
20
- version: defineString("0.1.10", "0.0.0-source"),
21
- commit: defineString("51a44cb", "source"),
20
+ version: defineString("0.1.12", "0.0.0-source"),
21
+ commit: defineString("eec6018", "source"),
22
22
  bundle: defineBundle("plugin"),
23
23
  contractVersion: defineNumber(1, CONTRACT_VERSION)
24
24
  });
@@ -209,13 +209,14 @@ import { appendFileSync, existsSync as existsSync2, renameSync, statSync, unlink
209
209
  import { dirname } from "path";
210
210
  var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
211
211
  var DEFAULT_KEEP = 3;
212
- function appendRotatingLog(path, content, options = {}) {
212
+ var REAL_FS_OPS = { statSync, renameSync, unlinkSync, appendFileSync, existsSync: existsSync2 };
213
+ function appendRotatingLog(path, content, options = {}, fsOps = REAL_FS_OPS) {
213
214
  const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
214
215
  const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
215
- if (!existsSync2(dirname(path)))
216
+ if (!fsOps.existsSync(dirname(path)))
216
217
  return;
217
- rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
218
- appendFileSync(path, content, "utf-8");
218
+ rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep, fsOps);
219
+ fsOps.appendFileSync(path, content, "utf-8");
219
220
  }
220
221
  function positiveIntFromEnv(name, fallback) {
221
222
  const value = process.env[name];
@@ -224,26 +225,48 @@ function positiveIntFromEnv(name, fallback) {
224
225
  const parsed = Number(value);
225
226
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
226
227
  }
227
- function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
228
+ function isEnoent(error) {
229
+ return !!error && error.code === "ENOENT";
230
+ }
231
+ function renameIfPresent(from, to, fsOps) {
232
+ try {
233
+ fsOps.renameSync(from, to);
234
+ } catch (error) {
235
+ if (!isEnoent(error))
236
+ throw error;
237
+ }
238
+ }
239
+ function unlinkIfPresent(path, fsOps) {
240
+ try {
241
+ fsOps.unlinkSync(path);
242
+ } catch (error) {
243
+ if (!isEnoent(error))
244
+ throw error;
245
+ }
246
+ }
247
+ function rotateIfNeeded(path, incomingBytes, maxBytes, keep, fsOps) {
228
248
  if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
229
249
  return;
230
- if (!existsSync2(path))
231
- return;
232
- const size = statSync(path).size;
250
+ let size;
251
+ try {
252
+ size = fsOps.statSync(path).size;
253
+ } catch (error) {
254
+ if (isEnoent(error))
255
+ return;
256
+ throw error;
257
+ }
233
258
  if (size + incomingBytes <= maxBytes)
234
259
  return;
235
260
  for (let index = keep;index >= 1; index--) {
236
261
  const current = `${path}.${index}`;
237
262
  const next = `${path}.${index + 1}`;
238
- if (!existsSync2(current))
239
- continue;
240
263
  if (index === keep) {
241
- unlinkSync(current);
264
+ unlinkIfPresent(current, fsOps);
242
265
  } else {
243
- renameSync(current, next);
266
+ renameIfPresent(current, next, fsOps);
244
267
  }
245
268
  }
246
- renameSync(path, `${path}.1`);
269
+ renameIfPresent(path, `${path}.1`, fsOps);
247
270
  }
248
271
 
249
272
  // src/process-log.ts
@@ -362,6 +385,28 @@ function isAppServerResponseMessage(value) {
362
385
  return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
363
386
  }
364
387
 
388
+ // src/env-utils.ts
389
+ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
390
+ const raw = env[name];
391
+ if (raw == null || raw === "")
392
+ return fallback;
393
+ const parsed = Number(raw);
394
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
395
+ log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
396
+ return fallback;
397
+ }
398
+ return parsed;
399
+ }
400
+
401
+ // src/interrupt-timing.ts
402
+ var CLIENT_REPLY_TIMEOUT_MS = 15000;
403
+ var INTERRUPT_CLIENT_MARGIN_MS = 2000;
404
+ var DEFAULT_INTERRUPT_TIMEOUT_MS = 1e4;
405
+ var MAX_INTERRUPT_TIMEOUT_MS = CLIENT_REPLY_TIMEOUT_MS - INTERRUPT_CLIENT_MARGIN_MS;
406
+ function clampInterruptTimeoutMs(requested) {
407
+ return Math.min(requested, MAX_INTERRUPT_TIMEOUT_MS);
408
+ }
409
+
365
410
  // src/codex-transport.ts
366
411
  import { createServer, connect } from "net";
367
412
  import { spawnSync } from "child_process";
@@ -602,6 +647,25 @@ function buildTurnAbortedNotice(reason, replyWasRequired) {
602
647
  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
648
  }
604
649
 
650
+ // src/ws-origin-guard.ts
651
+ var ALLOWED_ORIGINS_ENV = "AGENTBRIDGE_WS_ALLOWED_ORIGINS";
652
+ function parseAllowedWsOrigins(env = process.env) {
653
+ const raw = env[ALLOWED_ORIGINS_ENV];
654
+ if (raw == null || raw === "")
655
+ return new Set;
656
+ const origins = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
657
+ return new Set(origins);
658
+ }
659
+ function isAllowedWsUpgrade(req, allowedOrigins = parseAllowedWsOrigins()) {
660
+ const origin = req.headers.get("origin");
661
+ if (origin == null || origin === "")
662
+ return true;
663
+ return allowedOrigins.has(origin);
664
+ }
665
+ function wsOriginRejectedResponse() {
666
+ return new Response("Forbidden: WebSocket Origin not allowed", { status: 403 });
667
+ }
668
+
605
669
  // src/codex-adapter.ts
606
670
  class CodexAdapter extends EventEmitter {
607
671
  static RESPONSE_TRACKING_TTL_MS = 30000;
@@ -764,15 +828,15 @@ class CodexAdapter extends EventEmitter {
764
828
  injectMessage(text, overrides) {
765
829
  if (!this.threadId) {
766
830
  this.log("Cannot inject: no active thread");
767
- return false;
831
+ return null;
768
832
  }
769
833
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
770
834
  this.log("Cannot inject: app-server WebSocket not connected");
771
- return false;
835
+ return null;
772
836
  }
773
837
  if (this.turnInProgress) {
774
838
  this.log(`Rejected injection: Codex turn is in progress (thread ${this.threadId})`);
775
- return false;
839
+ return null;
776
840
  }
777
841
  this.log(`Injecting message into Codex (${text.length} chars)`);
778
842
  const requestId = this.nextInjectionId--;
@@ -791,30 +855,30 @@ class CodexAdapter extends EventEmitter {
791
855
  id: requestId,
792
856
  params
793
857
  }));
794
- return true;
858
+ return requestId;
795
859
  } catch (err) {
796
860
  this.untrackBridgeRequestId(requestId);
797
861
  this.log(`Injection send failed: ${err.message}`);
798
- return false;
862
+ return null;
799
863
  }
800
864
  }
801
865
  steerMessage(text) {
802
866
  if (!this.threadId) {
803
867
  this.log("Cannot steer: no active thread");
804
- return false;
868
+ return null;
805
869
  }
806
870
  if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
807
871
  this.log("Cannot steer: app-server WebSocket not connected");
808
- return false;
872
+ return null;
809
873
  }
810
874
  if (!this.turnInProgress) {
811
875
  this.log("Cannot steer: no turn in progress (use injectMessage)");
812
- return false;
876
+ return null;
813
877
  }
814
878
  const expectedTurnId = this.currentSteerableTurnId();
815
879
  if (!expectedTurnId) {
816
880
  this.log("Cannot steer: no addressable active turn id (turn/started carried no id)");
817
- return false;
881
+ return null;
818
882
  }
819
883
  this.log(`Steering message into active Codex turn ${expectedTurnId} (${text.length} chars)`);
820
884
  const requestId = this.nextInjectionId--;
@@ -830,12 +894,96 @@ class CodexAdapter extends EventEmitter {
830
894
  id: requestId,
831
895
  params
832
896
  }));
833
- return true;
897
+ return requestId;
834
898
  } catch (err) {
835
899
  this.untrackBridgeRequestId(requestId);
836
900
  this.log(`Steer send failed: ${err.message}`);
837
- return false;
901
+ return null;
902
+ }
903
+ }
904
+ interruptActiveTurns() {
905
+ if (!this.threadId) {
906
+ this.log("Cannot interrupt: no active thread");
907
+ return { ok: false, code: "interrupt_unavailable", error: "no active thread" };
908
+ }
909
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
910
+ this.log("Cannot interrupt: app-server WebSocket not connected");
911
+ return { ok: false, code: "interrupt_unavailable", error: "app-server WebSocket not connected" };
912
+ }
913
+ const addressable = [...this.activeTurnIds].filter((id) => !id.startsWith("unknown:"));
914
+ if (addressable.length === 0) {
915
+ this.log("Cannot interrupt: no addressable active turn id (turn/started carried no id)");
916
+ return {
917
+ ok: false,
918
+ code: "interrupt_unavailable",
919
+ error: "no addressable active turn id (turn/started carried no id)"
920
+ };
838
921
  }
922
+ for (const turnId of addressable) {
923
+ const requestId = this.nextInjectionId--;
924
+ this.trackBridgeRequestId(requestId, "interrupt");
925
+ const params = { threadId: this.threadId, turnId };
926
+ try {
927
+ this.appServerWs.send(JSON.stringify({
928
+ method: "turn/interrupt",
929
+ id: requestId,
930
+ params
931
+ }));
932
+ this.log(`Sent turn/interrupt for active turn ${turnId} (request ${requestId})`);
933
+ } catch (err) {
934
+ this.untrackBridgeRequestId(requestId);
935
+ this.log(`turn/interrupt send failed for ${turnId}: ${err.message}`);
936
+ return {
937
+ ok: false,
938
+ code: "interrupt_unavailable",
939
+ error: `turn/interrupt send failed (${err.message}); earlier interrupts may still land`
940
+ };
941
+ }
942
+ }
943
+ return { ok: true, turnIds: addressable };
944
+ }
945
+ interruptTimeoutMs() {
946
+ const requested = parsePositiveIntEnv("AGENTBRIDGE_INTERRUPT_TIMEOUT_MS", DEFAULT_INTERRUPT_TIMEOUT_MS, (m) => this.log(m));
947
+ const clamped = clampInterruptTimeoutMs(requested);
948
+ if (clamped !== requested) {
949
+ 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)`);
950
+ }
951
+ return clamped;
952
+ }
953
+ waitForTurnsTerminal(turnIds, timeoutMs = this.interruptTimeoutMs(), signal) {
954
+ const satisfied = () => turnIds.every((id) => !this.activeTurnIds.has(id) && !this.currentlyStalledTurnIds.has(id));
955
+ if (satisfied())
956
+ return Promise.resolve({ ok: true });
957
+ if (signal?.aborted)
958
+ return Promise.resolve({ ok: false, code: "interrupt_aborted" });
959
+ return new Promise((resolve) => {
960
+ let settled = false;
961
+ const finish = (result) => {
962
+ if (settled)
963
+ return;
964
+ settled = true;
965
+ clearTimeout(timer);
966
+ this.off("turnIdCompleted", check);
967
+ this.off("turnTrackingReset", check);
968
+ this.off("turnPhaseChanged", check);
969
+ signal?.removeEventListener("abort", onAbort);
970
+ resolve(result);
971
+ };
972
+ const check = () => {
973
+ if (satisfied())
974
+ finish({ ok: true });
975
+ };
976
+ const onAbort = () => finish({ ok: false, code: "interrupt_aborted" });
977
+ const timer = setTimeout(() => {
978
+ this.log(`waitForTurnsTerminal timed out after ${timeoutMs}ms (still active: ` + `${turnIds.filter((id) => this.activeTurnIds.has(id)).join(", ") || "none"}, phase=${this.turnPhase})`);
979
+ finish({ ok: false, code: "interrupt_timeout" });
980
+ }, timeoutMs);
981
+ timer.unref?.();
982
+ this.on("turnIdCompleted", check);
983
+ this.on("turnTrackingReset", check);
984
+ this.on("turnPhaseChanged", check);
985
+ signal?.addEventListener("abort", onAbort, { once: true });
986
+ });
839
987
  }
840
988
  async waitForHealthy(maxRetries = 20, delayMs = 500) {
841
989
  for (let i = 0;i < maxRetries; i++) {
@@ -1151,6 +1299,10 @@ class CodexAdapter extends EventEmitter {
1151
1299
  }
1152
1300
  return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
1153
1301
  }
1302
+ if (isUpgrade && !isAllowedWsUpgrade(req)) {
1303
+ self.log("Rejected WS upgrade on proxy port: Origin header present (possible CSWSH)");
1304
+ return wsOriginRejectedResponse();
1305
+ }
1154
1306
  if (server.upgrade(req, { data: { connId: 0 } }))
1155
1307
  return;
1156
1308
  self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
@@ -1564,16 +1716,30 @@ class CodexAdapter extends EventEmitter {
1564
1716
  if (parsed.error) {
1565
1717
  this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1566
1718
  if (bridgeKind === "steer") {
1567
- this.emit("steerFailed", parsed.error.message ?? "unknown error");
1719
+ this.emit("steerFailed", { requestId: numericId, reason: parsed.error.message ?? "unknown error" });
1720
+ } else if (bridgeKind === "interrupt") {
1721
+ this.emit("interruptFailed", parsed.error.message ?? "unknown error");
1568
1722
  } else {
1569
1723
  this.lastTurnEndedAbnormally = true;
1570
1724
  this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1725
+ this.emit("bridgeTurnRejected", {
1726
+ requestId: numericId,
1727
+ error: parsed.error.message ?? "unknown error"
1728
+ });
1571
1729
  this.notifyPhaseIfChanged();
1572
1730
  }
1573
1731
  } else {
1574
1732
  this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
1575
1733
  if (bridgeKind === "steer") {
1576
- this.emit("steerAccepted");
1734
+ this.emit("steerAccepted", { requestId: numericId });
1735
+ } else if (bridgeKind === "turn-start") {
1736
+ const result = parsed.result;
1737
+ const turnId = result?.turn?.id;
1738
+ if (typeof turnId === "string" && turnId.length > 0) {
1739
+ this.emit("bridgeTurnStarted", { requestId: numericId, turnId });
1740
+ } else {
1741
+ this.log(`Bridge-originated turn/start response carried no turn id (id ${responseId}) \u2014 turn_started ACK skipped`);
1742
+ }
1577
1743
  }
1578
1744
  }
1579
1745
  return null;
@@ -1775,6 +1941,9 @@ class CodexAdapter extends EventEmitter {
1775
1941
  }
1776
1942
  return newest;
1777
1943
  }
1944
+ get steerableTurnId() {
1945
+ return this.currentSteerableTurnId();
1946
+ }
1778
1947
  get turnPhase() {
1779
1948
  if (this.activeTurnIds.size > 0) {
1780
1949
  const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
@@ -1806,11 +1975,12 @@ class CodexAdapter extends EventEmitter {
1806
1975
  this.notifyPhaseIfChanged();
1807
1976
  }
1808
1977
  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);
1978
+ const completedId = typeof turnId === "string" && turnId.length > 0 ? turnId : null;
1979
+ if (completedId !== null) {
1980
+ this.activeTurnIds.delete(completedId);
1981
+ this.clearTurnWatchdog(completedId);
1982
+ this.stalledTurnIds.delete(completedId);
1983
+ this.currentlyStalledTurnIds.delete(completedId);
1814
1984
  } else {
1815
1985
  this.activeTurnIds.clear();
1816
1986
  this.clearAllTurnWatchdogs();
@@ -1819,6 +1989,7 @@ class CodexAdapter extends EventEmitter {
1819
1989
  }
1820
1990
  this.lastTurnEndedAbnormally = false;
1821
1991
  this.turnInProgress = this.activeTurnIds.size > 0;
1992
+ this.emit("turnIdCompleted", completedId);
1822
1993
  this.notifyPhaseIfChanged();
1823
1994
  }
1824
1995
  turnWatchdogMs() {
@@ -1888,6 +2059,7 @@ class CodexAdapter extends EventEmitter {
1888
2059
  this.log(`Turn state reset (${reason})`);
1889
2060
  }
1890
2061
  this.notifyPhaseIfChanged();
2062
+ this.emit("turnTrackingReset", reason);
1891
2063
  }
1892
2064
  requestKey(id) {
1893
2065
  if (typeof id === "number" || typeof id === "string")
@@ -2225,21 +2397,43 @@ class TuiConnectionState {
2225
2397
  }
2226
2398
 
2227
2399
  // src/daemon-lifecycle.ts
2228
- import { spawn as spawn2, execFileSync as execFileSync2 } from "child_process";
2400
+ import { spawn as spawn2 } from "child_process";
2229
2401
  import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
2230
2402
  import { fileURLToPath } from "url";
2231
2403
 
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;
2404
+ // src/process-lifecycle.ts
2405
+ import { execFileSync as execFileSync2 } from "child_process";
2406
+ function commandForPid(pid) {
2407
+ try {
2408
+ return execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
2409
+ } catch {
2410
+ return null;
2411
+ }
2412
+ }
2413
+ function pidLooksAlive(pid) {
2414
+ if (!Number.isInteger(pid) || pid <= 0)
2415
+ return false;
2416
+ try {
2417
+ process.kill(pid, 0);
2418
+ return true;
2419
+ } catch (err) {
2420
+ return err?.code === "EPERM";
2241
2421
  }
2242
- return parsed;
2422
+ }
2423
+ var isProcessAlive = pidLooksAlive;
2424
+ function isAgentBridgeDaemon(pid, lookup = commandForPid) {
2425
+ const cmd = lookup(pid);
2426
+ if (cmd === null)
2427
+ return false;
2428
+ const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
2429
+ const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
2430
+ return hasDaemonEntry && hasAgentbridge;
2431
+ }
2432
+ function isAgentBridgeProcess(pid, lookup = commandForPid) {
2433
+ const cmd = lookup(pid);
2434
+ if (cmd === null)
2435
+ return false;
2436
+ return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
2243
2437
  }
2244
2438
 
2245
2439
  // src/daemon-lifecycle.ts
@@ -2250,6 +2444,48 @@ var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES",
2250
2444
  var REUSE_READY_DELAY_MS = 250;
2251
2445
  var HEALTH_FETCH_TIMEOUT_MS = 500;
2252
2446
  var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
2447
+ function isReuseVerdict(verdict) {
2448
+ return verdict === "reuse" || verdict === "reuse-despite-drift";
2449
+ }
2450
+ function classifyDaemon(expectedPairId, status, buildInfo) {
2451
+ if (!status) {
2452
+ return { verdict: "unreachable", reason: "daemon status is unavailable or unparseable" };
2453
+ }
2454
+ const reportedPairId = status.pairId;
2455
+ if (!expectedPairId && reportedPairId != null) {
2456
+ return {
2457
+ verdict: "manual-conflict",
2458
+ reason: `manual mode must not adopt registered pair ${reportedPairId}`
2459
+ };
2460
+ }
2461
+ if (expectedPairId) {
2462
+ if (reportedPairId == null) {
2463
+ return {
2464
+ verdict: "replace-foreign",
2465
+ reason: `pair ${expectedPairId} found daemon without pair identity`
2466
+ };
2467
+ }
2468
+ if (reportedPairId !== expectedPairId) {
2469
+ return {
2470
+ verdict: "replace-foreign",
2471
+ reason: `pair ${expectedPairId} found daemon for pair ${reportedPairId}`
2472
+ };
2473
+ }
2474
+ }
2475
+ if (!sameRuntimeContract(status.build, buildInfo)) {
2476
+ if (compatibleContractVersion(status.build, buildInfo) && status.tuiConnected === true) {
2477
+ return {
2478
+ verdict: "reuse-despite-drift",
2479
+ reason: "runtime build drift has a compatible contract and a live Codex TUI is attached"
2480
+ };
2481
+ }
2482
+ return {
2483
+ verdict: "replace-drifted",
2484
+ reason: `runtime build ${formatBuildInfo(status.build)} does not match launcher ${formatBuildInfo(buildInfo)}`
2485
+ };
2486
+ }
2487
+ return { verdict: "reuse", reason: "daemon pair and runtime contract match" };
2488
+ }
2253
2489
 
2254
2490
  class DaemonLifecycle {
2255
2491
  stateDir;
@@ -2282,52 +2518,37 @@ class DaemonLifecycle {
2282
2518
  return null;
2283
2519
  }
2284
2520
  }
2285
- isForeignDaemon(status) {
2286
- const expected = this.expectedPairId;
2287
- if (!expected)
2288
- return false;
2289
- if (!status)
2290
- return false;
2291
- const reported = status.pairId;
2292
- if (reported == null)
2293
- return true;
2294
- return reported !== expected;
2295
- }
2296
- isRegisteredPairDaemonInManualMode(status) {
2297
- return !this.expectedPairId && status?.pairId != null;
2298
- }
2299
- isBuildDrifted(status) {
2300
- if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
2301
- return false;
2302
- const runtime = status?.build;
2303
- if (!runtime)
2304
- return true;
2305
- return !sameRuntimeContract(runtime, BUILD_INFO);
2521
+ classifyDaemon(status) {
2522
+ const classification = classifyDaemon(this.expectedPairId, status, BUILD_INFO);
2523
+ if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1" && (classification.verdict === "replace-drifted" || classification.verdict === "unreachable")) {
2524
+ return { verdict: "reuse", reason: "build drift replacement disabled by AGENTBRIDGE_ALLOW_BUILD_DRIFT" };
2525
+ }
2526
+ return classification;
2306
2527
  }
2307
- canReuseDespiteDrift(status) {
2308
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
2309
- return false;
2310
- return status?.tuiConnected === true;
2528
+ manualConflictError(status) {
2529
+ 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.`);
2311
2530
  }
2312
2531
  async ensureRunning() {
2313
2532
  if (await this.isHealthy()) {
2314
2533
  const status = await this.fetchStatus();
2315
- if (this.isRegisteredPairDaemonInManualMode(status)) {
2316
- 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.`);
2317
- }
2318
- if (this.isForeignDaemon(status)) {
2319
- 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`);
2320
- await this.replaceUnhealthyDaemon(status?.pid);
2321
- return;
2322
- }
2323
- if (this.isBuildDrifted(status)) {
2324
- if (this.canReuseDespiteDrift(status)) {
2325
- 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)`);
2326
- } else {
2534
+ const classification = this.classifyDaemon(status);
2535
+ switch (classification.verdict) {
2536
+ case "manual-conflict":
2537
+ throw this.manualConflictError(status);
2538
+ case "replace-foreign":
2539
+ 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`);
2540
+ await this.replaceUnhealthyDaemon(status?.pid);
2541
+ return;
2542
+ case "replace-drifted":
2543
+ case "unreachable":
2327
2544
  this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
2328
2545
  await this.replaceUnhealthyDaemon(status?.pid);
2329
2546
  return;
2330
- }
2547
+ case "reuse-despite-drift":
2548
+ 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)`);
2549
+ break;
2550
+ case "reuse":
2551
+ break;
2331
2552
  }
2332
2553
  try {
2333
2554
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
@@ -2341,7 +2562,7 @@ class DaemonLifecycle {
2341
2562
  const existingPid = this.readPid();
2342
2563
  if (existingPid) {
2343
2564
  if (isProcessAlive(existingPid)) {
2344
- if (this.isDaemonProcess(existingPid)) {
2565
+ if (isAgentBridgeDaemon(existingPid)) {
2345
2566
  try {
2346
2567
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2347
2568
  return;
@@ -2357,14 +2578,17 @@ class DaemonLifecycle {
2357
2578
  }
2358
2579
  await this.withStartupLockStrict(async (locked) => {
2359
2580
  if (!locked) {
2360
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
2361
- await this.waitForReadyAndOurs();
2581
+ await this.waitForContendedStartupLock();
2362
2582
  return;
2363
2583
  }
2364
2584
  if (await this.isHealthy()) {
2365
2585
  const status = await this.fetchStatus();
2366
- if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
2367
- this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
2586
+ const classification = this.classifyDaemon(status);
2587
+ if (classification.verdict === "manual-conflict") {
2588
+ throw this.manualConflictError(status);
2589
+ }
2590
+ if (!isReuseVerdict(classification.verdict)) {
2591
+ 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`);
2368
2592
  await this.kill(3000, status?.pid);
2369
2593
  } else {
2370
2594
  try {
@@ -2416,7 +2640,11 @@ class DaemonLifecycle {
2416
2640
  for (let attempt = 0;attempt < maxRetries; attempt++) {
2417
2641
  if (await this.isReady()) {
2418
2642
  const status = await this.fetchStatus();
2419
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
2643
+ const classification = this.classifyDaemon(status);
2644
+ if (classification.verdict === "manual-conflict") {
2645
+ throw this.manualConflictError(status);
2646
+ }
2647
+ if (isReuseVerdict(classification.verdict)) {
2420
2648
  return;
2421
2649
  }
2422
2650
  }
@@ -2498,13 +2726,16 @@ class DaemonLifecycle {
2498
2726
  async replaceUnhealthyDaemon(statusPid) {
2499
2727
  await this.withStartupLockStrict(async (locked) => {
2500
2728
  if (!locked) {
2501
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
2502
- await this.waitForReadyAndOurs();
2729
+ await this.waitForContendedStartupLock();
2503
2730
  return;
2504
2731
  }
2505
2732
  if (await this.isHealthy()) {
2506
2733
  const status = await this.fetchStatus();
2507
- if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
2734
+ const classification = this.classifyDaemon(status);
2735
+ if (classification.verdict === "manual-conflict") {
2736
+ throw this.manualConflictError(status);
2737
+ }
2738
+ if (isReuseVerdict(classification.verdict)) {
2508
2739
  try {
2509
2740
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2510
2741
  return;
@@ -2517,6 +2748,10 @@ class DaemonLifecycle {
2517
2748
  await this.waitForReady();
2518
2749
  });
2519
2750
  }
2751
+ async waitForContendedStartupLock() {
2752
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
2753
+ await this.waitForReadyAndOurs();
2754
+ }
2520
2755
  async withStartupLockStrict(fn) {
2521
2756
  const locked = this.acquireLockStrict();
2522
2757
  try {
@@ -2552,7 +2787,7 @@ class DaemonLifecycle {
2552
2787
  this.releaseLock();
2553
2788
  return this.acquireLockStrict(true);
2554
2789
  }
2555
- if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
2790
+ if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !isAgentBridgeProcess(holderPid)) {
2556
2791
  this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
2557
2792
  this.releaseLock();
2558
2793
  return this.acquireLockStrict(true);
@@ -2573,14 +2808,6 @@ class DaemonLifecycle {
2573
2808
  return 0;
2574
2809
  }
2575
2810
  }
2576
- isAgentBridgeProcess(pid) {
2577
- try {
2578
- const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
2579
- return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
2580
- } catch {
2581
- return false;
2582
- }
2583
- }
2584
2811
  releaseLock() {
2585
2812
  try {
2586
2813
  unlinkSync2(this.stateDir.lockFile);
@@ -2598,7 +2825,7 @@ class DaemonLifecycle {
2598
2825
  this.cleanup();
2599
2826
  return false;
2600
2827
  }
2601
- if (!this.isDaemonProcess(pid)) {
2828
+ if (!isAgentBridgeDaemon(pid)) {
2602
2829
  this.log(`Pid ${pid} is alive but is NOT an AgentBridge daemon \u2014 refusing to kill. Cleaning up stale pid file.`);
2603
2830
  this.cleanup();
2604
2831
  return false;
@@ -2626,16 +2853,6 @@ class DaemonLifecycle {
2626
2853
  this.cleanup();
2627
2854
  return true;
2628
2855
  }
2629
- isDaemonProcess(pid) {
2630
- try {
2631
- const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
2632
- const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
2633
- const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
2634
- return hasDaemonEntry && hasAgentbridge;
2635
- } catch {
2636
- return false;
2637
- }
2638
- }
2639
2856
  cleanup() {
2640
2857
  this.removePidFile();
2641
2858
  this.removeStatusFile();
@@ -2650,21 +2867,13 @@ async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
2650
2867
  clearTimeout(timer);
2651
2868
  }
2652
2869
  }
2653
- function isProcessAlive(pid) {
2654
- try {
2655
- process.kill(pid, 0);
2656
- return true;
2657
- } catch {
2658
- return false;
2659
- }
2660
- }
2661
2870
 
2662
2871
  // src/config-service.ts
2663
2872
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
2664
2873
  import { join as join3 } from "path";
2665
2874
  var DEFAULT_BUDGET_CONFIG = {
2666
2875
  enabled: true,
2667
- pollSeconds: 60,
2876
+ pollSeconds: 300,
2668
2877
  pauseAt: 90,
2669
2878
  resumeBelow: 30,
2670
2879
  syncDriftPct: 10,
@@ -2693,9 +2902,52 @@ var DEFAULT_CONFIG = {
2693
2902
  };
2694
2903
  var CONFIG_DIR = ".agentbridge";
2695
2904
  var CONFIG_FILE = "config.json";
2905
+ var NOOP_LOGGER = () => {};
2696
2906
  function isRecord(value) {
2697
2907
  return typeof value === "object" && value !== null && !Array.isArray(value);
2698
2908
  }
2909
+ function isCoercibleNumber(value) {
2910
+ if (typeof value === "number")
2911
+ return Number.isFinite(value);
2912
+ if (typeof value === "string")
2913
+ return Number.isFinite(Number(value));
2914
+ return false;
2915
+ }
2916
+ function findShapeViolation(raw) {
2917
+ if ("idleShutdownSeconds" in raw && !isCoercibleNumber(raw.idleShutdownSeconds)) {
2918
+ return "idleShutdownSeconds is present but not a number";
2919
+ }
2920
+ if ("budget" in raw) {
2921
+ const budget = raw.budget;
2922
+ if (!isRecord(budget)) {
2923
+ return "budget is present but not an object";
2924
+ }
2925
+ const numericKeys = ["pauseAt", "resumeBelow", "pollSeconds", "syncDriftPct"];
2926
+ for (const key of numericKeys) {
2927
+ if (key in budget && !isCoercibleNumber(budget[key])) {
2928
+ return `budget.${key} is present but not a number`;
2929
+ }
2930
+ }
2931
+ if ("parallel" in budget) {
2932
+ const parallel = budget.parallel;
2933
+ if (!isRecord(parallel)) {
2934
+ return "budget.parallel is present but not an object";
2935
+ }
2936
+ for (const key of ["minRemainingPct", "timeWindowSec"]) {
2937
+ if (key in parallel && !isCoercibleNumber(parallel[key])) {
2938
+ return `budget.parallel.${key} is present but not a number`;
2939
+ }
2940
+ }
2941
+ }
2942
+ }
2943
+ return null;
2944
+ }
2945
+ function hasCustomDecisionValues(config) {
2946
+ const d = DEFAULT_CONFIG;
2947
+ const b = config.budget;
2948
+ const db = d.budget;
2949
+ 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;
2950
+ }
2699
2951
  function normalizeInteger(value, fallback) {
2700
2952
  if (typeof value === "number" && Number.isFinite(value))
2701
2953
  return value;
@@ -2731,35 +2983,35 @@ function normalizeCodexOverride(raw) {
2731
2983
  override.effort = raw.effort.trim();
2732
2984
  return Object.keys(override).length > 0 ? override : null;
2733
2985
  }
2734
- function normalizeCodexTiers(raw) {
2986
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
2735
2987
  const tiers = isRecord(raw) ? raw : {};
2736
2988
  return {
2737
2989
  full: normalizeCodexOverride(tiers.full),
2738
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
2739
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
2990
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
2991
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
2740
2992
  };
2741
2993
  }
2742
- function normalizeBudgetConfig(raw) {
2994
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
2743
2995
  const budget = isRecord(raw) ? raw : {};
2744
2996
  const parallel = isRecord(budget.parallel) ? budget.parallel : {};
2745
- const codexTiers = normalizeCodexTiers(budget.codexTiers);
2746
- let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
2747
- let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
2997
+ const codexTiers = normalizeCodexTiers(budget.codexTiers, fallback.codexTiers);
2998
+ let pauseAt = normalizeBoundedInteger(budget.pauseAt, fallback.pauseAt, 1, 100);
2999
+ let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, fallback.resumeBelow, 0, 99);
2748
3000
  if (pauseAt <= resumeBelow) {
2749
3001
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
2750
3002
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
2751
3003
  }
2752
3004
  return {
2753
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
2754
- pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
3005
+ enabled: normalizeBoolean(budget.enabled, fallback.enabled),
3006
+ pollSeconds: normalizeBoundedInteger(budget.pollSeconds, fallback.pollSeconds, 5, 3600),
2755
3007
  pauseAt,
2756
3008
  resumeBelow,
2757
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
3009
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
2758
3010
  parallel: {
2759
- minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
2760
- timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
3011
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
3012
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
2761
3013
  },
2762
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
3014
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
2763
3015
  codexTiers
2764
3016
  };
2765
3017
  }
@@ -2777,7 +3029,7 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
2777
3029
  codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
2778
3030
  codexTiers: budget.codexTiers
2779
3031
  };
2780
- return normalizeBudgetConfig(overlay);
3032
+ return normalizeBudgetConfig(overlay, budget);
2781
3033
  }
2782
3034
  function normalizeConfig(raw) {
2783
3035
  if (!isRecord(raw))
@@ -2812,15 +3064,59 @@ class ConfigService {
2812
3064
  return existsSync4(this.configPath);
2813
3065
  }
2814
3066
  load() {
3067
+ let raw;
2815
3068
  try {
2816
- const raw = readFileSync2(this.configPath, "utf-8");
2817
- return normalizeConfig(JSON.parse(raw));
2818
- } catch {
2819
- return null;
3069
+ raw = readFileSync2(this.configPath, "utf-8");
3070
+ } catch (err) {
3071
+ if (err?.code === "ENOENT") {
3072
+ return { state: "absent" };
3073
+ }
3074
+ return { state: "corrupt", reason: `config.json is unreadable: ${err.message}` };
3075
+ }
3076
+ let parsed;
3077
+ try {
3078
+ parsed = JSON.parse(raw);
3079
+ } catch (err) {
3080
+ return {
3081
+ state: "corrupt",
3082
+ reason: `config.json is not valid JSON: ${err.message}`
3083
+ };
3084
+ }
3085
+ if (!isRecord(parsed)) {
3086
+ return { state: "corrupt", reason: "config.json is not a JSON object" };
3087
+ }
3088
+ const violation = findShapeViolation(parsed);
3089
+ if (violation) {
3090
+ return { state: "corrupt", reason: `config.json is shape-invalid: ${violation}` };
3091
+ }
3092
+ const config = normalizeConfig(parsed);
3093
+ if (!config) {
3094
+ return { state: "corrupt", reason: "config.json could not be normalized" };
2820
3095
  }
3096
+ return { state: "parsed", config };
2821
3097
  }
2822
- loadOrDefault() {
2823
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
3098
+ loadOrDefault(log = NOOP_LOGGER) {
3099
+ const result = this.load();
3100
+ if (result.state === "parsed")
3101
+ return result.config;
3102
+ if (result.state === "corrupt") {
3103
+ 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.");
3104
+ }
3105
+ return structuredClone(DEFAULT_CONFIG);
3106
+ }
3107
+ describeConfig() {
3108
+ const result = this.load();
3109
+ if (result.state === "absent") {
3110
+ return { state: "absent", path: this.configPath, customValues: false };
3111
+ }
3112
+ if (result.state === "corrupt") {
3113
+ return { state: "corrupt", path: this.configPath, reason: result.reason, customValues: false };
3114
+ }
3115
+ return {
3116
+ state: "parsed",
3117
+ path: this.configPath,
3118
+ customValues: hasCustomDecisionValues(result.config)
3119
+ };
2824
3120
  }
2825
3121
  save(config) {
2826
3122
  this.ensureConfigDir();
@@ -3073,6 +3369,23 @@ function computeBudgetState(claude, codex, cfg, now) {
3073
3369
 
3074
3370
  // src/budget/budget-coordinator.ts
3075
3371
  var RESET_FINGERPRINT_BUCKET_SEC = 600;
3372
+ var LOW_UTIL_PCT = 50;
3373
+ var NEAR_PAUSE_MARGIN_PCT = 10;
3374
+ var NEAR_WARN_UTIL_PCT = 75;
3375
+ var NEAR_THRESHOLD_POLL_MS = 60000;
3376
+ var PAUSED_POLL_MS = 15000;
3377
+ var RESET_WAKE_AFTER_SEC = 5;
3378
+ var RESET_RECENTLY_PASSED_WINDOW_SEC = 120;
3379
+ var REAL_BUDGET_POLL_SCHEDULER = {
3380
+ setTimeout(callback, delayMs) {
3381
+ return setTimeout(() => {
3382
+ callback();
3383
+ }, delayMs);
3384
+ },
3385
+ clearTimeout(timer) {
3386
+ clearTimeout(timer);
3387
+ }
3388
+ };
3076
3389
  var AGENT_LABEL2 = {
3077
3390
  claude: "Claude",
3078
3391
  codex: "Codex"
@@ -3095,6 +3408,55 @@ function matchingGateReset2(usage) {
3095
3408
  return 0;
3096
3409
  return Math.min(...candidates.map((window) => window.resetEpoch));
3097
3410
  }
3411
+ function maxPollDelayMs(config) {
3412
+ return Math.max(0, config.pollSeconds * 1000);
3413
+ }
3414
+ function capDelay(delayMs, maxDelayMs) {
3415
+ if (maxDelayMs <= 0)
3416
+ return 0;
3417
+ return Math.min(delayMs, maxDelayMs);
3418
+ }
3419
+ function usagePressure(usage) {
3420
+ const readings = [usage?.claude, usage?.codex].filter((agentUsage) => agentUsage !== null && agentUsage !== undefined).flatMap((agentUsage) => [agentUsage.gateUtil, agentUsage.warnUtil]);
3421
+ if (readings.length === 0)
3422
+ return null;
3423
+ return Math.max(...readings);
3424
+ }
3425
+ function usageResetEpochs(usage) {
3426
+ 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);
3427
+ }
3428
+ function adaptiveBudgetPollDelayMs(input) {
3429
+ const maxDelayMs = maxPollDelayMs(input.config);
3430
+ if (input.paused)
3431
+ return capDelay(PAUSED_POLL_MS, maxDelayMs);
3432
+ const pressure = usagePressure(input.usage);
3433
+ if (pressure === null || pressure < LOW_UTIL_PCT)
3434
+ return maxDelayMs;
3435
+ const nearPauseAt = Math.max(0, input.config.pauseAt - NEAR_PAUSE_MARGIN_PCT);
3436
+ if (pressure >= nearPauseAt || pressure >= NEAR_WARN_UTIL_PCT) {
3437
+ return capDelay(NEAR_THRESHOLD_POLL_MS, maxDelayMs);
3438
+ }
3439
+ return capDelay(maxDelayMs / 2, maxDelayMs);
3440
+ }
3441
+ function resetAlignedDelayMs(input, adaptiveDelayMs) {
3442
+ const epochs = usageResetEpochs(input.usage);
3443
+ if (epochs.length === 0)
3444
+ return null;
3445
+ const candidates = epochs.map((epoch) => {
3446
+ if (epoch >= input.now)
3447
+ return (epoch - input.now + RESET_WAKE_AFTER_SEC) * 1000;
3448
+ if (input.now - epoch <= RESET_RECENTLY_PASSED_WINDOW_SEC)
3449
+ return RESET_WAKE_AFTER_SEC * 1000;
3450
+ return null;
3451
+ }).filter((delayMs) => delayMs !== null && delayMs >= 0 && delayMs <= adaptiveDelayMs);
3452
+ if (candidates.length === 0)
3453
+ return null;
3454
+ return Math.min(...candidates);
3455
+ }
3456
+ function nextBudgetPollDelayMs(input) {
3457
+ const adaptiveDelayMs = adaptiveBudgetPollDelayMs(input);
3458
+ return resetAlignedDelayMs(input, adaptiveDelayMs) ?? adaptiveDelayMs;
3459
+ }
3098
3460
 
3099
3461
  class BudgetCoordinator {
3100
3462
  source;
@@ -3102,6 +3464,7 @@ class BudgetCoordinator {
3102
3464
  emit;
3103
3465
  onPauseChange;
3104
3466
  now;
3467
+ scheduler;
3105
3468
  log;
3106
3469
  timer = null;
3107
3470
  running = false;
@@ -3121,6 +3484,7 @@ class BudgetCoordinator {
3121
3484
  this.emit = options.emit;
3122
3485
  this.onPauseChange = options.onPauseChange;
3123
3486
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
3487
+ this.scheduler = options.scheduler ?? REAL_BUDGET_POLL_SCHEDULER;
3124
3488
  this.log = options.log ?? (() => {});
3125
3489
  }
3126
3490
  async start() {
@@ -3134,7 +3498,7 @@ class BudgetCoordinator {
3134
3498
  stop() {
3135
3499
  this.running = false;
3136
3500
  if (this.timer) {
3137
- clearTimeout(this.timer);
3501
+ this.scheduler.clearTimeout(this.timer);
3138
3502
  this.timer = null;
3139
3503
  }
3140
3504
  }
@@ -3168,11 +3532,17 @@ class BudgetCoordinator {
3168
3532
  if (!this.running)
3169
3533
  return;
3170
3534
  if (this.timer)
3171
- clearTimeout(this.timer);
3172
- const delayMs = Math.max(0, this.config.pollSeconds * 1000);
3173
- this.timer = setTimeout(() => {
3535
+ this.scheduler.clearTimeout(this.timer);
3536
+ const snapshotUsage = this.latestSnapshot ? { claude: this.latestSnapshot.claude, codex: this.latestSnapshot.codex } : null;
3537
+ const delayMs = nextBudgetPollDelayMs({
3538
+ config: this.config,
3539
+ usage: snapshotUsage,
3540
+ now: this.now(),
3541
+ paused: this.isPaused()
3542
+ });
3543
+ this.timer = this.scheduler.setTimeout(() => {
3174
3544
  this.timer = null;
3175
- this.pollAndReschedule();
3545
+ return this.pollAndReschedule();
3176
3546
  }, delayMs);
3177
3547
  }
3178
3548
  async pollAndReschedule() {
@@ -3544,38 +3914,44 @@ function identifyWindows(buckets) {
3544
3914
  const weeklyMatches = buckets.filter((bucket) => bucket.id.includes("seven_day") || bucket.id.includes("secondary_window"));
3545
3915
  let fiveHour = toWindow(pickHighestUtil(fiveHourMatches));
3546
3916
  let weekly = toWindow(pickHighestUtil(weeklyMatches));
3917
+ let parsedVia = "id-match";
3547
3918
  const sorted = [...buckets].sort((a, b) => bucketSortKey(a) - bucketSortKey(b));
3548
3919
  if (!fiveHour && sorted.length > 0) {
3549
3920
  fiveHour = toWindow(sorted[0]);
3921
+ parsedVia = "positional";
3550
3922
  }
3551
3923
  if (!weekly && sorted.length > 1) {
3552
3924
  const latestDistinct = [...sorted].reverse().find((bucket) => !sameBucketWindow(bucket, fiveHour));
3553
3925
  weekly = toWindow(latestDistinct);
3926
+ if (latestDistinct)
3927
+ parsedVia = "positional";
3554
3928
  }
3555
- return { fiveHour, weekly };
3929
+ return { fiveHour, weekly, parsedVia };
3556
3930
  }
3557
- function normalizeProbeResult(raw) {
3558
- const record = asRecord(raw);
3559
- if (!record)
3560
- return null;
3931
+ function normalizeTolerantProbeRecord(record) {
3561
3932
  const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
3562
3933
  const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
3563
3934
  const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
3564
3935
  const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
3565
3936
  const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
3566
3937
  const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
3938
+ let parsedVia = "id-match";
3567
3939
  if (buckets.length === 0 && hasFiniteUtil) {
3568
3940
  const topLevelBucket = normalizeTopLevelBucket(record, gateUtil, fetchedAt);
3569
- if (topLevelBucket)
3941
+ if (topLevelBucket) {
3570
3942
  buckets.push(topLevelBucket);
3943
+ parsedVia = "top-level";
3944
+ }
3571
3945
  }
3572
3946
  const rateLimitedUntil = Math.max(0, numberOr(record.rate_limited_until ?? record.rateLimitedUntil, 0));
3573
3947
  const ok = record.ok === true;
3574
3948
  if (!ok && rateLimitedUntil <= 0 && buckets.length === 0)
3575
3949
  return null;
3576
- const { fiveHour, weekly } = identifyWindows(buckets);
3950
+ const { fiveHour, weekly, parsedVia: bucketParsedVia } = identifyWindows(buckets);
3577
3951
  if (!fiveHour && !weekly && rateLimitedUntil === 0 && !hasFiniteUtil)
3578
3952
  return null;
3953
+ if (parsedVia !== "top-level")
3954
+ parsedVia = bucketParsedVia;
3579
3955
  return {
3580
3956
  ok,
3581
3957
  stale: record.stale === true,
@@ -3585,9 +3961,37 @@ function normalizeProbeResult(raw) {
3585
3961
  weekly,
3586
3962
  remaining: clamp(100 - gateUtil, 0, 100),
3587
3963
  rateLimitedUntil,
3588
- fetchedAt
3964
+ fetchedAt,
3965
+ parsedVia
3589
3966
  };
3590
3967
  }
3968
+ var PROBE_SCHEMA_PARSERS = {
3969
+ "1": normalizeTolerantProbeRecord
3970
+ };
3971
+ function schemaVersionKey(record) {
3972
+ const value = record.schema_version ?? record.schemaVersion;
3973
+ if (typeof value === "number" && Number.isFinite(value))
3974
+ return String(value);
3975
+ if (typeof value === "string" && value.trim() !== "")
3976
+ return value.trim();
3977
+ return null;
3978
+ }
3979
+ function normalizeProbeResultWithDiagnostics(raw) {
3980
+ const record = asRecord(raw);
3981
+ if (!record)
3982
+ return { usage: null, unknownSchemaVersion: null };
3983
+ const schemaVersion = schemaVersionKey(record);
3984
+ if (schemaVersion) {
3985
+ const parser = PROBE_SCHEMA_PARSERS[schemaVersion];
3986
+ if (parser)
3987
+ return { usage: parser(record), unknownSchemaVersion: null };
3988
+ return {
3989
+ usage: normalizeTolerantProbeRecord(record),
3990
+ unknownSchemaVersion: schemaVersion
3991
+ };
3992
+ }
3993
+ return { usage: normalizeTolerantProbeRecord(record), unknownSchemaVersion: null };
3994
+ }
3591
3995
  function withTimeout(promise, timeoutMs) {
3592
3996
  let timer = null;
3593
3997
  const timeout = new Promise((_, reject) => {
@@ -3613,6 +4017,8 @@ class QuotaSource {
3613
4017
  log;
3614
4018
  now;
3615
4019
  degradedLogged = new Map;
4020
+ positionalFallbackLogged = false;
4021
+ unknownSchemaVersionsLogged = new Set;
3616
4022
  constructor(options = {}) {
3617
4023
  this.env = options.env ?? process.env;
3618
4024
  this.homeDir = options.homeDir ?? homedir2();
@@ -3674,7 +4080,9 @@ class QuotaSource {
3674
4080
  this.log(`budget probe output unparseable for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
3675
4081
  continue;
3676
4082
  }
3677
- const usage = normalizeProbeResult(parsed);
4083
+ const normalized = normalizeProbeResultWithDiagnostics(parsed);
4084
+ this.noteParserDiagnostics(agent, normalized);
4085
+ const usage = normalized.usage;
3678
4086
  if (usage) {
3679
4087
  this.noteDegradation(agent, usage);
3680
4088
  return usage;
@@ -3686,6 +4094,16 @@ class QuotaSource {
3686
4094
  }
3687
4095
  return null;
3688
4096
  }
4097
+ noteParserDiagnostics(agent, normalized) {
4098
+ if (normalized.unknownSchemaVersion && !this.unknownSchemaVersionsLogged.has(normalized.unknownSchemaVersion)) {
4099
+ this.unknownSchemaVersionsLogged.add(normalized.unknownSchemaVersion);
4100
+ this.log(`unknown budget probe schema_version ${normalized.unknownSchemaVersion} for ${agent}; using tolerant legacy parser`);
4101
+ }
4102
+ if (normalized.usage?.parsedVia === "positional" && !this.positionalFallbackLogged) {
4103
+ this.positionalFallbackLogged = true;
4104
+ this.log(`budget probe positional bucket fallback for ${agent}: bucket ids did not identify quota windows; check probe schema_version/bucket ids`);
4105
+ }
4106
+ }
3689
4107
  noteDegradation(agent, usage) {
3690
4108
  const degraded = isDegradedUsage(usage, this.now());
3691
4109
  const wasDegraded = this.degradedLogged.get(agent) === true;
@@ -3702,6 +4120,127 @@ function createQuotaSource(options) {
3702
4120
  return new QuotaSource(options);
3703
4121
  }
3704
4122
 
4123
+ // src/idempotency-tracker.ts
4124
+ var DEFAULT_TOMBSTONE_TTL_MS = 20 * 60 * 1000;
4125
+
4126
+ class IdempotencyTracker {
4127
+ entries = new Map;
4128
+ ttlMs;
4129
+ now;
4130
+ constructor(options = {}) {
4131
+ this.ttlMs = options.ttlMs ?? DEFAULT_TOMBSTONE_TTL_MS;
4132
+ this.now = options.now ?? Date.now;
4133
+ }
4134
+ get size() {
4135
+ return this.entries.size;
4136
+ }
4137
+ check(threadId, key) {
4138
+ const entry = this.getLive(threadId, key);
4139
+ if (!entry)
4140
+ return { duplicate: false };
4141
+ if (entry.state.phase === "terminal") {
4142
+ return { duplicate: true, code: "duplicate_terminal", state: entry.state };
4143
+ }
4144
+ return { duplicate: true, code: "duplicate_in_flight", state: entry.state };
4145
+ }
4146
+ peek(threadId, key) {
4147
+ return this.getLive(threadId, key)?.state ?? null;
4148
+ }
4149
+ accept(threadId, key) {
4150
+ if (this.getLive(threadId, key))
4151
+ return;
4152
+ this.entries.set(this.compositeKey(threadId, key), {
4153
+ threadId,
4154
+ state: { phase: "accepted" },
4155
+ expiresAtMs: null,
4156
+ timer: null
4157
+ });
4158
+ }
4159
+ release(threadId, key) {
4160
+ const composite = this.compositeKey(threadId, key);
4161
+ const entry = this.entries.get(composite);
4162
+ if (!entry || entry.state.phase === "terminal")
4163
+ return;
4164
+ this.entries.delete(composite);
4165
+ }
4166
+ markStarted(threadId, key, turnId) {
4167
+ const entry = this.getLive(threadId, key);
4168
+ if (!entry || entry.state.phase === "terminal")
4169
+ return;
4170
+ entry.state = { phase: "started", turnId };
4171
+ }
4172
+ markRejected(threadId, key) {
4173
+ const entry = this.getLive(threadId, key);
4174
+ if (!entry || entry.state.phase === "terminal")
4175
+ return;
4176
+ this.terminate(entry, "rejected");
4177
+ }
4178
+ completeTurn(turnId, threadId) {
4179
+ for (const entry of this.entries.values()) {
4180
+ if (entry.state.phase !== "started")
4181
+ continue;
4182
+ if (turnId !== null) {
4183
+ if (entry.state.turnId !== turnId)
4184
+ continue;
4185
+ } else if (threadId !== undefined && entry.threadId !== threadId) {
4186
+ continue;
4187
+ }
4188
+ this.terminate(entry, "completed");
4189
+ }
4190
+ }
4191
+ terminateThread(threadId, outcome) {
4192
+ for (const entry of this.entries.values()) {
4193
+ if (entry.threadId !== threadId || entry.state.phase === "terminal")
4194
+ continue;
4195
+ this.terminate(entry, outcome);
4196
+ }
4197
+ }
4198
+ terminateAll(outcome) {
4199
+ for (const entry of this.entries.values()) {
4200
+ if (entry.state.phase === "terminal")
4201
+ continue;
4202
+ this.terminate(entry, outcome);
4203
+ }
4204
+ }
4205
+ dispose() {
4206
+ for (const entry of this.entries.values()) {
4207
+ if (entry.timer)
4208
+ clearTimeout(entry.timer);
4209
+ }
4210
+ this.entries.clear();
4211
+ }
4212
+ compositeKey(threadId, key) {
4213
+ return `${threadId}\x00${key}`;
4214
+ }
4215
+ getLive(threadId, key) {
4216
+ const composite = this.compositeKey(threadId, key);
4217
+ const entry = this.entries.get(composite);
4218
+ if (!entry)
4219
+ return null;
4220
+ if (entry.expiresAtMs !== null && this.now() >= entry.expiresAtMs) {
4221
+ if (entry.timer)
4222
+ clearTimeout(entry.timer);
4223
+ this.entries.delete(composite);
4224
+ return null;
4225
+ }
4226
+ return entry;
4227
+ }
4228
+ terminate(entry, outcome) {
4229
+ entry.state = { phase: "terminal", outcome };
4230
+ entry.expiresAtMs = this.now() + this.ttlMs;
4231
+ const timer = setTimeout(() => {
4232
+ for (const [composite, candidate] of this.entries.entries()) {
4233
+ if (candidate === entry) {
4234
+ this.entries.delete(composite);
4235
+ break;
4236
+ }
4237
+ }
4238
+ }, this.ttlMs);
4239
+ timer.unref?.();
4240
+ entry.timer = timer;
4241
+ }
4242
+ }
4243
+
3705
4244
  // src/reply-required-tracker.ts
3706
4245
  class ReplyRequiredTracker {
3707
4246
  armed = false;
@@ -3909,9 +4448,9 @@ async function probeLiveness(target, options) {
3909
4448
  // src/daemon.ts
3910
4449
  var stateDir = new StateDirResolver;
3911
4450
  stateDir.ensure();
3912
- var configService = new ConfigService;
3913
- var config = configService.loadOrDefault();
3914
4451
  var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
4452
+ var configService = new ConfigService;
4453
+ var config = configService.loadOrDefault(processLogger.log);
3915
4454
  var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
3916
4455
  var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
3917
4456
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
@@ -3936,6 +4475,10 @@ var codexBootstrapped = false;
3936
4475
  var attentionWindowTimer = null;
3937
4476
  var inAttentionWindow = false;
3938
4477
  var replyTracker = new ReplyRequiredTracker;
4478
+ var idempotencyTracker = new IdempotencyTracker;
4479
+ var pendingTurnStarts = new Map;
4480
+ var pendingSteerDispatches = new Map;
4481
+ var BUSY_RETRY_ADVISORY_MS = 15000;
3939
4482
  var shuttingDown = false;
3940
4483
  var bootDeadlineTimer = null;
3941
4484
  var idleShutdownTimer = null;
@@ -4010,13 +4553,70 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
4010
4553
  tryWriteStatusFile(`turnPhase:${phase}`);
4011
4554
  broadcastStatus();
4012
4555
  });
4013
- codex.on("steerFailed", (reason) => {
4556
+ codex.on("steerFailed", ({ requestId, reason }) => {
4014
4557
  log(`Steer rejected by app-server: ${reason}`);
4558
+ const dispatch = pendingSteerDispatches.get(requestId);
4559
+ pendingSteerDispatches.delete(requestId);
4560
+ if (dispatch?.idempotencyKey && dispatch.threadId) {
4561
+ idempotencyTracker.release(dispatch.threadId, dispatch.idempotencyKey);
4562
+ log(`Released idempotency key after steer failure (request ${requestId}) \u2014 same key is retryable again`);
4563
+ }
4015
4564
  const advice = codex.turnInProgress ? "wait for it to finish (\u2705), then send normally" : "the turn has ended \u2014 resend as a normal reply";
4016
4565
  emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 ${advice}.`));
4017
4566
  });
4018
- codex.on("steerAccepted", () => {
4567
+ codex.on("steerAccepted", ({ requestId }) => {
4019
4568
  log("Steer accepted by app-server");
4569
+ const dispatch = pendingSteerDispatches.get(requestId);
4570
+ pendingSteerDispatches.delete(requestId);
4571
+ if (dispatch?.requireReply) {
4572
+ replyTracker.arm();
4573
+ log("Reply required armed on steer-accept (steer-scoped expectation)");
4574
+ }
4575
+ });
4576
+ codex.on("bridgeTurnStarted", ({ requestId, turnId }) => {
4577
+ const pending = pendingTurnStarts.get(requestId);
4578
+ if (!pending) {
4579
+ log(`bridgeTurnStarted for unknown injection ${requestId} (turn ${turnId}) \u2014 correlation dropped`);
4580
+ return;
4581
+ }
4582
+ pendingTurnStarts.delete(requestId);
4583
+ log(`Bridge turn started: injection ${requestId} \u2192 turn ${turnId} (request ${pending.requestId})`);
4584
+ if (pending.idempotencyKey) {
4585
+ idempotencyTracker.markStarted(pending.threadId, pending.idempotencyKey, turnId);
4586
+ }
4587
+ if (attachedClaude) {
4588
+ sendProtocolMessage(attachedClaude, {
4589
+ type: "turn_started",
4590
+ requestId: pending.requestId,
4591
+ ...pending.idempotencyKey ? { idempotencyKey: pending.idempotencyKey } : {},
4592
+ threadId: pending.threadId,
4593
+ turnId
4594
+ });
4595
+ }
4596
+ });
4597
+ codex.on("bridgeTurnRejected", ({ requestId, error }) => {
4598
+ const pending = pendingTurnStarts.get(requestId);
4599
+ if (!pending)
4600
+ return;
4601
+ pendingTurnStarts.delete(requestId);
4602
+ log(`Bridge turn rejected before start: injection ${requestId} (request ${pending.requestId}): ${error}`);
4603
+ if (pending.idempotencyKey) {
4604
+ idempotencyTracker.markRejected(pending.threadId, pending.idempotencyKey);
4605
+ }
4606
+ });
4607
+ codex.on("turnIdCompleted", (turnId) => {
4608
+ idempotencyTracker.completeTurn(turnId, codex.activeThreadId ?? undefined);
4609
+ });
4610
+ codex.on("turnTrackingReset", (reason) => {
4611
+ idempotencyTracker.terminateAll("aborted");
4612
+ if (pendingTurnStarts.size > 0) {
4613
+ log(`Cleared ${pendingTurnStarts.size} pending turn-start correlation(s) on turn tracking reset (${reason})`);
4614
+ }
4615
+ if (pendingSteerDispatches.size > 0) {
4616
+ log(`Cleared ${pendingSteerDispatches.size} pending steer dispatch(es) on turn tracking reset (${reason})`);
4617
+ }
4618
+ pendingTurnStarts.clear();
4619
+ pendingSteerDispatches.clear();
4020
4620
  });
4021
4621
  codex.on("turnStarted", () => {
4022
4622
  log("Codex turn started");
@@ -4124,6 +4724,9 @@ codex.on("exit", (code) => {
4124
4724
  log(`Codex process exited (code ${code})`);
4125
4725
  codexBootstrapped = false;
4126
4726
  replyTracker.reset();
4727
+ idempotencyTracker.terminateAll("aborted");
4728
+ pendingTurnStarts.clear();
4729
+ pendingSteerDispatches.clear();
4127
4730
  statusBuffer.flush("codex exited");
4128
4731
  tuiConnectionState.handleCodexExit();
4129
4732
  clearPendingClaudeDisconnect("Codex process exited");
@@ -4143,8 +4746,14 @@ function startControlServer() {
4143
4746
  if (url.pathname === "/readyz") {
4144
4747
  return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
4145
4748
  }
4146
- if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
4147
- return;
4749
+ if (url.pathname === "/ws") {
4750
+ if (!isAllowedWsUpgrade(req)) {
4751
+ log("Rejected WS upgrade on control port: Origin header present (possible CSWSH)");
4752
+ return wsOriginRejectedResponse();
4753
+ }
4754
+ if (server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
4755
+ return;
4756
+ }
4148
4757
  }
4149
4758
  return new Response("AgentBridge daemon");
4150
4759
  },
@@ -4219,98 +4828,217 @@ function handleControlMessage(ws, raw) {
4219
4828
  });
4220
4829
  return;
4221
4830
  case "claude_to_codex": {
4222
- if (message.message.source !== "claude") {
4223
- sendProtocolMessage(ws, {
4224
- type: "claude_to_codex_result",
4225
- requestId: message.requestId,
4226
- success: false,
4227
- error: "Invalid message source"
4228
- });
4229
- return;
4230
- }
4231
- if (!tuiConnectionState.canReply()) {
4232
- sendProtocolMessage(ws, {
4233
- type: "claude_to_codex_result",
4234
- requestId: message.requestId,
4831
+ handleClaudeToCodex(ws, message).catch((err) => {
4832
+ log(`handleClaudeToCodex threw for request ${message.requestId}: ${err?.message ?? err}`);
4833
+ sendClaudeToCodexResult(ws, message.requestId, {
4235
4834
  success: false,
4236
- error: "Codex is not ready. Wait for TUI to connect and create a thread."
4237
- });
4238
- return;
4239
- }
4240
- if (budgetCoordinator?.isGateClosed()) {
4241
- const reason = budgetPauseGateError();
4242
- log(`Injection rejected by budget pause gate`);
4243
- sendProtocolMessage(ws, {
4244
- type: "claude_to_codex_result",
4245
- requestId: message.requestId,
4246
- success: false,
4247
- error: reason
4835
+ code: "internal_error",
4836
+ error: `Internal bridge error: ${err?.message ?? err}`
4248
4837
  });
4838
+ });
4839
+ return;
4840
+ }
4841
+ }
4842
+ }
4843
+ function sendClaudeToCodexResult(ws, requestId, opts) {
4844
+ sendProtocolMessage(ws, {
4845
+ type: "claude_to_codex_result",
4846
+ requestId,
4847
+ success: opts.success,
4848
+ ...opts.error !== undefined ? { error: opts.error } : {},
4849
+ ok: opts.success,
4850
+ ...opts.code !== undefined ? { code: opts.code } : {},
4851
+ phase: codex.turnPhase,
4852
+ ...opts.retryAfterMs !== undefined ? { retryAfterMs: opts.retryAfterMs } : {}
4853
+ });
4854
+ }
4855
+ function describeDuplicate(dup) {
4856
+ if (dup.code === "duplicate_terminal") {
4857
+ const outcome = dup.state.phase === "terminal" ? dup.state.outcome : "unknown";
4858
+ 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.`;
4859
+ }
4860
+ const detail = dup.state.phase === "started" ? `already running as turn ${dup.state.turnId}` : "still in flight";
4861
+ 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.`;
4862
+ }
4863
+ function waitForInterruptOutcome(turnIds) {
4864
+ return new Promise((resolve) => {
4865
+ let settled = false;
4866
+ const abort = new AbortController;
4867
+ const finish = (result) => {
4868
+ if (settled)
4249
4869
  return;
4870
+ settled = true;
4871
+ codex.off("interruptFailed", onFailed);
4872
+ abort.abort();
4873
+ resolve(result);
4874
+ };
4875
+ const onFailed = (reason) => finish({ ok: false, code: "interrupt_rejected", reason });
4876
+ codex.on("interruptFailed", onFailed);
4877
+ codex.waitForTurnsTerminal(turnIds, undefined, abort.signal).then((result) => {
4878
+ if (result.ok) {
4879
+ finish({ ok: true });
4880
+ } else if (result.code === "interrupt_timeout") {
4881
+ finish({ ok: false, code: "interrupt_timeout" });
4250
4882
  }
4251
- const requireReply = !!message.requireReply;
4252
- let contentToSend = message.message.content;
4253
- if (requireReply) {
4254
- contentToSend += REPLY_REQUIRED_INSTRUCTION;
4255
- }
4256
- log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4257
- const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4258
- if (codex.turnInProgress && message.onBusy === "steer") {
4259
- if (requireReply) {
4260
- sendProtocolMessage(ws, {
4261
- type: "claude_to_codex_result",
4262
- requestId: message.requestId,
4263
- success: false,
4264
- 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.'
4265
- });
4266
- return;
4267
- }
4268
- const steerContent = `[STEER from Claude]
4883
+ });
4884
+ });
4885
+ }
4886
+ async function handleClaudeToCodex(ws, message) {
4887
+ if (message.message.source !== "claude") {
4888
+ sendClaudeToCodexResult(ws, message.requestId, {
4889
+ success: false,
4890
+ code: "invalid_source",
4891
+ error: "Invalid message source"
4892
+ });
4893
+ return;
4894
+ }
4895
+ const idempotencyKey = typeof message.idempotencyKey === "string" && message.idempotencyKey.length > 0 ? message.idempotencyKey : undefined;
4896
+ if (idempotencyKey && codex.activeThreadId) {
4897
+ const dup = idempotencyTracker.check(codex.activeThreadId, idempotencyKey);
4898
+ if (dup.duplicate) {
4899
+ log(`Rejected duplicate idempotency key (${dup.code})`);
4900
+ sendClaudeToCodexResult(ws, message.requestId, {
4901
+ success: false,
4902
+ code: dup.code,
4903
+ error: describeDuplicate(dup)
4904
+ });
4905
+ return;
4906
+ }
4907
+ }
4908
+ if (!tuiConnectionState.canReply()) {
4909
+ sendClaudeToCodexResult(ws, message.requestId, {
4910
+ success: false,
4911
+ code: "no_thread",
4912
+ error: "Codex is not ready. Wait for TUI to connect and create a thread."
4913
+ });
4914
+ return;
4915
+ }
4916
+ if (budgetCoordinator?.isGateClosed()) {
4917
+ const reason = budgetPauseGateError();
4918
+ log(`Injection rejected by budget pause gate`);
4919
+ const resumeAfterEpoch2 = budgetCoordinator?.getSnapshot()?.resumeAfterEpoch ?? null;
4920
+ const retryAfterMs = resumeAfterEpoch2 !== null ? Math.max(0, resumeAfterEpoch2 * 1000 - Date.now()) : undefined;
4921
+ sendClaudeToCodexResult(ws, message.requestId, {
4922
+ success: false,
4923
+ code: "budget_paused",
4924
+ error: reason,
4925
+ ...retryAfterMs !== undefined ? { retryAfterMs } : {}
4926
+ });
4927
+ return;
4928
+ }
4929
+ const requireReply = !!message.requireReply;
4930
+ let contentToSend = message.message.content;
4931
+ if (requireReply) {
4932
+ contentToSend += REPLY_REQUIRED_INSTRUCTION;
4933
+ }
4934
+ log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4935
+ const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4936
+ if (codex.turnInProgress && message.onBusy === "steer") {
4937
+ const steerContent = `[STEER from Claude]
4269
4938
  ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4270
4939
 
4271
- ` + message.message.content;
4272
- const steered = codex.steerMessage(steerContent);
4273
- log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
4274
- if (steered) {
4275
- clearAttentionWindow();
4940
+ ` + contentToSend;
4941
+ const steerTurnId = codex.steerableTurnId;
4942
+ const steerThreadId = codex.activeThreadId;
4943
+ const steerRequestId = codex.steerMessage(steerContent);
4944
+ const steered = steerRequestId !== null;
4945
+ log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars, requireReply=${requireReply})`);
4946
+ if (steered) {
4947
+ clearAttentionWindow();
4948
+ pendingSteerDispatches.set(steerRequestId, {
4949
+ requireReply,
4950
+ ...idempotencyKey ? { idempotencyKey } : {},
4951
+ ...steerThreadId ? { threadId: steerThreadId } : {}
4952
+ });
4953
+ if (idempotencyKey && steerThreadId) {
4954
+ idempotencyTracker.accept(steerThreadId, idempotencyKey);
4955
+ if (steerTurnId) {
4956
+ idempotencyTracker.markStarted(steerThreadId, idempotencyKey, steerTurnId);
4276
4957
  }
4277
- 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.";
4278
- sendProtocolMessage(ws, {
4279
- type: "claude_to_codex_result",
4280
- requestId: message.requestId,
4281
- success: steered,
4282
- error: steered ? undefined : steerFailureAdvice
4283
- });
4284
- return;
4285
4958
  }
4286
- const injected = codex.injectMessage(contentToSend, tierOverrides);
4287
- if (!injected) {
4288
- 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.";
4289
- log(`Injection rejected: ${reason}`);
4290
- sendProtocolMessage(ws, {
4291
- type: "claude_to_codex_result",
4292
- requestId: message.requestId,
4293
- success: false,
4294
- error: reason
4295
- });
4296
- return;
4297
- }
4298
- if (tierOverrides) {
4299
- budgetCoordinator?.notifyOverridesDelivered();
4300
- }
4301
- if (requireReply) {
4302
- replyTracker.arm();
4303
- log(`Reply required flag set for this message`);
4959
+ }
4960
+ 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.";
4961
+ sendClaudeToCodexResult(ws, message.requestId, {
4962
+ success: steered,
4963
+ ...steered ? {} : { code: "steer_failed", error: steerFailureAdvice }
4964
+ });
4965
+ return;
4966
+ }
4967
+ if (codex.turnInProgress && message.onBusy === "interrupt") {
4968
+ const interruptThreadId = codex.activeThreadId;
4969
+ if (idempotencyKey && interruptThreadId) {
4970
+ idempotencyTracker.accept(interruptThreadId, idempotencyKey);
4971
+ }
4972
+ const releaseInterruptKey = () => {
4973
+ if (idempotencyKey && interruptThreadId) {
4974
+ idempotencyTracker.release(interruptThreadId, idempotencyKey);
4304
4975
  }
4305
- clearAttentionWindow();
4306
- sendProtocolMessage(ws, {
4307
- type: "claude_to_codex_result",
4308
- requestId: message.requestId,
4309
- success: true
4976
+ };
4977
+ const interrupted = codex.interruptActiveTurns();
4978
+ if (!interrupted.ok) {
4979
+ releaseInterruptKey();
4980
+ log(`Interrupt unavailable: ${interrupted.error}`);
4981
+ sendClaudeToCodexResult(ws, message.requestId, {
4982
+ success: false,
4983
+ code: interrupted.code,
4984
+ 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".`
4985
+ });
4986
+ return;
4987
+ }
4988
+ log(`Interrupt dispatched for turn(s) ${interrupted.turnIds.join(", ")} \u2014 waiting for terminal boundary`);
4989
+ const outcome = await waitForInterruptOutcome(interrupted.turnIds);
4990
+ if (!outcome.ok) {
4991
+ releaseInterruptKey();
4992
+ 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.`;
4993
+ log(`Interrupt failed (${outcome.code})`);
4994
+ sendClaudeToCodexResult(ws, message.requestId, {
4995
+ success: false,
4996
+ code: outcome.code,
4997
+ error
4310
4998
  });
4311
4999
  return;
4312
5000
  }
5001
+ log("Interrupt reached terminal boundary \u2014 injecting the message as a new turn");
5002
+ if (interruptThreadId && codex.activeThreadId !== interruptThreadId) {
5003
+ releaseInterruptKey();
5004
+ }
5005
+ }
5006
+ const injectThreadId = codex.activeThreadId;
5007
+ const injectionId = codex.injectMessage(contentToSend, tierOverrides);
5008
+ if (injectionId === null) {
5009
+ if (idempotencyKey && injectThreadId) {
5010
+ idempotencyTracker.release(injectThreadId, idempotencyKey);
5011
+ }
5012
+ const busy = codex.turnInProgress;
5013
+ 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.";
5014
+ log(`Injection rejected: ${reason}`);
5015
+ sendClaudeToCodexResult(ws, message.requestId, {
5016
+ success: false,
5017
+ code: busy ? "busy_reject" : "no_thread",
5018
+ error: reason,
5019
+ ...busy ? { retryAfterMs: BUSY_RETRY_ADVISORY_MS } : {}
5020
+ });
5021
+ return;
5022
+ }
5023
+ if (tierOverrides) {
5024
+ budgetCoordinator?.notifyOverridesDelivered();
5025
+ }
5026
+ if (requireReply) {
5027
+ replyTracker.arm();
5028
+ log(`Reply required flag set for this message`);
5029
+ }
5030
+ clearAttentionWindow();
5031
+ if (injectThreadId) {
5032
+ if (idempotencyKey) {
5033
+ idempotencyTracker.accept(injectThreadId, idempotencyKey);
5034
+ }
5035
+ pendingTurnStarts.set(injectionId, {
5036
+ requestId: message.requestId,
5037
+ ...idempotencyKey ? { idempotencyKey } : {},
5038
+ threadId: injectThreadId
5039
+ });
4313
5040
  }
5041
+ sendClaudeToCodexResult(ws, message.requestId, { success: true });
4314
5042
  }
4315
5043
  async function attachClaude(ws, identity) {
4316
5044
  const occupant = attachedClaude;
@@ -4698,6 +5426,7 @@ function shutdown(reason, exitCode = 0) {
4698
5426
  log(`Shutting down daemon (${reason})...`);
4699
5427
  clearBootDeadline();
4700
5428
  stopBudgetCoordinator();
5429
+ idempotencyTracker.dispose();
4701
5430
  tuiConnectionState.dispose(`daemon shutdown (${reason})`);
4702
5431
  clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
4703
5432
  controlServer?.stop();