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