@raysonmeng/agentbridge 0.1.11 → 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.11", "0.0.0-source"),
21
- commit: defineString("48eb0ed", "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
+ };
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
+ }
838
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")
@@ -2229,19 +2401,6 @@ 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;
2241
- }
2242
- return parsed;
2243
- }
2244
-
2245
2404
  // src/process-lifecycle.ts
2246
2405
  import { execFileSync as execFileSync2 } from "child_process";
2247
2406
  function commandForPid(pid) {
@@ -2285,6 +2444,48 @@ var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES",
2285
2444
  var REUSE_READY_DELAY_MS = 250;
2286
2445
  var HEALTH_FETCH_TIMEOUT_MS = 500;
2287
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
+ }
2288
2489
 
2289
2490
  class DaemonLifecycle {
2290
2491
  stateDir;
@@ -2317,52 +2518,37 @@ class DaemonLifecycle {
2317
2518
  return null;
2318
2519
  }
2319
2520
  }
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);
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;
2341
2527
  }
2342
- canReuseDespiteDrift(status) {
2343
- if (!compatibleContractVersion(status?.build, BUILD_INFO))
2344
- return false;
2345
- 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.`);
2346
2530
  }
2347
2531
  async ensureRunning() {
2348
2532
  if (await this.isHealthy()) {
2349
2533
  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 {
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":
2362
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`);
2363
2545
  await this.replaceUnhealthyDaemon(status?.pid);
2364
2546
  return;
2365
- }
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;
2366
2552
  }
2367
2553
  try {
2368
2554
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
@@ -2392,14 +2578,17 @@ class DaemonLifecycle {
2392
2578
  }
2393
2579
  await this.withStartupLockStrict(async (locked) => {
2394
2580
  if (!locked) {
2395
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
2396
- await this.waitForReadyAndOurs();
2581
+ await this.waitForContendedStartupLock();
2397
2582
  return;
2398
2583
  }
2399
2584
  if (await this.isHealthy()) {
2400
2585
  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`);
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`);
2403
2592
  await this.kill(3000, status?.pid);
2404
2593
  } else {
2405
2594
  try {
@@ -2451,7 +2640,11 @@ class DaemonLifecycle {
2451
2640
  for (let attempt = 0;attempt < maxRetries; attempt++) {
2452
2641
  if (await this.isReady()) {
2453
2642
  const status = await this.fetchStatus();
2454
- 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)) {
2455
2648
  return;
2456
2649
  }
2457
2650
  }
@@ -2533,13 +2726,16 @@ class DaemonLifecycle {
2533
2726
  async replaceUnhealthyDaemon(statusPid) {
2534
2727
  await this.withStartupLockStrict(async (locked) => {
2535
2728
  if (!locked) {
2536
- this.log("Another process holds the startup lock, waiting for readiness+identity...");
2537
- await this.waitForReadyAndOurs();
2729
+ await this.waitForContendedStartupLock();
2538
2730
  return;
2539
2731
  }
2540
2732
  if (await this.isHealthy()) {
2541
2733
  const status = await this.fetchStatus();
2542
- 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)) {
2543
2739
  try {
2544
2740
  await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
2545
2741
  return;
@@ -2552,6 +2748,10 @@ class DaemonLifecycle {
2552
2748
  await this.waitForReady();
2553
2749
  });
2554
2750
  }
2751
+ async waitForContendedStartupLock() {
2752
+ this.log("Another process holds the startup lock, waiting for readiness+identity...");
2753
+ await this.waitForReadyAndOurs();
2754
+ }
2555
2755
  async withStartupLockStrict(fn) {
2556
2756
  const locked = this.acquireLockStrict();
2557
2757
  try {
@@ -2673,7 +2873,7 @@ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSy
2673
2873
  import { join as join3 } from "path";
2674
2874
  var DEFAULT_BUDGET_CONFIG = {
2675
2875
  enabled: true,
2676
- pollSeconds: 60,
2876
+ pollSeconds: 300,
2677
2877
  pauseAt: 90,
2678
2878
  resumeBelow: 30,
2679
2879
  syncDriftPct: 10,
@@ -2702,9 +2902,52 @@ var DEFAULT_CONFIG = {
2702
2902
  };
2703
2903
  var CONFIG_DIR = ".agentbridge";
2704
2904
  var CONFIG_FILE = "config.json";
2905
+ var NOOP_LOGGER = () => {};
2705
2906
  function isRecord(value) {
2706
2907
  return typeof value === "object" && value !== null && !Array.isArray(value);
2707
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
+ }
2708
2951
  function normalizeInteger(value, fallback) {
2709
2952
  if (typeof value === "number" && Number.isFinite(value))
2710
2953
  return value;
@@ -2740,35 +2983,35 @@ function normalizeCodexOverride(raw) {
2740
2983
  override.effort = raw.effort.trim();
2741
2984
  return Object.keys(override).length > 0 ? override : null;
2742
2985
  }
2743
- function normalizeCodexTiers(raw) {
2986
+ function normalizeCodexTiers(raw, fallback = DEFAULT_BUDGET_CONFIG.codexTiers) {
2744
2987
  const tiers = isRecord(raw) ? raw : {};
2745
2988
  return {
2746
2989
  full: normalizeCodexOverride(tiers.full),
2747
- balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
2748
- eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
2990
+ balanced: normalizeCodexOverride(tiers.balanced) ?? fallback.balanced,
2991
+ eco: normalizeCodexOverride(tiers.eco) ?? fallback.eco
2749
2992
  };
2750
2993
  }
2751
- function normalizeBudgetConfig(raw) {
2994
+ function normalizeBudgetConfig(raw, fallback = DEFAULT_BUDGET_CONFIG) {
2752
2995
  const budget = isRecord(raw) ? raw : {};
2753
2996
  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);
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);
2757
3000
  if (pauseAt <= resumeBelow) {
2758
3001
  pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
2759
3002
  resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
2760
3003
  }
2761
3004
  return {
2762
- enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
2763
- 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),
2764
3007
  pauseAt,
2765
3008
  resumeBelow,
2766
- syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
3009
+ syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
2767
3010
  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)
3011
+ minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, fallback.parallel.minRemainingPct, 1, 100),
3012
+ timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, fallback.parallel.timeWindowSec, 60, 604800)
2770
3013
  },
2771
- codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
3014
+ codexTierControl: normalizeBoolean(budget.codexTierControl, fallback.codexTierControl) && codexTiers.full !== null,
2772
3015
  codexTiers
2773
3016
  };
2774
3017
  }
@@ -2786,7 +3029,7 @@ function applyBudgetEnvOverrides(budget, env = process.env) {
2786
3029
  codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
2787
3030
  codexTiers: budget.codexTiers
2788
3031
  };
2789
- return normalizeBudgetConfig(overlay);
3032
+ return normalizeBudgetConfig(overlay, budget);
2790
3033
  }
2791
3034
  function normalizeConfig(raw) {
2792
3035
  if (!isRecord(raw))
@@ -2821,15 +3064,59 @@ class ConfigService {
2821
3064
  return existsSync4(this.configPath);
2822
3065
  }
2823
3066
  load() {
3067
+ let raw;
2824
3068
  try {
2825
- const raw = readFileSync2(this.configPath, "utf-8");
2826
- return normalizeConfig(JSON.parse(raw));
2827
- } catch {
2828
- 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}` };
2829
3091
  }
3092
+ const config = normalizeConfig(parsed);
3093
+ if (!config) {
3094
+ return { state: "corrupt", reason: "config.json could not be normalized" };
3095
+ }
3096
+ return { state: "parsed", config };
3097
+ }
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);
2830
3106
  }
2831
- loadOrDefault() {
2832
- return this.load() ?? structuredClone(DEFAULT_CONFIG);
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
+ };
2833
3120
  }
2834
3121
  save(config) {
2835
3122
  this.ensureConfigDir();
@@ -3082,6 +3369,23 @@ function computeBudgetState(claude, codex, cfg, now) {
3082
3369
 
3083
3370
  // src/budget/budget-coordinator.ts
3084
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
+ };
3085
3389
  var AGENT_LABEL2 = {
3086
3390
  claude: "Claude",
3087
3391
  codex: "Codex"
@@ -3104,6 +3408,55 @@ function matchingGateReset2(usage) {
3104
3408
  return 0;
3105
3409
  return Math.min(...candidates.map((window) => window.resetEpoch));
3106
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
+ }
3107
3460
 
3108
3461
  class BudgetCoordinator {
3109
3462
  source;
@@ -3111,6 +3464,7 @@ class BudgetCoordinator {
3111
3464
  emit;
3112
3465
  onPauseChange;
3113
3466
  now;
3467
+ scheduler;
3114
3468
  log;
3115
3469
  timer = null;
3116
3470
  running = false;
@@ -3130,6 +3484,7 @@ class BudgetCoordinator {
3130
3484
  this.emit = options.emit;
3131
3485
  this.onPauseChange = options.onPauseChange;
3132
3486
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
3487
+ this.scheduler = options.scheduler ?? REAL_BUDGET_POLL_SCHEDULER;
3133
3488
  this.log = options.log ?? (() => {});
3134
3489
  }
3135
3490
  async start() {
@@ -3143,7 +3498,7 @@ class BudgetCoordinator {
3143
3498
  stop() {
3144
3499
  this.running = false;
3145
3500
  if (this.timer) {
3146
- clearTimeout(this.timer);
3501
+ this.scheduler.clearTimeout(this.timer);
3147
3502
  this.timer = null;
3148
3503
  }
3149
3504
  }
@@ -3177,11 +3532,17 @@ class BudgetCoordinator {
3177
3532
  if (!this.running)
3178
3533
  return;
3179
3534
  if (this.timer)
3180
- clearTimeout(this.timer);
3181
- const delayMs = Math.max(0, this.config.pollSeconds * 1000);
3182
- 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(() => {
3183
3544
  this.timer = null;
3184
- this.pollAndReschedule();
3545
+ return this.pollAndReschedule();
3185
3546
  }, delayMs);
3186
3547
  }
3187
3548
  async pollAndReschedule() {
@@ -3553,38 +3914,44 @@ function identifyWindows(buckets) {
3553
3914
  const weeklyMatches = buckets.filter((bucket) => bucket.id.includes("seven_day") || bucket.id.includes("secondary_window"));
3554
3915
  let fiveHour = toWindow(pickHighestUtil(fiveHourMatches));
3555
3916
  let weekly = toWindow(pickHighestUtil(weeklyMatches));
3917
+ let parsedVia = "id-match";
3556
3918
  const sorted = [...buckets].sort((a, b) => bucketSortKey(a) - bucketSortKey(b));
3557
3919
  if (!fiveHour && sorted.length > 0) {
3558
3920
  fiveHour = toWindow(sorted[0]);
3921
+ parsedVia = "positional";
3559
3922
  }
3560
3923
  if (!weekly && sorted.length > 1) {
3561
3924
  const latestDistinct = [...sorted].reverse().find((bucket) => !sameBucketWindow(bucket, fiveHour));
3562
3925
  weekly = toWindow(latestDistinct);
3926
+ if (latestDistinct)
3927
+ parsedVia = "positional";
3563
3928
  }
3564
- return { fiveHour, weekly };
3929
+ return { fiveHour, weekly, parsedVia };
3565
3930
  }
3566
- function normalizeProbeResult(raw) {
3567
- const record = asRecord(raw);
3568
- if (!record)
3569
- return null;
3931
+ function normalizeTolerantProbeRecord(record) {
3570
3932
  const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
3571
3933
  const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
3572
3934
  const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
3573
3935
  const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
3574
3936
  const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
3575
3937
  const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
3938
+ let parsedVia = "id-match";
3576
3939
  if (buckets.length === 0 && hasFiniteUtil) {
3577
3940
  const topLevelBucket = normalizeTopLevelBucket(record, gateUtil, fetchedAt);
3578
- if (topLevelBucket)
3941
+ if (topLevelBucket) {
3579
3942
  buckets.push(topLevelBucket);
3943
+ parsedVia = "top-level";
3944
+ }
3580
3945
  }
3581
3946
  const rateLimitedUntil = Math.max(0, numberOr(record.rate_limited_until ?? record.rateLimitedUntil, 0));
3582
3947
  const ok = record.ok === true;
3583
3948
  if (!ok && rateLimitedUntil <= 0 && buckets.length === 0)
3584
3949
  return null;
3585
- const { fiveHour, weekly } = identifyWindows(buckets);
3950
+ const { fiveHour, weekly, parsedVia: bucketParsedVia } = identifyWindows(buckets);
3586
3951
  if (!fiveHour && !weekly && rateLimitedUntil === 0 && !hasFiniteUtil)
3587
3952
  return null;
3953
+ if (parsedVia !== "top-level")
3954
+ parsedVia = bucketParsedVia;
3588
3955
  return {
3589
3956
  ok,
3590
3957
  stale: record.stale === true,
@@ -3594,9 +3961,37 @@ function normalizeProbeResult(raw) {
3594
3961
  weekly,
3595
3962
  remaining: clamp(100 - gateUtil, 0, 100),
3596
3963
  rateLimitedUntil,
3597
- fetchedAt
3964
+ fetchedAt,
3965
+ parsedVia
3598
3966
  };
3599
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
+ }
3600
3995
  function withTimeout(promise, timeoutMs) {
3601
3996
  let timer = null;
3602
3997
  const timeout = new Promise((_, reject) => {
@@ -3622,6 +4017,8 @@ class QuotaSource {
3622
4017
  log;
3623
4018
  now;
3624
4019
  degradedLogged = new Map;
4020
+ positionalFallbackLogged = false;
4021
+ unknownSchemaVersionsLogged = new Set;
3625
4022
  constructor(options = {}) {
3626
4023
  this.env = options.env ?? process.env;
3627
4024
  this.homeDir = options.homeDir ?? homedir2();
@@ -3683,7 +4080,9 @@ class QuotaSource {
3683
4080
  this.log(`budget probe output unparseable for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
3684
4081
  continue;
3685
4082
  }
3686
- const usage = normalizeProbeResult(parsed);
4083
+ const normalized = normalizeProbeResultWithDiagnostics(parsed);
4084
+ this.noteParserDiagnostics(agent, normalized);
4085
+ const usage = normalized.usage;
3687
4086
  if (usage) {
3688
4087
  this.noteDegradation(agent, usage);
3689
4088
  return usage;
@@ -3695,6 +4094,16 @@ class QuotaSource {
3695
4094
  }
3696
4095
  return null;
3697
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
+ }
3698
4107
  noteDegradation(agent, usage) {
3699
4108
  const degraded = isDegradedUsage(usage, this.now());
3700
4109
  const wasDegraded = this.degradedLogged.get(agent) === true;
@@ -3711,6 +4120,127 @@ function createQuotaSource(options) {
3711
4120
  return new QuotaSource(options);
3712
4121
  }
3713
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
+
3714
4244
  // src/reply-required-tracker.ts
3715
4245
  class ReplyRequiredTracker {
3716
4246
  armed = false;
@@ -3918,9 +4448,9 @@ async function probeLiveness(target, options) {
3918
4448
  // src/daemon.ts
3919
4449
  var stateDir = new StateDirResolver;
3920
4450
  stateDir.ensure();
3921
- var configService = new ConfigService;
3922
- var config = configService.loadOrDefault();
3923
4451
  var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
4452
+ var configService = new ConfigService;
4453
+ var config = configService.loadOrDefault(processLogger.log);
3924
4454
  var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
3925
4455
  var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
3926
4456
  var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
@@ -3945,6 +4475,10 @@ var codexBootstrapped = false;
3945
4475
  var attentionWindowTimer = null;
3946
4476
  var inAttentionWindow = false;
3947
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;
3948
4482
  var shuttingDown = false;
3949
4483
  var bootDeadlineTimer = null;
3950
4484
  var idleShutdownTimer = null;
@@ -4019,13 +4553,70 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
4019
4553
  tryWriteStatusFile(`turnPhase:${phase}`);
4020
4554
  broadcastStatus();
4021
4555
  });
4022
- codex.on("steerFailed", (reason) => {
4556
+ codex.on("steerFailed", ({ requestId, reason }) => {
4023
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
+ }
4024
4564
  const advice = codex.turnInProgress ? "wait for it to finish (\u2705), then send normally" : "the turn has ended \u2014 resend as a normal reply";
4025
4565
  emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 ${advice}.`));
4026
4566
  });
4027
- codex.on("steerAccepted", () => {
4567
+ codex.on("steerAccepted", ({ requestId }) => {
4028
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();
4029
4620
  });
4030
4621
  codex.on("turnStarted", () => {
4031
4622
  log("Codex turn started");
@@ -4133,6 +4724,9 @@ codex.on("exit", (code) => {
4133
4724
  log(`Codex process exited (code ${code})`);
4134
4725
  codexBootstrapped = false;
4135
4726
  replyTracker.reset();
4727
+ idempotencyTracker.terminateAll("aborted");
4728
+ pendingTurnStarts.clear();
4729
+ pendingSteerDispatches.clear();
4136
4730
  statusBuffer.flush("codex exited");
4137
4731
  tuiConnectionState.handleCodexExit();
4138
4732
  clearPendingClaudeDisconnect("Codex process exited");
@@ -4152,8 +4746,14 @@ function startControlServer() {
4152
4746
  if (url.pathname === "/readyz") {
4153
4747
  return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
4154
4748
  }
4155
- if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
4156
- 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
+ }
4157
4757
  }
4158
4758
  return new Response("AgentBridge daemon");
4159
4759
  },
@@ -4228,98 +4828,217 @@ function handleControlMessage(ws, raw) {
4228
4828
  });
4229
4829
  return;
4230
4830
  case "claude_to_codex": {
4231
- if (message.message.source !== "claude") {
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: "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,
4255
- success: false,
4256
- error: reason
4835
+ code: "internal_error",
4836
+ error: `Internal bridge error: ${err?.message ?? err}`
4257
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)
4258
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" });
4259
4882
  }
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]
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]
4278
4938
  ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4279
4939
 
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();
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);
4285
4957
  }
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
- }
4307
- if (tierOverrides) {
4308
- budgetCoordinator?.notifyOverridesDelivered();
4309
4958
  }
4310
- if (requireReply) {
4311
- replyTracker.arm();
4312
- 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);
4313
4975
  }
4314
- clearAttentionWindow();
4315
- sendProtocolMessage(ws, {
4316
- type: "claude_to_codex_result",
4317
- requestId: message.requestId,
4318
- 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".`
4319
4985
  });
4320
4986
  return;
4321
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
4998
+ });
4999
+ return;
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
+ });
4322
5040
  }
5041
+ sendClaudeToCodexResult(ws, message.requestId, { success: true });
4323
5042
  }
4324
5043
  async function attachClaude(ws, identity) {
4325
5044
  const occupant = attachedClaude;
@@ -4707,6 +5426,7 @@ function shutdown(reason, exitCode = 0) {
4707
5426
  log(`Shutting down daemon (${reason})...`);
4708
5427
  clearBootDeadline();
4709
5428
  stopBudgetCoordinator();
5429
+ idempotencyTracker.dispose();
4710
5430
  tuiConnectionState.dispose(`daemon shutdown (${reason})`);
4711
5431
  clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
4712
5432
  controlServer?.stop();