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