@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/cli.js +683 -216
- package/dist/daemon.js +932 -212
- package/package.json +1 -1
- package/plugins/agentbridge/.claude-plugin/plugin.json +1 -1
- package/plugins/agentbridge/server/bridge-server.js +336 -109
- package/plugins/agentbridge/server/daemon.js +932 -212
package/dist/daemon.js
CHANGED
|
@@ -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.
|
|
21
|
-
commit: defineString("
|
|
20
|
+
version: defineString("0.1.12", "0.0.0-source"),
|
|
21
|
+
commit: defineString("eec6018", "source"),
|
|
22
22
|
bundle: defineBundle("dist"),
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
264
|
+
unlinkIfPresent(current, fsOps);
|
|
242
265
|
} else {
|
|
243
|
-
|
|
266
|
+
renameIfPresent(current, next, fsOps);
|
|
244
267
|
}
|
|
245
268
|
}
|
|
246
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
858
|
+
return requestId;
|
|
795
859
|
} catch (err) {
|
|
796
860
|
this.untrackBridgeRequestId(requestId);
|
|
797
861
|
this.log(`Injection send failed: ${err.message}`);
|
|
798
|
-
return
|
|
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
|
|
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
|
|
872
|
+
return null;
|
|
809
873
|
}
|
|
810
874
|
if (!this.turnInProgress) {
|
|
811
875
|
this.log("Cannot steer: no turn in progress (use injectMessage)");
|
|
812
|
-
return
|
|
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
|
|
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
|
|
897
|
+
return requestId;
|
|
834
898
|
} catch (err) {
|
|
835
899
|
this.untrackBridgeRequestId(requestId);
|
|
836
900
|
this.log(`Steer send failed: ${err.message}`);
|
|
837
|
-
return
|
|
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
|
-
|
|
1810
|
-
|
|
1811
|
-
this.
|
|
1812
|
-
this.
|
|
1813
|
-
this.
|
|
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
|
-
|
|
2321
|
-
const
|
|
2322
|
-
if (
|
|
2323
|
-
return
|
|
2324
|
-
|
|
2325
|
-
|
|
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
|
-
|
|
2343
|
-
|
|
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
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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.
|
|
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
|
-
|
|
2402
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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) ??
|
|
2748
|
-
eco: normalizeCodexOverride(tiers.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,
|
|
2756
|
-
let resumeBelow = normalizeBoundedInteger(budget.resumeBelow,
|
|
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,
|
|
2763
|
-
pollSeconds: normalizeBoundedInteger(budget.pollSeconds,
|
|
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,
|
|
3009
|
+
syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, fallback.syncDriftPct, 1, 100),
|
|
2767
3010
|
parallel: {
|
|
2768
|
-
minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct,
|
|
2769
|
-
timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec,
|
|
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,
|
|
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
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
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
|
-
|
|
2832
|
-
|
|
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
|
|
3182
|
-
|
|
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
|
|
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
|
|
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"
|
|
4156
|
-
|
|
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
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
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
|
-
` +
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
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
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
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
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
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();
|