@raysonmeng/agentbridge 0.1.6 → 0.1.8
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 +53 -6
- package/README.zh-CN.md +37 -1
- package/dist/cli.js +3983 -440
- package/dist/daemon.js +4713 -0
- package/package.json +18 -5
- package/plugins/agentbridge/.claude-plugin/plugin.json +1 -1
- package/plugins/agentbridge/README.md +2 -2
- package/plugins/agentbridge/scripts/health-check.sh +22 -3
- package/plugins/agentbridge/scripts/plugin-update-notice.mjs +73 -0
- package/plugins/agentbridge/server/bridge-server.js +1170 -142
- package/plugins/agentbridge/server/daemon.js +2690 -358
- package/scripts/install-safety.cjs +209 -0
- package/scripts/postinstall.cjs +114 -34
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
|
-
// src/
|
|
5
|
-
|
|
4
|
+
// src/contract-version.ts
|
|
5
|
+
var CONTRACT_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
// src/build-info.ts
|
|
8
|
+
function defineString(value, fallback) {
|
|
9
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
10
|
+
}
|
|
11
|
+
function defineBundle(value) {
|
|
12
|
+
if (value === "source" || value === "dist" || value === "plugin")
|
|
13
|
+
return value;
|
|
14
|
+
return import.meta.url.endsWith(".ts") ? "source" : "dist";
|
|
15
|
+
}
|
|
16
|
+
function defineNumber(value, fallback) {
|
|
17
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
18
|
+
}
|
|
19
|
+
var BUILD_INFO = Object.freeze({
|
|
20
|
+
version: defineString("0.1.8", "0.0.0-source"),
|
|
21
|
+
commit: defineString("c80a7fd", "source"),
|
|
22
|
+
bundle: defineBundle("plugin"),
|
|
23
|
+
contractVersion: defineNumber(1, CONTRACT_VERSION)
|
|
24
|
+
});
|
|
25
|
+
function daemonStatusBuildInfo() {
|
|
26
|
+
return { ...BUILD_INFO };
|
|
27
|
+
}
|
|
28
|
+
function sameRuntimeContract(a, b) {
|
|
29
|
+
if (!a || !b)
|
|
30
|
+
return false;
|
|
31
|
+
return a.version === b.version && a.commit === b.commit && a.contractVersion === b.contractVersion;
|
|
32
|
+
}
|
|
33
|
+
function compatibleContractVersion(a, b) {
|
|
34
|
+
if (!a || !b)
|
|
35
|
+
return false;
|
|
36
|
+
return a.contractVersion === b.contractVersion;
|
|
37
|
+
}
|
|
38
|
+
function formatBuildInfo(build) {
|
|
39
|
+
if (!build)
|
|
40
|
+
return "<unknown>";
|
|
41
|
+
return `${build.version}/${build.commit}/${build.bundle}/contract-v${build.contractVersion}`;
|
|
42
|
+
}
|
|
6
43
|
|
|
7
44
|
// src/codex-adapter.ts
|
|
8
|
-
import { spawn,
|
|
45
|
+
import { spawn, execFileSync } from "child_process";
|
|
9
46
|
import { createInterface } from "readline";
|
|
10
47
|
import { EventEmitter } from "events";
|
|
11
|
-
import { appendFileSync } from "fs";
|
|
12
48
|
|
|
13
49
|
// src/state-dir.ts
|
|
14
50
|
import { mkdirSync, existsSync } from "fs";
|
|
@@ -17,16 +53,16 @@ import { homedir, platform } from "os";
|
|
|
17
53
|
|
|
18
54
|
class StateDirResolver {
|
|
19
55
|
stateDir;
|
|
56
|
+
static platformBaseDir() {
|
|
57
|
+
if (platform() === "darwin") {
|
|
58
|
+
return join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
59
|
+
}
|
|
60
|
+
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
61
|
+
return join(xdgState, "agentbridge");
|
|
62
|
+
}
|
|
20
63
|
constructor(envOverride) {
|
|
21
64
|
const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
|
|
22
|
-
|
|
23
|
-
this.stateDir = override;
|
|
24
|
-
} else if (platform() === "darwin") {
|
|
25
|
-
this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
26
|
-
} else {
|
|
27
|
-
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
28
|
-
this.stateDir = join(xdgState, "agentbridge");
|
|
29
|
-
}
|
|
65
|
+
this.stateDir = override && override.length > 0 ? override : StateDirResolver.platformBaseDir();
|
|
30
66
|
}
|
|
31
67
|
ensure() {
|
|
32
68
|
if (!existsSync(this.stateDir)) {
|
|
@@ -51,6 +87,9 @@ class StateDirResolver {
|
|
|
51
87
|
get portsFile() {
|
|
52
88
|
return join(this.stateDir, "ports.json");
|
|
53
89
|
}
|
|
90
|
+
get currentThreadFile() {
|
|
91
|
+
return join(this.stateDir, "current-thread.json");
|
|
92
|
+
}
|
|
54
93
|
get logFile() {
|
|
55
94
|
return join(this.stateDir, "agentbridge.log");
|
|
56
95
|
}
|
|
@@ -60,6 +99,224 @@ class StateDirResolver {
|
|
|
60
99
|
get killedFile() {
|
|
61
100
|
return join(this.stateDir, "killed");
|
|
62
101
|
}
|
|
102
|
+
get updateCheckFile() {
|
|
103
|
+
return join(this.stateDir, "update-check.json");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/port-cleanup.ts
|
|
108
|
+
function portPidsCommand(port, platform2 = process.platform) {
|
|
109
|
+
if (platform2 === "win32") {
|
|
110
|
+
return {
|
|
111
|
+
cmd: "powershell.exe",
|
|
112
|
+
args: [
|
|
113
|
+
"-NoProfile",
|
|
114
|
+
"-Command",
|
|
115
|
+
`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`
|
|
116
|
+
]
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { cmd: "lsof", args: ["-ti", `tcp:${port}`, "-sTCP:LISTEN"] };
|
|
120
|
+
}
|
|
121
|
+
function processCommandLineCommand(pid, platform2 = process.platform) {
|
|
122
|
+
if (platform2 === "win32") {
|
|
123
|
+
return {
|
|
124
|
+
cmd: "powershell.exe",
|
|
125
|
+
args: [
|
|
126
|
+
"-NoProfile",
|
|
127
|
+
"-Command",
|
|
128
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue; if ($p -and $p.CommandLine) { $p.CommandLine }`
|
|
129
|
+
]
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { cmd: "ps", args: ["-p", pid, "-o", "args="] };
|
|
133
|
+
}
|
|
134
|
+
function killPidCommand(pid, platform2 = process.platform) {
|
|
135
|
+
if (platform2 === "win32") {
|
|
136
|
+
return {
|
|
137
|
+
cmd: "powershell.exe",
|
|
138
|
+
args: ["-NoProfile", "-Command", `Stop-Process -Id ${pid} -Force -ErrorAction Stop`]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { cmd: "kill", args: [pid] };
|
|
142
|
+
}
|
|
143
|
+
function parsePids(output) {
|
|
144
|
+
const seen = new Set;
|
|
145
|
+
const pids = [];
|
|
146
|
+
for (const line of output.split(/\r?\n/)) {
|
|
147
|
+
const pid = line.trim();
|
|
148
|
+
if (!/^\d+$/.test(pid))
|
|
149
|
+
continue;
|
|
150
|
+
if (pid === "0")
|
|
151
|
+
continue;
|
|
152
|
+
if (seen.has(pid))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(pid);
|
|
155
|
+
pids.push(pid);
|
|
156
|
+
}
|
|
157
|
+
return pids;
|
|
158
|
+
}
|
|
159
|
+
function isCodexAppServerCommandLine(cmdline, platform2 = process.platform) {
|
|
160
|
+
const s = platform2 === "win32" ? cmdline.toLowerCase() : cmdline;
|
|
161
|
+
return s.includes("codex") && s.includes("app-server");
|
|
162
|
+
}
|
|
163
|
+
async function cleanupPorts(options) {
|
|
164
|
+
const platform2 = options.platform ?? process.platform;
|
|
165
|
+
const listPids = (port) => {
|
|
166
|
+
try {
|
|
167
|
+
return parsePids(options.run(portPidsCommand(port, platform2)));
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
for (const { port, envVar } of options.ports) {
|
|
173
|
+
const pidList = listPids(port);
|
|
174
|
+
if (pidList.length === 0)
|
|
175
|
+
continue;
|
|
176
|
+
const staleCodexPids = [];
|
|
177
|
+
const foreignPids = [];
|
|
178
|
+
for (const pid of pidList) {
|
|
179
|
+
try {
|
|
180
|
+
const cmdline = options.run(processCommandLineCommand(pid, platform2)).trim();
|
|
181
|
+
if (isCodexAppServerCommandLine(cmdline, platform2)) {
|
|
182
|
+
staleCodexPids.push(pid);
|
|
183
|
+
} else {
|
|
184
|
+
foreignPids.push(pid);
|
|
185
|
+
}
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
if (staleCodexPids.length > 0) {
|
|
189
|
+
options.log(`Cleaning up stale codex app-server on port ${port}: PID(s) ${staleCodexPids.join(", ")}`);
|
|
190
|
+
for (const pid of staleCodexPids) {
|
|
191
|
+
try {
|
|
192
|
+
options.run(killPidCommand(pid, platform2));
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
await options.sleep(500);
|
|
196
|
+
}
|
|
197
|
+
if (foreignPids.length > 0) {
|
|
198
|
+
throw new Error(`Port ${port} is already in use by non-Codex process(es): PID(s) ${foreignPids.join(", ")}. ` + `Please stop the process or set a different port via ${envVar} env var.`);
|
|
199
|
+
}
|
|
200
|
+
const remaining = listPids(port);
|
|
201
|
+
if (remaining.length > 0) {
|
|
202
|
+
throw new Error(`Port ${port} is still occupied (PID(s): ${remaining.join(", ")}) after cleanup. ` + `Please stop the process or set a different port via ${envVar} env var.`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/rotating-log.ts
|
|
208
|
+
import { appendFileSync, existsSync as existsSync2, renameSync, statSync, unlinkSync } from "fs";
|
|
209
|
+
import { dirname } from "path";
|
|
210
|
+
var DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
211
|
+
var DEFAULT_KEEP = 3;
|
|
212
|
+
function appendRotatingLog(path, content, options = {}) {
|
|
213
|
+
const maxBytes = options.maxBytes ?? positiveIntFromEnv("AGENTBRIDGE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES);
|
|
214
|
+
const keep = options.keep ?? positiveIntFromEnv("AGENTBRIDGE_LOG_ROTATE_KEEP", DEFAULT_KEEP);
|
|
215
|
+
if (!existsSync2(dirname(path)))
|
|
216
|
+
return;
|
|
217
|
+
rotateIfNeeded(path, Buffer.byteLength(content), maxBytes, keep);
|
|
218
|
+
appendFileSync(path, content, "utf-8");
|
|
219
|
+
}
|
|
220
|
+
function positiveIntFromEnv(name, fallback) {
|
|
221
|
+
const value = process.env[name];
|
|
222
|
+
if (!value)
|
|
223
|
+
return fallback;
|
|
224
|
+
const parsed = Number(value);
|
|
225
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
226
|
+
}
|
|
227
|
+
function rotateIfNeeded(path, incomingBytes, maxBytes, keep) {
|
|
228
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0 || keep <= 0)
|
|
229
|
+
return;
|
|
230
|
+
if (!existsSync2(path))
|
|
231
|
+
return;
|
|
232
|
+
const size = statSync(path).size;
|
|
233
|
+
if (size + incomingBytes <= maxBytes)
|
|
234
|
+
return;
|
|
235
|
+
for (let index = keep;index >= 1; index--) {
|
|
236
|
+
const current = `${path}.${index}`;
|
|
237
|
+
const next = `${path}.${index + 1}`;
|
|
238
|
+
if (!existsSync2(current))
|
|
239
|
+
continue;
|
|
240
|
+
if (index === keep) {
|
|
241
|
+
unlinkSync(current);
|
|
242
|
+
} else {
|
|
243
|
+
renameSync(current, next);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
renameSync(path, `${path}.1`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/process-log.ts
|
|
250
|
+
var stderrStates = new WeakMap;
|
|
251
|
+
function createProcessLogger(options) {
|
|
252
|
+
let fatalInProgress = false;
|
|
253
|
+
const stderr = options.stderr ?? process.stderr;
|
|
254
|
+
const stderrState = stateForStderr(stderr);
|
|
255
|
+
const write = (message) => {
|
|
256
|
+
const line = `[${new Date().toISOString()}] [${options.component}] ${message}
|
|
257
|
+
`;
|
|
258
|
+
if (options.logFile) {
|
|
259
|
+
try {
|
|
260
|
+
appendRotatingLog(options.logFile, line);
|
|
261
|
+
} catch {}
|
|
262
|
+
}
|
|
263
|
+
if (!stderrState.enabled)
|
|
264
|
+
return;
|
|
265
|
+
try {
|
|
266
|
+
stderr.write(line);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error?.code === "EPIPE")
|
|
269
|
+
stderrState.enabled = false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
log: write,
|
|
274
|
+
fatal(label, error) {
|
|
275
|
+
if (fatalInProgress)
|
|
276
|
+
return;
|
|
277
|
+
fatalInProgress = true;
|
|
278
|
+
try {
|
|
279
|
+
write(`${label}: ${safeFormatError(error)}`);
|
|
280
|
+
} finally {
|
|
281
|
+
fatalInProgress = false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function stateForStderr(stderr) {
|
|
287
|
+
const key = stderr;
|
|
288
|
+
let state = stderrStates.get(key);
|
|
289
|
+
if (state)
|
|
290
|
+
return state;
|
|
291
|
+
state = { enabled: true };
|
|
292
|
+
stderrStates.set(key, state);
|
|
293
|
+
if (typeof stderr.on === "function") {
|
|
294
|
+
stderr.on("error", (error) => {
|
|
295
|
+
if (error?.code === "EPIPE") {
|
|
296
|
+
state.enabled = false;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
setTimeout(() => {
|
|
300
|
+
throw error;
|
|
301
|
+
}, 0);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return state;
|
|
305
|
+
}
|
|
306
|
+
function safeFormatError(error) {
|
|
307
|
+
try {
|
|
308
|
+
return formatError(error);
|
|
309
|
+
} catch {
|
|
310
|
+
return "<failed to format error>";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function formatError(error) {
|
|
314
|
+
if (error instanceof Error)
|
|
315
|
+
return error.stack ?? error.message;
|
|
316
|
+
if (typeof error === "object" && error !== null && "stack" in error) {
|
|
317
|
+
return String(error.stack);
|
|
318
|
+
}
|
|
319
|
+
return String(error);
|
|
63
320
|
}
|
|
64
321
|
|
|
65
322
|
// src/app-server-protocol.ts
|
|
@@ -105,18 +362,263 @@ function isAppServerResponseMessage(value) {
|
|
|
105
362
|
return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
|
|
106
363
|
}
|
|
107
364
|
|
|
365
|
+
// src/codex-transport.ts
|
|
366
|
+
import { createServer, connect } from "net";
|
|
367
|
+
import { spawnSync } from "child_process";
|
|
368
|
+
import { mkdirSync as mkdirSync2, rmSync, chmodSync } from "fs";
|
|
369
|
+
import { join as join2 } from "path";
|
|
370
|
+
import { tmpdir } from "os";
|
|
371
|
+
var CODEX_TRANSPORT_ENV = "AGENTBRIDGE_CODEX_TRANSPORT";
|
|
372
|
+
var HEADER_SEP = `\r
|
|
373
|
+
\r
|
|
374
|
+
`;
|
|
375
|
+
var EXTENSIONS_HEADER_RE = /^sec-websocket-extensions:/i;
|
|
376
|
+
var MAX_UPGRADE_HEADER_BYTES = 64 * 1024;
|
|
377
|
+
function parseTransportMode(raw) {
|
|
378
|
+
switch ((raw ?? "").trim().toLowerCase()) {
|
|
379
|
+
case "ws":
|
|
380
|
+
return "ws";
|
|
381
|
+
case "unix":
|
|
382
|
+
return "unix";
|
|
383
|
+
case "auto":
|
|
384
|
+
case "":
|
|
385
|
+
return "auto";
|
|
386
|
+
default:
|
|
387
|
+
return "auto";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function probeCodexWsSupport(runHelp = defaultRunCodexAppServerHelp) {
|
|
391
|
+
const help = runHelp();
|
|
392
|
+
if (help === null)
|
|
393
|
+
return true;
|
|
394
|
+
return help.includes("ws://");
|
|
395
|
+
}
|
|
396
|
+
function defaultRunCodexAppServerHelp() {
|
|
397
|
+
try {
|
|
398
|
+
const res = spawnSync("codex", ["app-server", "--help"], {
|
|
399
|
+
encoding: "utf-8",
|
|
400
|
+
timeout: 5000
|
|
401
|
+
});
|
|
402
|
+
if (res.error || typeof res.stdout !== "string")
|
|
403
|
+
return null;
|
|
404
|
+
return res.stdout + (res.stderr ?? "");
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function resolveCodexTransport(mode, runHelp = defaultRunCodexAppServerHelp) {
|
|
410
|
+
if (mode === "ws")
|
|
411
|
+
return "ws";
|
|
412
|
+
if (mode === "unix")
|
|
413
|
+
return "unix";
|
|
414
|
+
return probeCodexWsSupport(runHelp) ? "ws" : "unix";
|
|
415
|
+
}
|
|
416
|
+
function codexSocketPath(appPort, baseTmpDir = tmpdir()) {
|
|
417
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
418
|
+
const dir = join2(baseTmpDir, `agentbridge-${uid}`);
|
|
419
|
+
const path = join2(dir, `codex-${appPort}.sock`);
|
|
420
|
+
if (path.length >= 104) {
|
|
421
|
+
throw new Error(`Codex unix socket path is too long for the platform (${path.length} >= 104): ${path}. ` + `Set a shorter TMPDIR or use ${CODEX_TRANSPORT_ENV}=ws.`);
|
|
422
|
+
}
|
|
423
|
+
return path;
|
|
424
|
+
}
|
|
425
|
+
function ensureSocketDir(socketPath) {
|
|
426
|
+
const dir = socketPath.slice(0, socketPath.lastIndexOf("/"));
|
|
427
|
+
if (!dir)
|
|
428
|
+
return;
|
|
429
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
430
|
+
try {
|
|
431
|
+
chmodSync(dir, 448);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
throw new Error(`Refusing to use Codex socket dir ${dir}: cannot enforce 0700 perms ` + `(${err.message}). Remove it or set a private TMPDIR.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function removeSocketFile(socketPath) {
|
|
437
|
+
try {
|
|
438
|
+
rmSync(socketPath, { force: true });
|
|
439
|
+
} catch {}
|
|
440
|
+
}
|
|
441
|
+
function codexListenArg(transport, appPort, socketPath) {
|
|
442
|
+
return transport === "unix" ? `unix://${socketPath}` : `ws://127.0.0.1:${appPort}`;
|
|
443
|
+
}
|
|
444
|
+
function stripWebSocketExtensions(headerBlock) {
|
|
445
|
+
return headerBlock.split(`\r
|
|
446
|
+
`).filter((line) => !EXTENSIONS_HEADER_RE.test(line)).join(`\r
|
|
447
|
+
`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
class TcpToUnixRelay {
|
|
451
|
+
tcpHost;
|
|
452
|
+
tcpPort;
|
|
453
|
+
unixPath;
|
|
454
|
+
log;
|
|
455
|
+
server = null;
|
|
456
|
+
pairs = new Set;
|
|
457
|
+
constructor(tcpHost, tcpPort, unixPath, log = () => {}) {
|
|
458
|
+
this.tcpHost = tcpHost;
|
|
459
|
+
this.tcpPort = tcpPort;
|
|
460
|
+
this.unixPath = unixPath;
|
|
461
|
+
this.log = log;
|
|
462
|
+
}
|
|
463
|
+
start() {
|
|
464
|
+
return new Promise((resolve, reject) => {
|
|
465
|
+
const server = createServer((tcp) => this.handleConnection(tcp));
|
|
466
|
+
const onListenError = (err) => {
|
|
467
|
+
server.removeListener("listening", onListening);
|
|
468
|
+
reject(err);
|
|
469
|
+
};
|
|
470
|
+
const onListening = () => {
|
|
471
|
+
server.removeListener("error", onListenError);
|
|
472
|
+
server.on("error", (err) => this.log(`relay server error: ${err.message}`));
|
|
473
|
+
this.server = server;
|
|
474
|
+
resolve();
|
|
475
|
+
};
|
|
476
|
+
server.once("error", onListenError);
|
|
477
|
+
server.once("listening", onListening);
|
|
478
|
+
server.listen(this.tcpPort, this.tcpHost);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
handleConnection(tcp) {
|
|
482
|
+
const unix = connect(this.unixPath);
|
|
483
|
+
const pair = { tcp, unix };
|
|
484
|
+
this.pairs.add(pair);
|
|
485
|
+
let closed = false;
|
|
486
|
+
const teardown = () => {
|
|
487
|
+
if (closed)
|
|
488
|
+
return;
|
|
489
|
+
closed = true;
|
|
490
|
+
this.pairs.delete(pair);
|
|
491
|
+
tcp.destroy();
|
|
492
|
+
unix.destroy();
|
|
493
|
+
};
|
|
494
|
+
let head = Buffer.alloc(0);
|
|
495
|
+
const onData = (chunk) => {
|
|
496
|
+
head = Buffer.concat([head, chunk]);
|
|
497
|
+
const sep = head.indexOf(HEADER_SEP);
|
|
498
|
+
if (sep === -1) {
|
|
499
|
+
if (head.length > MAX_UPGRADE_HEADER_BYTES) {
|
|
500
|
+
tcp.removeListener("data", onData);
|
|
501
|
+
unix.write(head);
|
|
502
|
+
head = Buffer.alloc(0);
|
|
503
|
+
tcp.pipe(unix);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
tcp.removeListener("data", onData);
|
|
508
|
+
const headers = head.subarray(0, sep).toString("utf8");
|
|
509
|
+
const rest = head.subarray(sep + HEADER_SEP.length);
|
|
510
|
+
unix.write(stripWebSocketExtensions(headers) + HEADER_SEP);
|
|
511
|
+
head = Buffer.alloc(0);
|
|
512
|
+
if (rest.length)
|
|
513
|
+
tcp.unshift(rest);
|
|
514
|
+
tcp.pipe(unix);
|
|
515
|
+
};
|
|
516
|
+
tcp.on("data", onData);
|
|
517
|
+
unix.pipe(tcp);
|
|
518
|
+
tcp.on("error", (e) => {
|
|
519
|
+
this.log(`relay tcp error: ${e.message}`);
|
|
520
|
+
teardown();
|
|
521
|
+
});
|
|
522
|
+
unix.on("error", (e) => {
|
|
523
|
+
this.log(`relay unix error: ${e.message}`);
|
|
524
|
+
teardown();
|
|
525
|
+
});
|
|
526
|
+
tcp.on("close", teardown);
|
|
527
|
+
unix.on("close", teardown);
|
|
528
|
+
}
|
|
529
|
+
get connectionCount() {
|
|
530
|
+
return this.pairs.size;
|
|
531
|
+
}
|
|
532
|
+
get port() {
|
|
533
|
+
const addr = this.server?.address();
|
|
534
|
+
return addr && typeof addr === "object" ? addr.port : this.tcpPort;
|
|
535
|
+
}
|
|
536
|
+
stop() {
|
|
537
|
+
if (this.server) {
|
|
538
|
+
this.server.close();
|
|
539
|
+
this.server = null;
|
|
540
|
+
}
|
|
541
|
+
for (const { tcp, unix } of this.pairs) {
|
|
542
|
+
tcp.destroy();
|
|
543
|
+
unix.destroy();
|
|
544
|
+
}
|
|
545
|
+
this.pairs.clear();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function waitForUnixWsReady(socketPath, maxRetries = 40, delayMs = 250) {
|
|
549
|
+
for (let i = 0;i < maxRetries; i++) {
|
|
550
|
+
if (await attemptUnixWsUpgrade(socketPath))
|
|
551
|
+
return;
|
|
552
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
553
|
+
}
|
|
554
|
+
throw new Error(`Codex unix app-server at ${socketPath} did not become ready`);
|
|
555
|
+
}
|
|
556
|
+
function attemptUnixWsUpgrade(socketPath) {
|
|
557
|
+
return new Promise((resolve) => {
|
|
558
|
+
let settled = false;
|
|
559
|
+
const done = (ok) => {
|
|
560
|
+
if (settled)
|
|
561
|
+
return;
|
|
562
|
+
settled = true;
|
|
563
|
+
try {
|
|
564
|
+
socket.destroy();
|
|
565
|
+
} catch {}
|
|
566
|
+
resolve(ok);
|
|
567
|
+
};
|
|
568
|
+
const socket = connect(socketPath, () => {
|
|
569
|
+
socket.write(`GET / HTTP/1.1\r
|
|
570
|
+
Host: localhost\r
|
|
571
|
+
Upgrade: websocket\r
|
|
572
|
+
Connection: Upgrade\r
|
|
573
|
+
` + `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r
|
|
574
|
+
Sec-WebSocket-Version: 13\r
|
|
575
|
+
\r
|
|
576
|
+
`);
|
|
577
|
+
});
|
|
578
|
+
let buf = "";
|
|
579
|
+
socket.on("data", (d) => {
|
|
580
|
+
buf += d.toString("utf8");
|
|
581
|
+
if (buf.includes(`\r
|
|
582
|
+
`))
|
|
583
|
+
done(buf.startsWith("HTTP/1.1 101"));
|
|
584
|
+
});
|
|
585
|
+
socket.on("error", () => done(false));
|
|
586
|
+
socket.on("close", () => done(false));
|
|
587
|
+
setTimeout(() => done(false), 1500);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/turn-notices.ts
|
|
592
|
+
var ADAPTER_DISCONNECT_REASON = "adapter disconnect";
|
|
593
|
+
var APP_SERVER_RECONNECT_NEW_TUI_REASON = "app-server reconnect for new TUI session";
|
|
594
|
+
var SILENT_ABORT_REASONS = new Set([
|
|
595
|
+
ADAPTER_DISCONNECT_REASON,
|
|
596
|
+
APP_SERVER_RECONNECT_NEW_TUI_REASON
|
|
597
|
+
]);
|
|
598
|
+
function buildTurnAbortedNotice(reason, replyWasRequired) {
|
|
599
|
+
if (SILENT_ABORT_REASONS.has(reason))
|
|
600
|
+
return null;
|
|
601
|
+
const tail = replyWasRequired ? " A reply you were waiting on will NOT arrive \u2014 retry your last message, or wait for the Codex TUI to reconnect." : " If you were waiting on a reply it will not arrive; retry, or wait for the Codex TUI to reconnect.";
|
|
602
|
+
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
|
+
}
|
|
604
|
+
|
|
108
605
|
// src/codex-adapter.ts
|
|
109
606
|
class CodexAdapter extends EventEmitter {
|
|
110
607
|
static RESPONSE_TRACKING_TTL_MS = 30000;
|
|
111
608
|
proc = null;
|
|
609
|
+
appServerPid = null;
|
|
112
610
|
appServerWs = null;
|
|
113
611
|
tuiWs = null;
|
|
114
612
|
proxyServer = null;
|
|
613
|
+
transport = "ws";
|
|
614
|
+
socketPath = null;
|
|
615
|
+
relay = null;
|
|
115
616
|
threadId = null;
|
|
116
617
|
nextInjectionId = -1;
|
|
117
618
|
appPort;
|
|
118
619
|
proxyPort;
|
|
119
620
|
logFile;
|
|
621
|
+
logger;
|
|
120
622
|
tuiConnId = 0;
|
|
121
623
|
connIdCounter = 0;
|
|
122
624
|
secondaryConnections = new Map;
|
|
@@ -124,6 +626,12 @@ class CodexAdapter extends EventEmitter {
|
|
|
124
626
|
pendingRequests = new Map;
|
|
125
627
|
activeTurnIds = new Set;
|
|
126
628
|
turnInProgress = false;
|
|
629
|
+
turnWatchdogs = new Map;
|
|
630
|
+
stalledTurnIds = new Set;
|
|
631
|
+
currentlyStalledTurnIds = new Set;
|
|
632
|
+
lastTurnEndedAbnormally = false;
|
|
633
|
+
lastEmittedPhase = "idle";
|
|
634
|
+
threadSwitchSeq = 0;
|
|
127
635
|
nextProxyId = 1e5;
|
|
128
636
|
upstreamToClient = new Map;
|
|
129
637
|
serverRequestToProxy = new Map;
|
|
@@ -131,6 +639,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
131
639
|
pendingServerResponses = new Map;
|
|
132
640
|
staleProxyIds = new Map;
|
|
133
641
|
bridgeRequestIds = new Map;
|
|
642
|
+
bridgeRequestKinds = new Map;
|
|
134
643
|
intentionalDisconnect = false;
|
|
135
644
|
pendingTuiMessages = [];
|
|
136
645
|
reconnectingForNewSession = false;
|
|
@@ -139,7 +648,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
139
648
|
outageQueue = [];
|
|
140
649
|
outageTimer = null;
|
|
141
650
|
static OUTAGE_QUEUE_MAX = 64;
|
|
142
|
-
static OUTAGE_TIMEOUT_MS =
|
|
651
|
+
static OUTAGE_TIMEOUT_MS = 1e4;
|
|
143
652
|
lastInitializeRaw = null;
|
|
144
653
|
lastInitializedRaw = null;
|
|
145
654
|
sessionRestoreInProgress = false;
|
|
@@ -150,6 +659,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
150
659
|
this.appPort = appPort;
|
|
151
660
|
this.proxyPort = proxyPort;
|
|
152
661
|
this.logFile = logFile;
|
|
662
|
+
this.logger = createProcessLogger({ component: "CodexAdapter", logFile: this.logFile });
|
|
153
663
|
}
|
|
154
664
|
get appServerUrl() {
|
|
155
665
|
return `ws://127.0.0.1:${this.appPort}`;
|
|
@@ -163,21 +673,44 @@ class CodexAdapter extends EventEmitter {
|
|
|
163
673
|
async start() {
|
|
164
674
|
this.intentionalDisconnect = false;
|
|
165
675
|
await this.checkPorts();
|
|
166
|
-
this.
|
|
167
|
-
|
|
676
|
+
this.resolveTransport();
|
|
677
|
+
const listen = codexListenArg(this.transport, this.appPort, this.socketPath ?? "");
|
|
678
|
+
if (this.transport === "unix" && this.socketPath) {
|
|
679
|
+
ensureSocketDir(this.socketPath);
|
|
680
|
+
removeSocketFile(this.socketPath);
|
|
681
|
+
}
|
|
682
|
+
this.log(`Spawning codex app-server (transport=${this.transport}) --listen ${listen}`);
|
|
683
|
+
this.proc = spawn("codex", ["app-server", "--listen", listen], {
|
|
168
684
|
stdio: ["pipe", "pipe", "pipe"]
|
|
169
685
|
});
|
|
686
|
+
this.appServerPid = this.proc.pid ?? null;
|
|
170
687
|
this.proc.on("error", (err) => this.emit("error", err));
|
|
171
|
-
this.proc.on("exit", (code) =>
|
|
688
|
+
this.proc.on("exit", (code) => {
|
|
689
|
+
this.appServerPid = null;
|
|
690
|
+
this.emit("exit", code);
|
|
691
|
+
});
|
|
172
692
|
const stderrRl = createInterface({ input: this.proc.stderr });
|
|
173
693
|
stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
|
|
174
694
|
const stdoutRl = createInterface({ input: this.proc.stdout });
|
|
175
695
|
stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
|
|
176
|
-
|
|
696
|
+
if (this.transport === "unix" && this.socketPath) {
|
|
697
|
+
await waitForUnixWsReady(this.socketPath);
|
|
698
|
+
this.relay = new TcpToUnixRelay("127.0.0.1", this.appPort, this.socketPath, (m) => this.log(`[relay] ${m}`));
|
|
699
|
+
await this.relay.start();
|
|
700
|
+
this.log(`Transport relay ready: ws://127.0.0.1:${this.appPort} \u2192 unix://${this.socketPath}`);
|
|
701
|
+
} else {
|
|
702
|
+
await this.waitForHealthy();
|
|
703
|
+
}
|
|
177
704
|
await this.connectToAppServer();
|
|
178
705
|
this.startProxy();
|
|
179
706
|
this.log(`Proxy ready on ${this.proxyUrl}`);
|
|
180
707
|
}
|
|
708
|
+
resolveTransport() {
|
|
709
|
+
const mode = parseTransportMode(process.env[CODEX_TRANSPORT_ENV]);
|
|
710
|
+
this.transport = resolveCodexTransport(mode);
|
|
711
|
+
this.socketPath = this.transport === "unix" ? codexSocketPath(this.appPort) : null;
|
|
712
|
+
this.log(`Codex transport mode=${mode} resolved=${this.transport}`);
|
|
713
|
+
}
|
|
181
714
|
disconnect() {
|
|
182
715
|
this.intentionalDisconnect = true;
|
|
183
716
|
if (this.reconnectTimer) {
|
|
@@ -196,7 +729,14 @@ class CodexAdapter extends EventEmitter {
|
|
|
196
729
|
}
|
|
197
730
|
this.proxyServer?.stop();
|
|
198
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);
|
|
199
738
|
this.clearResponseTrackingState();
|
|
739
|
+
this.resetTurnState(ADAPTER_DISCONNECT_REASON);
|
|
200
740
|
}
|
|
201
741
|
stop() {
|
|
202
742
|
this.intentionalDisconnect = true;
|
|
@@ -213,7 +753,15 @@ class CodexAdapter extends EventEmitter {
|
|
|
213
753
|
proc.on("exit", () => clearTimeout(killTimer));
|
|
214
754
|
}
|
|
215
755
|
}
|
|
216
|
-
|
|
756
|
+
forceKillAppServerSync() {
|
|
757
|
+
const pid = this.appServerPid;
|
|
758
|
+
if (pid === null)
|
|
759
|
+
return;
|
|
760
|
+
try {
|
|
761
|
+
process.kill(pid, "SIGKILL");
|
|
762
|
+
} catch {}
|
|
763
|
+
}
|
|
764
|
+
injectMessage(text, overrides) {
|
|
217
765
|
if (!this.threadId) {
|
|
218
766
|
this.log("Cannot inject: no active thread");
|
|
219
767
|
return false;
|
|
@@ -229,11 +777,19 @@ class CodexAdapter extends EventEmitter {
|
|
|
229
777
|
this.log(`Injecting message into Codex (${text.length} chars)`);
|
|
230
778
|
const requestId = this.nextInjectionId--;
|
|
231
779
|
this.trackBridgeRequestId(requestId);
|
|
780
|
+
const params = { threadId: this.threadId, input: [{ type: "text", text }] };
|
|
781
|
+
if (overrides?.model)
|
|
782
|
+
params.model = overrides.model;
|
|
783
|
+
if (overrides?.effort)
|
|
784
|
+
params.effort = overrides.effort;
|
|
785
|
+
if (overrides?.model || overrides?.effort) {
|
|
786
|
+
this.log(`Budget tier override on turn/start (model=${overrides.model ?? "unchanged"}, effort=${overrides.effort ?? "unchanged"}) \u2014 sticky for subsequent turns; transport-accepted unless a JSON-RPC error follows`);
|
|
787
|
+
}
|
|
232
788
|
try {
|
|
233
789
|
this.appServerWs.send(JSON.stringify({
|
|
234
790
|
method: "turn/start",
|
|
235
791
|
id: requestId,
|
|
236
|
-
params
|
|
792
|
+
params
|
|
237
793
|
}));
|
|
238
794
|
return true;
|
|
239
795
|
} catch (err) {
|
|
@@ -242,6 +798,36 @@ class CodexAdapter extends EventEmitter {
|
|
|
242
798
|
return false;
|
|
243
799
|
}
|
|
244
800
|
}
|
|
801
|
+
steerMessage(text) {
|
|
802
|
+
if (!this.threadId) {
|
|
803
|
+
this.log("Cannot steer: no active thread");
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
807
|
+
this.log("Cannot steer: app-server WebSocket not connected");
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
if (!this.turnInProgress) {
|
|
811
|
+
this.log("Cannot steer: no turn in progress (use injectMessage)");
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
this.log(`Steering message into active Codex turn (${text.length} chars)`);
|
|
815
|
+
const requestId = this.nextInjectionId--;
|
|
816
|
+
this.trackBridgeRequestId(requestId, "steer");
|
|
817
|
+
const params = { threadId: this.threadId, input: [{ type: "text", text }] };
|
|
818
|
+
try {
|
|
819
|
+
this.appServerWs.send(JSON.stringify({
|
|
820
|
+
method: "turn/steer",
|
|
821
|
+
id: requestId,
|
|
822
|
+
params
|
|
823
|
+
}));
|
|
824
|
+
return true;
|
|
825
|
+
} catch (err) {
|
|
826
|
+
this.untrackBridgeRequestId(requestId);
|
|
827
|
+
this.log(`Steer send failed: ${err.message}`);
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
245
831
|
async waitForHealthy(maxRetries = 20, delayMs = 500) {
|
|
246
832
|
for (let i = 0;i < maxRetries; i++) {
|
|
247
833
|
try {
|
|
@@ -323,8 +909,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
323
909
|
} catch {}
|
|
324
910
|
}
|
|
325
911
|
this.clearResponseTrackingStateForAppServerReconnect();
|
|
326
|
-
this.
|
|
327
|
-
this.turnInProgress = false;
|
|
912
|
+
this.resetTurnState(APP_SERVER_RECONNECT_NEW_TUI_REASON);
|
|
328
913
|
try {
|
|
329
914
|
await this.connectToAppServer(false);
|
|
330
915
|
this.log("App-server reconnected for new TUI session \u2014 replaying buffered messages");
|
|
@@ -378,8 +963,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
378
963
|
this.log(`App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`);
|
|
379
964
|
this.appServerWs = null;
|
|
380
965
|
this.clearResponseTrackingState();
|
|
381
|
-
this.
|
|
382
|
-
this.turnInProgress = false;
|
|
966
|
+
this.resetTurnState("app-server connection closed");
|
|
383
967
|
if (!intentional) {
|
|
384
968
|
this.scheduleReconnect();
|
|
385
969
|
}
|
|
@@ -552,6 +1136,10 @@ class CodexAdapter extends EventEmitter {
|
|
|
552
1136
|
const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
553
1137
|
self.log(`HTTP ${req.method} ${url.pathname} (upgrade=${isUpgrade})`);
|
|
554
1138
|
if (url.pathname === "/healthz" || url.pathname === "/readyz") {
|
|
1139
|
+
if (self.transport === "unix") {
|
|
1140
|
+
const up = self.appServerWs?.readyState === WebSocket.OPEN;
|
|
1141
|
+
return new Response(up ? "ok" : "upstream not connected", { status: up ? 200 : 503 });
|
|
1142
|
+
}
|
|
555
1143
|
return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
|
|
556
1144
|
}
|
|
557
1145
|
if (server.upgrade(req, { data: { connId: 0 } }))
|
|
@@ -589,7 +1177,13 @@ class CodexAdapter extends EventEmitter {
|
|
|
589
1177
|
}
|
|
590
1178
|
setupSecondaryConnection(ws, connId) {
|
|
591
1179
|
const appWs = new WebSocket(this.appServerUrl);
|
|
592
|
-
const entry = {
|
|
1180
|
+
const entry = {
|
|
1181
|
+
tuiWs: ws,
|
|
1182
|
+
appServerWs: appWs,
|
|
1183
|
+
buffer: [],
|
|
1184
|
+
initialized: false,
|
|
1185
|
+
initializationReplayed: false
|
|
1186
|
+
};
|
|
593
1187
|
this.secondaryConnections.set(connId, entry);
|
|
594
1188
|
appWs.onopen = () => {
|
|
595
1189
|
if (!this.secondaryConnections.has(connId)) {
|
|
@@ -711,13 +1305,13 @@ class CodexAdapter extends EventEmitter {
|
|
|
711
1305
|
const connId = ws.data.connId;
|
|
712
1306
|
const secondary = this.secondaryConnections.get(connId);
|
|
713
1307
|
if (secondary) {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
secondary.buffer.push(data);
|
|
1308
|
+
const method = this.detectJsonMethod(data);
|
|
1309
|
+
if (method === "initialize" || method === "initialized") {
|
|
1310
|
+
secondary.initialized = true;
|
|
1311
|
+
} else if (!secondary.initialized) {
|
|
1312
|
+
this.ensureSecondaryInitialized(secondary, connId);
|
|
720
1313
|
}
|
|
1314
|
+
this.sendOrBufferSecondary(secondary, data);
|
|
721
1315
|
return;
|
|
722
1316
|
}
|
|
723
1317
|
if (connId !== this.tuiConnId) {
|
|
@@ -808,9 +1402,44 @@ class CodexAdapter extends EventEmitter {
|
|
|
808
1402
|
this.log(`WARNING: app-server closed between OPEN check and send \u2014 message lost (connId=${ws.data.connId})`);
|
|
809
1403
|
}
|
|
810
1404
|
}
|
|
1405
|
+
detectJsonMethod(raw) {
|
|
1406
|
+
try {
|
|
1407
|
+
const parsed = JSON.parse(raw);
|
|
1408
|
+
return typeof parsed?.method === "string" ? parsed.method : undefined;
|
|
1409
|
+
} catch {
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
ensureSecondaryInitialized(secondary, connId) {
|
|
1414
|
+
if (secondary.initializationReplayed)
|
|
1415
|
+
return;
|
|
1416
|
+
secondary.initializationReplayed = true;
|
|
1417
|
+
if (!this.lastInitializeRaw) {
|
|
1418
|
+
this.log(`Secondary conn #${connId}: no cached initialize available before first non-initialize request`);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
this.log(`Secondary conn #${connId}: replaying cached initialize before picker request`);
|
|
1422
|
+
this.sendOrBufferSecondary(secondary, this.lastInitializeRaw);
|
|
1423
|
+
if (this.lastInitializedRaw) {
|
|
1424
|
+
this.sendOrBufferSecondary(secondary, this.lastInitializedRaw);
|
|
1425
|
+
}
|
|
1426
|
+
secondary.initialized = true;
|
|
1427
|
+
}
|
|
1428
|
+
sendOrBufferSecondary(secondary, raw) {
|
|
1429
|
+
if (secondary.appServerWs && secondary.appServerWs.readyState === WebSocket.OPEN) {
|
|
1430
|
+
try {
|
|
1431
|
+
secondary.appServerWs.send(raw);
|
|
1432
|
+
} catch {}
|
|
1433
|
+
} else {
|
|
1434
|
+
secondary.buffer.push(raw);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
811
1437
|
handleAppServerPayload(raw) {
|
|
812
1438
|
try {
|
|
813
1439
|
const parsed = JSON.parse(raw);
|
|
1440
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1441
|
+
this.refreshTurnWatchdogs();
|
|
1442
|
+
}
|
|
814
1443
|
if (typeof parsed === "object" && parsed !== null && "id" in parsed) {
|
|
815
1444
|
if (this.tryConsumeReplayResponse(parsed)) {
|
|
816
1445
|
return null;
|
|
@@ -889,7 +1518,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
889
1518
|
timestamp: Date.now()
|
|
890
1519
|
});
|
|
891
1520
|
this.serverRequestToProxy.delete(proxyId);
|
|
892
|
-
this.log(`
|
|
1521
|
+
this.log(`Approval response could not reach the app-server (${reason}) \u2014 buffered best-effort, but it is ` + `likely lost (session-scoped id; reconnects clear this buffer). The TUI may need to re-approve. ` + `(proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
|
|
893
1522
|
}
|
|
894
1523
|
flushPendingServerResponses() {
|
|
895
1524
|
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
|
|
@@ -921,11 +1550,22 @@ class CodexAdapter extends EventEmitter {
|
|
|
921
1550
|
this.interceptServerMessage(parsed, mapping.connId);
|
|
922
1551
|
return forwarded;
|
|
923
1552
|
}
|
|
924
|
-
|
|
1553
|
+
const bridgeKind = !isNaN(numericId) ? this.consumeBridgeRequestId(numericId) : null;
|
|
1554
|
+
if (bridgeKind) {
|
|
925
1555
|
if (parsed.error) {
|
|
926
|
-
this.log(`Bridge-originated request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
|
|
1556
|
+
this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
|
|
1557
|
+
if (bridgeKind === "steer") {
|
|
1558
|
+
this.emit("steerFailed", parsed.error.message ?? "unknown error");
|
|
1559
|
+
} else {
|
|
1560
|
+
this.lastTurnEndedAbnormally = true;
|
|
1561
|
+
this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
|
|
1562
|
+
this.notifyPhaseIfChanged();
|
|
1563
|
+
}
|
|
927
1564
|
} else {
|
|
928
|
-
this.log(`Bridge-originated request completed (id ${responseId})`);
|
|
1565
|
+
this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
|
|
1566
|
+
if (bridgeKind === "steer") {
|
|
1567
|
+
this.emit("steerAccepted");
|
|
1568
|
+
}
|
|
929
1569
|
}
|
|
930
1570
|
return null;
|
|
931
1571
|
}
|
|
@@ -1039,6 +1679,9 @@ class CodexAdapter extends EventEmitter {
|
|
|
1039
1679
|
pending.threadId = threadId;
|
|
1040
1680
|
}
|
|
1041
1681
|
}
|
|
1682
|
+
if (method === "thread/start" || method === "thread/resume") {
|
|
1683
|
+
pending.threadSwitchSeq = ++this.threadSwitchSeq;
|
|
1684
|
+
}
|
|
1042
1685
|
if (this.pendingRequests.has(key)) {
|
|
1043
1686
|
this.log(`WARNING: overwriting pending request for key ${key}`);
|
|
1044
1687
|
}
|
|
@@ -1062,6 +1705,10 @@ class CodexAdapter extends EventEmitter {
|
|
|
1062
1705
|
}
|
|
1063
1706
|
switch (pending.method) {
|
|
1064
1707
|
case "thread/start": {
|
|
1708
|
+
if (!this.isLatestThreadSwitch(pending)) {
|
|
1709
|
+
this.log(`Ignoring stale thread/start response ${key} (seq=${pending.threadSwitchSeq} < latest=${this.threadSwitchSeq})`);
|
|
1710
|
+
break;
|
|
1711
|
+
}
|
|
1065
1712
|
const threadId = message?.result?.thread?.id;
|
|
1066
1713
|
if (typeof threadId === "string" && threadId.length > 0) {
|
|
1067
1714
|
this.setActiveThreadId(threadId, `thread/start response ${key}`);
|
|
@@ -1070,6 +1717,10 @@ class CodexAdapter extends EventEmitter {
|
|
|
1070
1717
|
break;
|
|
1071
1718
|
}
|
|
1072
1719
|
case "thread/resume": {
|
|
1720
|
+
if (!this.isLatestThreadSwitch(pending)) {
|
|
1721
|
+
this.log(`Ignoring stale thread/resume response ${key} (seq=${pending.threadSwitchSeq} < latest=${this.threadSwitchSeq})`);
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1073
1724
|
const threadId = message?.result?.thread?.id;
|
|
1074
1725
|
if (typeof threadId === "string" && threadId.length > 0) {
|
|
1075
1726
|
this.setActiveThreadId(threadId, `thread/resume response ${key}`);
|
|
@@ -1082,16 +1733,24 @@ class CodexAdapter extends EventEmitter {
|
|
|
1082
1733
|
}
|
|
1083
1734
|
case "turn/start":
|
|
1084
1735
|
if (pending.threadId) {
|
|
1085
|
-
this.
|
|
1736
|
+
if (this.threadId === null || this.threadId === pending.threadId) {
|
|
1737
|
+
this.setActiveThreadId(pending.threadId, `turn/start response ${key}`);
|
|
1738
|
+
} else {
|
|
1739
|
+
this.log(`Ignoring turn/start response ${key} threadId=${pending.threadId} (active thread is ${this.threadId})`);
|
|
1740
|
+
}
|
|
1086
1741
|
}
|
|
1087
1742
|
break;
|
|
1088
1743
|
}
|
|
1089
1744
|
}
|
|
1745
|
+
isLatestThreadSwitch(pending) {
|
|
1746
|
+
return pending.threadSwitchSeq === this.threadSwitchSeq;
|
|
1747
|
+
}
|
|
1090
1748
|
setActiveThreadId(threadId, reason) {
|
|
1091
1749
|
if (this.threadId === threadId)
|
|
1092
1750
|
return;
|
|
1093
1751
|
const previousThreadId = this.threadId;
|
|
1094
1752
|
this.threadId = threadId;
|
|
1753
|
+
this.emit("threadChanged", { threadId, previousThreadId, reason });
|
|
1095
1754
|
if (previousThreadId) {
|
|
1096
1755
|
this.log(`Active thread changed: ${previousThreadId} \u2192 ${threadId} (${reason})`);
|
|
1097
1756
|
return;
|
|
@@ -1099,25 +1758,118 @@ class CodexAdapter extends EventEmitter {
|
|
|
1099
1758
|
this.log(`Thread detected: ${threadId} (${reason})`);
|
|
1100
1759
|
this.emit("ready", threadId);
|
|
1101
1760
|
}
|
|
1761
|
+
get turnPhase() {
|
|
1762
|
+
if (this.activeTurnIds.size > 0) {
|
|
1763
|
+
const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
|
|
1764
|
+
return allStalled ? "stalled" : "running";
|
|
1765
|
+
}
|
|
1766
|
+
return this.lastTurnEndedAbnormally ? "aborted" : "idle";
|
|
1767
|
+
}
|
|
1768
|
+
notifyPhaseIfChanged() {
|
|
1769
|
+
const phase = this.turnPhase;
|
|
1770
|
+
if (phase === this.lastEmittedPhase)
|
|
1771
|
+
return;
|
|
1772
|
+
const previous = this.lastEmittedPhase;
|
|
1773
|
+
this.lastEmittedPhase = phase;
|
|
1774
|
+
this.emit("turnPhaseChanged", { phase, previous });
|
|
1775
|
+
}
|
|
1102
1776
|
markTurnStarted(turnId) {
|
|
1103
1777
|
const wasInProgress = this.turnInProgress;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1778
|
+
const turnKey = typeof turnId === "string" && turnId.length > 0 ? turnId : `unknown:${Date.now()}`;
|
|
1779
|
+
this.activeTurnIds.add(turnKey);
|
|
1780
|
+
this.stalledTurnIds.delete(turnKey);
|
|
1781
|
+
this.currentlyStalledTurnIds.delete(turnKey);
|
|
1782
|
+
this.lastTurnEndedAbnormally = false;
|
|
1783
|
+
this.scheduleTurnWatchdog(turnKey);
|
|
1109
1784
|
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
1110
1785
|
if (!wasInProgress && this.turnInProgress) {
|
|
1111
1786
|
this.emit("turnStarted");
|
|
1112
1787
|
}
|
|
1788
|
+
this.notifyPhaseIfChanged();
|
|
1113
1789
|
}
|
|
1114
1790
|
markTurnCompleted(turnId) {
|
|
1115
1791
|
if (typeof turnId === "string" && turnId.length > 0) {
|
|
1116
1792
|
this.activeTurnIds.delete(turnId);
|
|
1793
|
+
this.clearTurnWatchdog(turnId);
|
|
1794
|
+
this.stalledTurnIds.delete(turnId);
|
|
1795
|
+
this.currentlyStalledTurnIds.delete(turnId);
|
|
1117
1796
|
} else {
|
|
1118
1797
|
this.activeTurnIds.clear();
|
|
1798
|
+
this.clearAllTurnWatchdogs();
|
|
1799
|
+
this.stalledTurnIds.clear();
|
|
1800
|
+
this.currentlyStalledTurnIds.clear();
|
|
1119
1801
|
}
|
|
1802
|
+
this.lastTurnEndedAbnormally = false;
|
|
1120
1803
|
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
1804
|
+
this.notifyPhaseIfChanged();
|
|
1805
|
+
}
|
|
1806
|
+
turnWatchdogMs() {
|
|
1807
|
+
const v = Number(process.env.AGENTBRIDGE_TURN_WATCHDOG_MS);
|
|
1808
|
+
return Number.isFinite(v) && v > 0 ? v : 300000;
|
|
1809
|
+
}
|
|
1810
|
+
scheduleTurnWatchdog(turnKey) {
|
|
1811
|
+
this.clearTurnWatchdog(turnKey);
|
|
1812
|
+
const timer = setTimeout(() => {
|
|
1813
|
+
if (!this.activeTurnIds.has(turnKey))
|
|
1814
|
+
return;
|
|
1815
|
+
this.log(`WARNING: turn ${turnKey} watchdog fired after ${this.turnWatchdogMs()}ms of inactivity \u2014 ` + `marking stalled but keeping Codex busy until a real completion or reconnect`);
|
|
1816
|
+
this.markTurnStalled(turnKey);
|
|
1817
|
+
}, this.turnWatchdogMs());
|
|
1818
|
+
timer.unref?.();
|
|
1819
|
+
this.turnWatchdogs.set(turnKey, timer);
|
|
1820
|
+
}
|
|
1821
|
+
clearTurnWatchdog(turnKey) {
|
|
1822
|
+
const timer = this.turnWatchdogs.get(turnKey);
|
|
1823
|
+
if (timer) {
|
|
1824
|
+
clearTimeout(timer);
|
|
1825
|
+
this.turnWatchdogs.delete(turnKey);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
clearAllTurnWatchdogs() {
|
|
1829
|
+
for (const timer of this.turnWatchdogs.values())
|
|
1830
|
+
clearTimeout(timer);
|
|
1831
|
+
this.turnWatchdogs.clear();
|
|
1832
|
+
}
|
|
1833
|
+
refreshTurnWatchdogs() {
|
|
1834
|
+
if (this.turnWatchdogs.size === 0)
|
|
1835
|
+
return;
|
|
1836
|
+
for (const turnKey of [...this.turnWatchdogs.keys()]) {
|
|
1837
|
+
this.scheduleTurnWatchdog(turnKey);
|
|
1838
|
+
}
|
|
1839
|
+
this.currentlyStalledTurnIds.clear();
|
|
1840
|
+
this.notifyPhaseIfChanged();
|
|
1841
|
+
}
|
|
1842
|
+
markTurnStalled(turnKey) {
|
|
1843
|
+
if (!this.activeTurnIds.has(turnKey))
|
|
1844
|
+
return;
|
|
1845
|
+
this.turnInProgress = true;
|
|
1846
|
+
this.currentlyStalledTurnIds.add(turnKey);
|
|
1847
|
+
this.notifyPhaseIfChanged();
|
|
1848
|
+
if (this.stalledTurnIds.has(turnKey))
|
|
1849
|
+
return;
|
|
1850
|
+
this.stalledTurnIds.add(turnKey);
|
|
1851
|
+
this.emit("turnStalled", {
|
|
1852
|
+
turnId: turnKey,
|
|
1853
|
+
inactivityMs: this.turnWatchdogMs()
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
resetTurnState(reason, emitCompleted = false) {
|
|
1857
|
+
const wasInProgress = this.turnInProgress;
|
|
1858
|
+
this.activeTurnIds.clear();
|
|
1859
|
+
this.clearAllTurnWatchdogs();
|
|
1860
|
+
this.stalledTurnIds.clear();
|
|
1861
|
+
this.currentlyStalledTurnIds.clear();
|
|
1862
|
+
this.turnInProgress = false;
|
|
1863
|
+
if (wasInProgress) {
|
|
1864
|
+
this.lastTurnEndedAbnormally = !emitCompleted;
|
|
1865
|
+
if (emitCompleted) {
|
|
1866
|
+
this.emit("turnCompleted");
|
|
1867
|
+
} else {
|
|
1868
|
+
this.emit("turnAborted", reason);
|
|
1869
|
+
}
|
|
1870
|
+
this.log(`Turn state reset (${reason})`);
|
|
1871
|
+
}
|
|
1872
|
+
this.notifyPhaseIfChanged();
|
|
1121
1873
|
}
|
|
1122
1874
|
requestKey(id) {
|
|
1123
1875
|
if (typeof id === "number" || typeof id === "string")
|
|
@@ -1164,18 +1916,23 @@ class CodexAdapter extends EventEmitter {
|
|
|
1164
1916
|
consumeStaleProxyId(proxyId) {
|
|
1165
1917
|
return this.clearTrackedId(this.staleProxyIds, proxyId);
|
|
1166
1918
|
}
|
|
1167
|
-
trackBridgeRequestId(requestId) {
|
|
1919
|
+
trackBridgeRequestId(requestId, kind = "turn-start") {
|
|
1168
1920
|
this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
1169
1921
|
const timer = setTimeout(() => {
|
|
1170
1922
|
this.bridgeRequestIds.delete(requestId);
|
|
1923
|
+
this.bridgeRequestKinds.delete(requestId);
|
|
1171
1924
|
}, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
|
|
1172
1925
|
timer.unref?.();
|
|
1173
1926
|
this.bridgeRequestIds.set(requestId, timer);
|
|
1927
|
+
this.bridgeRequestKinds.set(requestId, kind);
|
|
1174
1928
|
}
|
|
1175
1929
|
consumeBridgeRequestId(requestId) {
|
|
1176
|
-
|
|
1930
|
+
const kind = this.bridgeRequestKinds.get(requestId) ?? "turn-start";
|
|
1931
|
+
this.bridgeRequestKinds.delete(requestId);
|
|
1932
|
+
return this.clearTrackedId(this.bridgeRequestIds, requestId) ? kind : null;
|
|
1177
1933
|
}
|
|
1178
1934
|
untrackBridgeRequestId(requestId) {
|
|
1935
|
+
this.bridgeRequestKinds.delete(requestId);
|
|
1179
1936
|
this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
1180
1937
|
}
|
|
1181
1938
|
clearTrackedId(store, id) {
|
|
@@ -1197,6 +1954,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
1197
1954
|
clearTimeout(timer);
|
|
1198
1955
|
}
|
|
1199
1956
|
this.bridgeRequestIds.clear();
|
|
1957
|
+
this.bridgeRequestKinds.clear();
|
|
1200
1958
|
}
|
|
1201
1959
|
clearResponseTrackingState() {
|
|
1202
1960
|
this.clearTransientResponseTrackingState();
|
|
@@ -1219,69 +1977,55 @@ class CodexAdapter extends EventEmitter {
|
|
|
1219
1977
|
this.pendingServerResponses.clear();
|
|
1220
1978
|
}
|
|
1221
1979
|
static buildPortListenLsofCommand(port) {
|
|
1222
|
-
|
|
1980
|
+
const { cmd, args } = portPidsCommand(port, "linux");
|
|
1981
|
+
return [cmd, ...args].join(" ");
|
|
1223
1982
|
}
|
|
1224
1983
|
async checkPorts() {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
const staleCodexPids = [];
|
|
1235
|
-
const foreignPids = [];
|
|
1236
|
-
for (const pid of pidList) {
|
|
1237
|
-
try {
|
|
1238
|
-
const cmdline = execSync(`ps -p ${pid} -o args=`, { encoding: "utf-8" }).trim();
|
|
1239
|
-
if (cmdline.includes("codex") && cmdline.includes("app-server")) {
|
|
1240
|
-
staleCodexPids.push(pid);
|
|
1241
|
-
} else {
|
|
1242
|
-
foreignPids.push(pid);
|
|
1243
|
-
}
|
|
1244
|
-
} catch {}
|
|
1245
|
-
}
|
|
1246
|
-
if (staleCodexPids.length > 0) {
|
|
1247
|
-
this.log(`Cleaning up stale codex app-server on port ${port}: PID(s) ${staleCodexPids.join(", ")}`);
|
|
1248
|
-
for (const pid of staleCodexPids) {
|
|
1249
|
-
try {
|
|
1250
|
-
execSync(`kill ${pid}`, { encoding: "utf-8" });
|
|
1251
|
-
} catch {}
|
|
1252
|
-
}
|
|
1253
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1254
|
-
}
|
|
1255
|
-
if (foreignPids.length > 0) {
|
|
1256
|
-
throw new Error(`Port ${port} is already in use by non-Codex process(es): PID(s) ${foreignPids.join(", ")}. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
|
|
1257
|
-
}
|
|
1258
|
-
try {
|
|
1259
|
-
const remaining = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
|
|
1260
|
-
encoding: "utf-8"
|
|
1261
|
-
}).trim();
|
|
1262
|
-
if (remaining) {
|
|
1263
|
-
throw new Error(`Port ${port} is still occupied (PID(s): ${remaining.replace(/\n/g, ", ")}) after cleanup. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
|
|
1264
|
-
}
|
|
1265
|
-
} catch (err) {
|
|
1266
|
-
if (err.message?.includes("Port"))
|
|
1267
|
-
throw err;
|
|
1268
|
-
}
|
|
1269
|
-
} catch (err) {
|
|
1270
|
-
if (err.message?.includes("Port") || err.message?.includes("non-Codex"))
|
|
1271
|
-
throw err;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1984
|
+
await cleanupPorts({
|
|
1985
|
+
ports: [
|
|
1986
|
+
{ port: this.appPort, envVar: "CODEX_WS_PORT" },
|
|
1987
|
+
{ port: this.proxyPort, envVar: "CODEX_PROXY_PORT" }
|
|
1988
|
+
],
|
|
1989
|
+
run: ({ cmd, args }) => execFileSync(cmd, args, { encoding: "utf-8" }),
|
|
1990
|
+
log: (message) => this.log(message),
|
|
1991
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1992
|
+
});
|
|
1274
1993
|
}
|
|
1275
1994
|
log(msg) {
|
|
1276
|
-
|
|
1277
|
-
`;
|
|
1278
|
-
process.stderr.write(line);
|
|
1279
|
-
try {
|
|
1280
|
-
appendFileSync(this.logFile, line);
|
|
1281
|
-
} catch {}
|
|
1995
|
+
this.logger.log(msg);
|
|
1282
1996
|
}
|
|
1283
1997
|
}
|
|
1284
1998
|
|
|
1999
|
+
// src/control-protocol.ts
|
|
2000
|
+
var CLOSE_CODE_REPLACED = 4001;
|
|
2001
|
+
var CLOSE_CODE_EVICTED_STALE = 4002;
|
|
2002
|
+
var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
|
|
2003
|
+
var CLOSE_CODE_PAIR_MISMATCH = 4004;
|
|
2004
|
+
|
|
2005
|
+
// src/daemon-identity.ts
|
|
2006
|
+
function validateClaudeClientIdentity(input) {
|
|
2007
|
+
if (!input.expectedPairId)
|
|
2008
|
+
return { ok: true };
|
|
2009
|
+
if (!input.identity) {
|
|
2010
|
+
return input.allowIdentityless ? { ok: true } : { ok: false, closeCode: CLOSE_CODE_PAIR_MISMATCH, reason: "missing client identity" };
|
|
2011
|
+
}
|
|
2012
|
+
if (input.identity.pairId !== input.expectedPairId) {
|
|
2013
|
+
return {
|
|
2014
|
+
ok: false,
|
|
2015
|
+
closeCode: CLOSE_CODE_PAIR_MISMATCH,
|
|
2016
|
+
reason: `pair mismatch: expected ${input.expectedPairId}, got ${input.identity.pairId ?? "<none>"}`
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
if (!input.identity.cwd || input.identity.cwd !== input.daemonCwd) {
|
|
2020
|
+
return {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
closeCode: CLOSE_CODE_PAIR_MISMATCH,
|
|
2023
|
+
reason: `cwd mismatch: expected ${input.daemonCwd}, got ${input.identity.cwd ?? "<none>"}`
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
return { ok: true };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1285
2029
|
// src/message-filter.ts
|
|
1286
2030
|
var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
|
|
1287
2031
|
function parseMarker(content) {
|
|
@@ -1308,27 +2052,6 @@ function classifyMessage(content, mode) {
|
|
|
1308
2052
|
return { action: "forward", marker };
|
|
1309
2053
|
}
|
|
1310
2054
|
}
|
|
1311
|
-
var BRIDGE_CONTRACT_REMINDER = `[Bridge Contract] When sending agentMessage, put the marker at the very start of the message:
|
|
1312
|
-
- [IMPORTANT] for decisions, reviews, completions, blockers
|
|
1313
|
-
- [STATUS] for progress updates
|
|
1314
|
-
- [FYI] for background context
|
|
1315
|
-
The marker MUST be the first text in the message (e.g. "[IMPORTANT] Task done", not "Task done [IMPORTANT]").
|
|
1316
|
-
Keep agentMessage for high-value communication only.
|
|
1317
|
-
|
|
1318
|
-
[Git Operations \u2014 FORBIDDEN]
|
|
1319
|
-
You MUST NOT execute any git write commands. This includes but is not limited to:
|
|
1320
|
-
git commit, git push, git pull, git fetch, git checkout -b, git branch, git merge, git rebase, git cherry-pick, git tag, git stash.
|
|
1321
|
-
These commands write to the .git directory, which is blocked by your sandbox. Attempting them will cause your session to hang indefinitely.
|
|
1322
|
-
Read-only git commands (git status, git log, git diff, git show, git rev-parse) are allowed.
|
|
1323
|
-
All git write operations must be delegated to Claude Code via agentMessage. Report what you changed and let Claude handle branching, committing, and pushing.
|
|
1324
|
-
|
|
1325
|
-
[Role Guidance for Codex]
|
|
1326
|
-
- Your default role: Implementer, Executor, Verifier
|
|
1327
|
-
- Analytical/review tasks: Independent Analysis & Convergence
|
|
1328
|
-
- Implementation tasks: Architect -> Builder -> Critic
|
|
1329
|
-
- Debugging tasks: Hypothesis -> Experiment -> Interpretation
|
|
1330
|
-
- Do not blindly follow Claude - challenge with evidence when you disagree
|
|
1331
|
-
- Use explicit collaboration phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"`;
|
|
1332
2055
|
var REPLY_REQUIRED_INSTRUCTION = `
|
|
1333
2056
|
|
|
1334
2057
|
[\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.`;
|
|
@@ -1484,11 +2207,31 @@ class TuiConnectionState {
|
|
|
1484
2207
|
}
|
|
1485
2208
|
|
|
1486
2209
|
// src/daemon-lifecycle.ts
|
|
1487
|
-
import { spawn as spawn2, execFileSync } from "child_process";
|
|
1488
|
-
import { existsSync as
|
|
2210
|
+
import { spawn as spawn2, execFileSync as execFileSync2 } from "child_process";
|
|
2211
|
+
import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
1489
2212
|
import { fileURLToPath } from "url";
|
|
1490
|
-
|
|
2213
|
+
|
|
2214
|
+
// src/env-utils.ts
|
|
2215
|
+
function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
|
|
2216
|
+
const raw = env[name];
|
|
2217
|
+
if (raw == null || raw === "")
|
|
2218
|
+
return fallback;
|
|
2219
|
+
const parsed = Number(raw);
|
|
2220
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
2221
|
+
log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
|
|
2222
|
+
return fallback;
|
|
2223
|
+
}
|
|
2224
|
+
return parsed;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// src/daemon-lifecycle.ts
|
|
2228
|
+
var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
|
|
2229
|
+
var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
|
|
1491
2230
|
var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
|
|
2231
|
+
var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
|
|
2232
|
+
var REUSE_READY_DELAY_MS = 250;
|
|
2233
|
+
var HEALTH_FETCH_TIMEOUT_MS = 500;
|
|
2234
|
+
var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
|
|
1492
2235
|
|
|
1493
2236
|
class DaemonLifecycle {
|
|
1494
2237
|
stateDir;
|
|
@@ -1508,42 +2251,120 @@ class DaemonLifecycle {
|
|
|
1508
2251
|
get controlWsUrl() {
|
|
1509
2252
|
return `ws://127.0.0.1:${this.controlPort}/ws`;
|
|
1510
2253
|
}
|
|
2254
|
+
get expectedPairId() {
|
|
2255
|
+
return process.env.AGENTBRIDGE_PAIR_ID || null;
|
|
2256
|
+
}
|
|
2257
|
+
async fetchStatus() {
|
|
2258
|
+
try {
|
|
2259
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
2260
|
+
if (!response.ok)
|
|
2261
|
+
return null;
|
|
2262
|
+
return await response.json();
|
|
2263
|
+
} catch {
|
|
2264
|
+
return null;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
isForeignDaemon(status) {
|
|
2268
|
+
const expected = this.expectedPairId;
|
|
2269
|
+
if (!expected)
|
|
2270
|
+
return false;
|
|
2271
|
+
if (!status)
|
|
2272
|
+
return false;
|
|
2273
|
+
const reported = status.pairId;
|
|
2274
|
+
if (reported == null)
|
|
2275
|
+
return true;
|
|
2276
|
+
return reported !== expected;
|
|
2277
|
+
}
|
|
2278
|
+
isRegisteredPairDaemonInManualMode(status) {
|
|
2279
|
+
return !this.expectedPairId && status?.pairId != null;
|
|
2280
|
+
}
|
|
2281
|
+
isBuildDrifted(status) {
|
|
2282
|
+
if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
|
|
2283
|
+
return false;
|
|
2284
|
+
const runtime = status?.build;
|
|
2285
|
+
if (!runtime)
|
|
2286
|
+
return true;
|
|
2287
|
+
return !sameRuntimeContract(runtime, BUILD_INFO);
|
|
2288
|
+
}
|
|
2289
|
+
canReuseDespiteDrift(status) {
|
|
2290
|
+
if (!compatibleContractVersion(status?.build, BUILD_INFO))
|
|
2291
|
+
return false;
|
|
2292
|
+
return status?.tuiConnected === true;
|
|
2293
|
+
}
|
|
1511
2294
|
async ensureRunning() {
|
|
1512
2295
|
if (await this.isHealthy()) {
|
|
1513
|
-
await this.
|
|
1514
|
-
|
|
2296
|
+
const status = await this.fetchStatus();
|
|
2297
|
+
if (this.isRegisteredPairDaemonInManualMode(status)) {
|
|
2298
|
+
throw new Error(`Control port ${this.controlPort} is owned by registered pair ${status?.pairId}. ` + `This session has no pair identity (manual mode) and will not reuse or replace it \u2014 ` + `start with \`agentbridge claude\` from that pair's directory, or set AGENTBRIDGE_CONTROL_PORT to a free port.`);
|
|
2299
|
+
}
|
|
2300
|
+
if (this.isForeignDaemon(status)) {
|
|
2301
|
+
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`);
|
|
2302
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
if (this.isBuildDrifted(status)) {
|
|
2306
|
+
if (this.canReuseDespiteDrift(status)) {
|
|
2307
|
+
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)`);
|
|
2308
|
+
} else {
|
|
2309
|
+
this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
|
|
2310
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2316
|
+
return;
|
|
2317
|
+
} catch {
|
|
2318
|
+
this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
|
|
2319
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
1515
2322
|
}
|
|
1516
2323
|
const existingPid = this.readPid();
|
|
1517
2324
|
if (existingPid) {
|
|
1518
2325
|
if (isProcessAlive(existingPid)) {
|
|
1519
2326
|
if (this.isDaemonProcess(existingPid)) {
|
|
1520
2327
|
try {
|
|
1521
|
-
await this.waitForReady(
|
|
2328
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
1522
2329
|
return;
|
|
1523
2330
|
} catch {
|
|
1524
|
-
|
|
2331
|
+
this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
|
|
2332
|
+
await this.replaceUnhealthyDaemon(existingPid);
|
|
2333
|
+
return;
|
|
1525
2334
|
}
|
|
1526
2335
|
}
|
|
1527
2336
|
this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
|
|
1528
2337
|
}
|
|
1529
2338
|
this.removeStalePidFile();
|
|
1530
2339
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
2340
|
+
await this.withStartupLockStrict(async (locked) => {
|
|
2341
|
+
if (!locked) {
|
|
2342
|
+
this.log("Another process holds the startup lock, waiting for readiness+identity...");
|
|
2343
|
+
await this.waitForReadyAndOurs();
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (await this.isHealthy()) {
|
|
2347
|
+
const status = await this.fetchStatus();
|
|
2348
|
+
if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
|
|
2349
|
+
this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
|
|
2350
|
+
await this.kill(3000, status?.pid);
|
|
2351
|
+
} else {
|
|
2352
|
+
try {
|
|
2353
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2354
|
+
return;
|
|
2355
|
+
} catch {
|
|
2356
|
+
this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
|
|
2357
|
+
await this.kill(3000, status?.pid);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
1538
2361
|
this.launch();
|
|
1539
2362
|
await this.waitForReady();
|
|
1540
|
-
}
|
|
1541
|
-
this.releaseLock();
|
|
1542
|
-
}
|
|
2363
|
+
});
|
|
1543
2364
|
}
|
|
1544
2365
|
async isHealthy() {
|
|
1545
2366
|
try {
|
|
1546
|
-
const response = await
|
|
2367
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
1547
2368
|
return response.ok;
|
|
1548
2369
|
} catch {
|
|
1549
2370
|
return false;
|
|
@@ -1559,7 +2380,7 @@ class DaemonLifecycle {
|
|
|
1559
2380
|
}
|
|
1560
2381
|
async isReady() {
|
|
1561
2382
|
try {
|
|
1562
|
-
const response = await
|
|
2383
|
+
const response = await fetchWithTimeout(this.readyUrl);
|
|
1563
2384
|
return response.ok;
|
|
1564
2385
|
} catch {
|
|
1565
2386
|
return false;
|
|
@@ -1573,6 +2394,18 @@ class DaemonLifecycle {
|
|
|
1573
2394
|
}
|
|
1574
2395
|
throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
|
|
1575
2396
|
}
|
|
2397
|
+
async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
|
|
2398
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
2399
|
+
if (await this.isReady()) {
|
|
2400
|
+
const status = await this.fetchStatus();
|
|
2401
|
+
if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2406
|
+
}
|
|
2407
|
+
throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
|
|
2408
|
+
}
|
|
1576
2409
|
readStatus() {
|
|
1577
2410
|
try {
|
|
1578
2411
|
const raw = readFileSync(this.stateDir.statusFile, "utf-8");
|
|
@@ -1604,12 +2437,12 @@ class DaemonLifecycle {
|
|
|
1604
2437
|
}
|
|
1605
2438
|
removePidFile() {
|
|
1606
2439
|
try {
|
|
1607
|
-
|
|
2440
|
+
unlinkSync2(this.stateDir.pidFile);
|
|
1608
2441
|
} catch {}
|
|
1609
2442
|
}
|
|
1610
2443
|
removeStatusFile() {
|
|
1611
2444
|
try {
|
|
1612
|
-
|
|
2445
|
+
unlinkSync2(this.stateDir.statusFile);
|
|
1613
2446
|
} catch {}
|
|
1614
2447
|
}
|
|
1615
2448
|
markKilled() {
|
|
@@ -1619,11 +2452,11 @@ class DaemonLifecycle {
|
|
|
1619
2452
|
}
|
|
1620
2453
|
clearKilled() {
|
|
1621
2454
|
try {
|
|
1622
|
-
|
|
2455
|
+
unlinkSync2(this.stateDir.killedFile);
|
|
1623
2456
|
} catch {}
|
|
1624
2457
|
}
|
|
1625
2458
|
wasKilled() {
|
|
1626
|
-
return
|
|
2459
|
+
return existsSync3(this.stateDir.killedFile);
|
|
1627
2460
|
}
|
|
1628
2461
|
launch() {
|
|
1629
2462
|
this.stateDir.ensure();
|
|
@@ -1644,45 +2477,99 @@ class DaemonLifecycle {
|
|
|
1644
2477
|
this.log("Removing stale pid file");
|
|
1645
2478
|
this.removePidFile();
|
|
1646
2479
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
2480
|
+
async replaceUnhealthyDaemon(statusPid) {
|
|
2481
|
+
await this.withStartupLockStrict(async (locked) => {
|
|
2482
|
+
if (!locked) {
|
|
2483
|
+
this.log("Another process holds the startup lock, waiting for readiness+identity...");
|
|
2484
|
+
await this.waitForReadyAndOurs();
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (await this.isHealthy()) {
|
|
2488
|
+
const status = await this.fetchStatus();
|
|
2489
|
+
if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
|
|
2490
|
+
try {
|
|
2491
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2492
|
+
return;
|
|
2493
|
+
} catch {}
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
|
|
2497
|
+
await this.kill(3000, statusPid);
|
|
2498
|
+
this.launch();
|
|
2499
|
+
await this.waitForReady();
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
async withStartupLockStrict(fn) {
|
|
2503
|
+
const locked = this.acquireLockStrict();
|
|
2504
|
+
try {
|
|
2505
|
+
return await fn(locked);
|
|
2506
|
+
} finally {
|
|
2507
|
+
if (locked)
|
|
2508
|
+
this.releaseLock();
|
|
1651
2509
|
}
|
|
2510
|
+
}
|
|
2511
|
+
acquireLockStrict(reclaimed = false) {
|
|
1652
2512
|
this.stateDir.ensure();
|
|
2513
|
+
let fd = null;
|
|
1653
2514
|
try {
|
|
1654
|
-
|
|
2515
|
+
fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
1655
2516
|
writeFileSync(fd, `${process.pid}
|
|
1656
2517
|
`);
|
|
1657
2518
|
closeSync(fd);
|
|
1658
2519
|
return true;
|
|
1659
2520
|
} catch (err) {
|
|
2521
|
+
if (fd !== null && err.code !== "EEXIST") {
|
|
2522
|
+
try {
|
|
2523
|
+
closeSync(fd);
|
|
2524
|
+
} catch {}
|
|
2525
|
+
this.releaseLock();
|
|
2526
|
+
}
|
|
1660
2527
|
if (err.code === "EEXIST") {
|
|
2528
|
+
if (reclaimed)
|
|
2529
|
+
return false;
|
|
1661
2530
|
try {
|
|
1662
2531
|
const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
|
|
1663
2532
|
if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
|
|
1664
|
-
this.log(`Stale lock
|
|
2533
|
+
this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
|
|
2534
|
+
this.releaseLock();
|
|
2535
|
+
return this.acquireLockStrict(true);
|
|
2536
|
+
}
|
|
2537
|
+
if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
|
|
2538
|
+
this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
|
|
1665
2539
|
this.releaseLock();
|
|
1666
|
-
return this.
|
|
2540
|
+
return this.acquireLockStrict(true);
|
|
1667
2541
|
}
|
|
1668
2542
|
} catch {
|
|
1669
|
-
|
|
1670
|
-
this.releaseLock();
|
|
1671
|
-
return this.acquireLock(depth + 1);
|
|
2543
|
+
return false;
|
|
1672
2544
|
}
|
|
1673
2545
|
return false;
|
|
1674
2546
|
}
|
|
1675
|
-
this.log(`
|
|
1676
|
-
return
|
|
2547
|
+
this.log(`Could not acquire strict startup lock: ${err.message}`);
|
|
2548
|
+
return false;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
lockAgeMs() {
|
|
2552
|
+
try {
|
|
2553
|
+
return Date.now() - statSync2(this.stateDir.lockFile).mtimeMs;
|
|
2554
|
+
} catch {
|
|
2555
|
+
return 0;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
isAgentBridgeProcess(pid) {
|
|
2559
|
+
try {
|
|
2560
|
+
const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
2561
|
+
return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
|
|
2562
|
+
} catch {
|
|
2563
|
+
return false;
|
|
1677
2564
|
}
|
|
1678
2565
|
}
|
|
1679
2566
|
releaseLock() {
|
|
1680
2567
|
try {
|
|
1681
|
-
|
|
2568
|
+
unlinkSync2(this.stateDir.lockFile);
|
|
1682
2569
|
} catch {}
|
|
1683
2570
|
}
|
|
1684
|
-
async kill(gracefulTimeoutMs = 3000) {
|
|
1685
|
-
const pid = this.readPid();
|
|
2571
|
+
async kill(gracefulTimeoutMs = 3000, pidOverride) {
|
|
2572
|
+
const pid = pidOverride ?? this.readPid();
|
|
1686
2573
|
if (!pid) {
|
|
1687
2574
|
this.log("No daemon pid file found");
|
|
1688
2575
|
this.cleanup();
|
|
@@ -1723,8 +2610,10 @@ class DaemonLifecycle {
|
|
|
1723
2610
|
}
|
|
1724
2611
|
isDaemonProcess(pid) {
|
|
1725
2612
|
try {
|
|
1726
|
-
const cmd =
|
|
1727
|
-
|
|
2613
|
+
const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
2614
|
+
const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
|
|
2615
|
+
const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
|
|
2616
|
+
return hasDaemonEntry && hasAgentbridge;
|
|
1728
2617
|
} catch {
|
|
1729
2618
|
return false;
|
|
1730
2619
|
}
|
|
@@ -1732,7 +2621,15 @@ class DaemonLifecycle {
|
|
|
1732
2621
|
cleanup() {
|
|
1733
2622
|
this.removePidFile();
|
|
1734
2623
|
this.removeStatusFile();
|
|
1735
|
-
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
|
|
2627
|
+
const controller = new AbortController;
|
|
2628
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2629
|
+
try {
|
|
2630
|
+
return await fetch(url, { signal: controller.signal });
|
|
2631
|
+
} finally {
|
|
2632
|
+
clearTimeout(timer);
|
|
1736
2633
|
}
|
|
1737
2634
|
}
|
|
1738
2635
|
function isProcessAlive(pid) {
|
|
@@ -1745,8 +2642,25 @@ function isProcessAlive(pid) {
|
|
|
1745
2642
|
}
|
|
1746
2643
|
|
|
1747
2644
|
// src/config-service.ts
|
|
1748
|
-
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as
|
|
1749
|
-
import { join as
|
|
2645
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
|
|
2646
|
+
import { join as join3 } from "path";
|
|
2647
|
+
var DEFAULT_BUDGET_CONFIG = {
|
|
2648
|
+
enabled: true,
|
|
2649
|
+
pollSeconds: 60,
|
|
2650
|
+
pauseAt: 90,
|
|
2651
|
+
resumeBelow: 30,
|
|
2652
|
+
syncDriftPct: 10,
|
|
2653
|
+
parallel: {
|
|
2654
|
+
minRemainingPct: 60,
|
|
2655
|
+
timeWindowSec: 3600
|
|
2656
|
+
},
|
|
2657
|
+
codexTierControl: false,
|
|
2658
|
+
codexTiers: {
|
|
2659
|
+
full: null,
|
|
2660
|
+
balanced: { effort: "medium" },
|
|
2661
|
+
eco: { effort: "low" }
|
|
2662
|
+
}
|
|
2663
|
+
};
|
|
1750
2664
|
var DEFAULT_CONFIG = {
|
|
1751
2665
|
version: "1.0",
|
|
1752
2666
|
codex: {
|
|
@@ -1756,7 +2670,8 @@ var DEFAULT_CONFIG = {
|
|
|
1756
2670
|
turnCoordination: {
|
|
1757
2671
|
attentionWindowSeconds: 15
|
|
1758
2672
|
},
|
|
1759
|
-
idleShutdownSeconds: 30
|
|
2673
|
+
idleShutdownSeconds: 30,
|
|
2674
|
+
budget: DEFAULT_BUDGET_CONFIG
|
|
1760
2675
|
};
|
|
1761
2676
|
var CONFIG_DIR = ".agentbridge";
|
|
1762
2677
|
var CONFIG_FILE = "config.json";
|
|
@@ -1773,6 +2688,79 @@ function normalizeInteger(value, fallback) {
|
|
|
1773
2688
|
}
|
|
1774
2689
|
return fallback;
|
|
1775
2690
|
}
|
|
2691
|
+
function normalizeBoundedInteger(value, fallback, min, max) {
|
|
2692
|
+
const parsed = normalizeInteger(value, fallback);
|
|
2693
|
+
if (parsed < min || parsed > max)
|
|
2694
|
+
return fallback;
|
|
2695
|
+
return parsed;
|
|
2696
|
+
}
|
|
2697
|
+
function normalizeBoolean(value, fallback) {
|
|
2698
|
+
if (typeof value === "boolean")
|
|
2699
|
+
return value;
|
|
2700
|
+
if (value === "true" || value === "1")
|
|
2701
|
+
return true;
|
|
2702
|
+
if (value === "false" || value === "0")
|
|
2703
|
+
return false;
|
|
2704
|
+
return fallback;
|
|
2705
|
+
}
|
|
2706
|
+
function normalizeCodexOverride(raw) {
|
|
2707
|
+
if (!isRecord(raw))
|
|
2708
|
+
return null;
|
|
2709
|
+
const override = {};
|
|
2710
|
+
if (typeof raw.model === "string" && raw.model.trim() !== "")
|
|
2711
|
+
override.model = raw.model.trim();
|
|
2712
|
+
if (typeof raw.effort === "string" && raw.effort.trim() !== "")
|
|
2713
|
+
override.effort = raw.effort.trim();
|
|
2714
|
+
return Object.keys(override).length > 0 ? override : null;
|
|
2715
|
+
}
|
|
2716
|
+
function normalizeCodexTiers(raw) {
|
|
2717
|
+
const tiers = isRecord(raw) ? raw : {};
|
|
2718
|
+
return {
|
|
2719
|
+
full: normalizeCodexOverride(tiers.full),
|
|
2720
|
+
balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
|
|
2721
|
+
eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
function normalizeBudgetConfig(raw) {
|
|
2725
|
+
const budget = isRecord(raw) ? raw : {};
|
|
2726
|
+
const parallel = isRecord(budget.parallel) ? budget.parallel : {};
|
|
2727
|
+
const codexTiers = normalizeCodexTiers(budget.codexTiers);
|
|
2728
|
+
let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
|
|
2729
|
+
let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
|
|
2730
|
+
if (pauseAt <= resumeBelow) {
|
|
2731
|
+
pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
|
|
2732
|
+
resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
|
|
2733
|
+
}
|
|
2734
|
+
return {
|
|
2735
|
+
enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
|
|
2736
|
+
pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
|
|
2737
|
+
pauseAt,
|
|
2738
|
+
resumeBelow,
|
|
2739
|
+
syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
|
|
2740
|
+
parallel: {
|
|
2741
|
+
minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
|
|
2742
|
+
timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
|
|
2743
|
+
},
|
|
2744
|
+
codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
|
|
2745
|
+
codexTiers
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
function applyBudgetEnvOverrides(budget, env = process.env) {
|
|
2749
|
+
const overlay = {
|
|
2750
|
+
enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
|
|
2751
|
+
pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
|
|
2752
|
+
pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
|
|
2753
|
+
resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
|
|
2754
|
+
syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
|
|
2755
|
+
parallel: {
|
|
2756
|
+
minRemainingPct: env.AGENTBRIDGE_BUDGET_PARALLEL_MIN_REMAINING_PCT ?? budget.parallel.minRemainingPct,
|
|
2757
|
+
timeWindowSec: env.AGENTBRIDGE_BUDGET_PARALLEL_TIME_WINDOW_SEC ?? budget.parallel.timeWindowSec
|
|
2758
|
+
},
|
|
2759
|
+
codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
|
|
2760
|
+
codexTiers: budget.codexTiers
|
|
2761
|
+
};
|
|
2762
|
+
return normalizeBudgetConfig(overlay);
|
|
2763
|
+
}
|
|
1776
2764
|
function normalizeConfig(raw) {
|
|
1777
2765
|
if (!isRecord(raw))
|
|
1778
2766
|
return null;
|
|
@@ -1789,7 +2777,8 @@ function normalizeConfig(raw) {
|
|
|
1789
2777
|
turnCoordination: {
|
|
1790
2778
|
attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
|
|
1791
2779
|
},
|
|
1792
|
-
idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
|
|
2780
|
+
idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
|
|
2781
|
+
budget: normalizeBudgetConfig(config.budget)
|
|
1793
2782
|
};
|
|
1794
2783
|
}
|
|
1795
2784
|
|
|
@@ -1798,11 +2787,11 @@ class ConfigService {
|
|
|
1798
2787
|
configPath;
|
|
1799
2788
|
constructor(projectRoot) {
|
|
1800
2789
|
const root = projectRoot ?? process.cwd();
|
|
1801
|
-
this.configDir =
|
|
1802
|
-
this.configPath =
|
|
2790
|
+
this.configDir = join3(root, CONFIG_DIR);
|
|
2791
|
+
this.configPath = join3(this.configDir, CONFIG_FILE);
|
|
1803
2792
|
}
|
|
1804
2793
|
hasConfig() {
|
|
1805
|
-
return
|
|
2794
|
+
return existsSync4(this.configPath);
|
|
1806
2795
|
}
|
|
1807
2796
|
load() {
|
|
1808
2797
|
try {
|
|
@@ -1823,7 +2812,7 @@ class ConfigService {
|
|
|
1823
2812
|
initDefaults() {
|
|
1824
2813
|
this.ensureConfigDir();
|
|
1825
2814
|
const created = [];
|
|
1826
|
-
if (!
|
|
2815
|
+
if (!existsSync4(this.configPath)) {
|
|
1827
2816
|
this.save(DEFAULT_CONFIG);
|
|
1828
2817
|
created.push(this.configPath);
|
|
1829
2818
|
}
|
|
@@ -1833,125 +2822,1269 @@ class ConfigService {
|
|
|
1833
2822
|
return this.configPath;
|
|
1834
2823
|
}
|
|
1835
2824
|
ensureConfigDir() {
|
|
1836
|
-
if (!
|
|
1837
|
-
|
|
2825
|
+
if (!existsSync4(this.configDir)) {
|
|
2826
|
+
mkdirSync3(this.configDir, { recursive: true });
|
|
1838
2827
|
}
|
|
1839
2828
|
}
|
|
1840
2829
|
}
|
|
1841
2830
|
|
|
1842
|
-
// src/
|
|
1843
|
-
var
|
|
2831
|
+
// src/budget/types.ts
|
|
2832
|
+
var STALE_MAX_AGE_SEC = 600;
|
|
1844
2833
|
|
|
1845
|
-
// src/
|
|
1846
|
-
var
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
var
|
|
1851
|
-
var
|
|
1852
|
-
var
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
codex
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
if (
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2834
|
+
// src/budget/budget-state.ts
|
|
2835
|
+
var AGENT_LABEL = {
|
|
2836
|
+
claude: "Claude",
|
|
2837
|
+
codex: "Codex"
|
|
2838
|
+
};
|
|
2839
|
+
var CODEX_BALANCED_WARN_UTIL = 60;
|
|
2840
|
+
var CODEX_ECO_WARN_UTIL = 80;
|
|
2841
|
+
var CLAUDE_ADVICE_WARN_UTIL = 80;
|
|
2842
|
+
function pct(value) {
|
|
2843
|
+
return `${Math.round(value * 10) / 10}%`;
|
|
2844
|
+
}
|
|
2845
|
+
function formatEpoch(epoch) {
|
|
2846
|
+
if (!epoch || epoch <= 0)
|
|
2847
|
+
return "\u672A\u77E5";
|
|
2848
|
+
return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
|
|
2849
|
+
}
|
|
2850
|
+
function usageSummary(name, usage) {
|
|
2851
|
+
if (!usage)
|
|
2852
|
+
return `${AGENT_LABEL[name]} \u672A\u77E5`;
|
|
2853
|
+
return `${AGENT_LABEL[name]} gate=${pct(usage.gateUtil)} warn=${pct(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
|
|
2854
|
+
}
|
|
2855
|
+
function matchingGateReset(usage) {
|
|
2856
|
+
if (!usage)
|
|
2857
|
+
return 0;
|
|
2858
|
+
const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
|
|
2859
|
+
const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
|
|
2860
|
+
const candidates = matching.length > 0 ? matching : windows;
|
|
2861
|
+
if (candidates.length === 0)
|
|
2862
|
+
return 0;
|
|
2863
|
+
return Math.min(...candidates.map((window) => window.resetEpoch));
|
|
2864
|
+
}
|
|
2865
|
+
function resumeBlockingEpoch(usage, cfg, now) {
|
|
2866
|
+
if (!usage)
|
|
2867
|
+
return 0;
|
|
2868
|
+
if (usage.rateLimitedUntil > now)
|
|
2869
|
+
return usage.rateLimitedUntil;
|
|
2870
|
+
if (usage.gateUtil >= cfg.resumeBelow)
|
|
2871
|
+
return matchingGateReset(usage);
|
|
2872
|
+
return 0;
|
|
2873
|
+
}
|
|
2874
|
+
function resumeAfterEpoch(claude, codex, cfg, now) {
|
|
2875
|
+
const epochs = [
|
|
2876
|
+
resumeBlockingEpoch(claude, cfg, now),
|
|
2877
|
+
resumeBlockingEpoch(codex, cfg, now)
|
|
2878
|
+
].filter((epoch) => epoch > 0);
|
|
2879
|
+
if (epochs.length === 0)
|
|
2880
|
+
return null;
|
|
2881
|
+
return Math.max(...epochs);
|
|
2882
|
+
}
|
|
2883
|
+
function isDecisionGrade(usage, now) {
|
|
2884
|
+
if (!usage)
|
|
2885
|
+
return false;
|
|
2886
|
+
const freshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
|
|
2887
|
+
if (!freshWindow)
|
|
2888
|
+
return false;
|
|
2889
|
+
if (usage.fetchedAt > 0 && now - usage.fetchedAt > STALE_MAX_AGE_SEC)
|
|
2890
|
+
return false;
|
|
2891
|
+
return true;
|
|
2892
|
+
}
|
|
2893
|
+
function pauseTrigger(agent, usage, cfg, now) {
|
|
2894
|
+
if (!usage)
|
|
2895
|
+
return null;
|
|
2896
|
+
if (!isDecisionGrade(usage, now))
|
|
2897
|
+
return null;
|
|
2898
|
+
if (usage.gateUtil >= cfg.pauseAt) {
|
|
2899
|
+
return {
|
|
2900
|
+
agent,
|
|
2901
|
+
reason: `${AGENT_LABEL[agent]} gateUtil ${pct(usage.gateUtil)} \u2265 pauseAt ${pct(cfg.pauseAt)}`
|
|
2902
|
+
};
|
|
1908
2903
|
}
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
function driftFor(claude, codex, cfg) {
|
|
2907
|
+
if (!claude || !codex)
|
|
2908
|
+
return { pct: 0, heavier: null, lighter: null };
|
|
2909
|
+
const drift = Math.round((claude.warnUtil - codex.warnUtil) * 10) / 10;
|
|
2910
|
+
if (Math.abs(drift) <= cfg.syncDriftPct) {
|
|
2911
|
+
return { pct: drift, heavier: null, lighter: null };
|
|
1913
2912
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2913
|
+
return {
|
|
2914
|
+
pct: drift,
|
|
2915
|
+
heavier: drift > 0 ? "claude" : "codex",
|
|
2916
|
+
lighter: drift > 0 ? "codex" : "claude"
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
function parallelState(claude, codex, cfg, now) {
|
|
2920
|
+
if (!claude || !codex)
|
|
2921
|
+
return { recommended: false, reason: null };
|
|
2922
|
+
if (claude.remaining <= cfg.parallel.minRemainingPct || codex.remaining <= cfg.parallel.minRemainingPct) {
|
|
2923
|
+
return { recommended: false, reason: null };
|
|
2924
|
+
}
|
|
2925
|
+
const claudeReset = claude.fiveHour?.resetEpoch ?? 0;
|
|
2926
|
+
const codexReset = codex.fiveHour?.resetEpoch ?? 0;
|
|
2927
|
+
if (claudeReset <= now || codexReset <= now)
|
|
2928
|
+
return { recommended: false, reason: null };
|
|
2929
|
+
const nearestResetSec = Math.min(claudeReset - now, codexReset - now);
|
|
2930
|
+
if (nearestResetSec >= cfg.parallel.timeWindowSec)
|
|
2931
|
+
return { recommended: false, reason: null };
|
|
2932
|
+
const minutes = Math.ceil(nearestResetSec / 60);
|
|
2933
|
+
return {
|
|
2934
|
+
recommended: true,
|
|
2935
|
+
reason: `\u53CC\u65B9\u5269\u4F59\u989D\u5EA6\u5747\u9AD8\u4E8E ${pct(cfg.parallel.minRemainingPct)}\uFF0C\u6700\u8FD1 5h \u6876\u7EA6 ${minutes} \u5206\u949F\u540E\u91CD\u7F6E`
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
function renderBudgetInterventionDirective(claude, codex, side, reason, resumeEpoch, cfg) {
|
|
2939
|
+
const resumeText = `\u9884\u8BA1\u6062\u590D\u65F6\u95F4\uFF08\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09\uFF1A${formatEpoch(resumeEpoch)}\u3002`;
|
|
2940
|
+
if (side === "claude") {
|
|
2941
|
+
return [
|
|
2942
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u8FDB\u5165\u63A5\u529B\u6A21\u5F0F\u3002",
|
|
2943
|
+
`\u89E6\u53D1\u65B9\uFF1AClaude\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
|
|
2944
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2945
|
+
`\u6062\u590D\u53C2\u8003\uFF1AClaude gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
|
|
2946
|
+
"\u8BF7\u7ACB\u5373\u4EA4\u63A5\uFF1A\u628A\u5269\u4F59\u4EFB\u52A1\u6E05\u5355\u3001\u5173\u952E\u4E0A\u4E0B\u6587\u3001\u4EA7\u51FA\u4F4D\u7F6E\u3001\u9A8C\u6536\u6807\u51C6\u6253\u5305\u6210\u4E00\u6761 reply \u53D1\u7ED9 Codex\u3002",
|
|
2947
|
+
"\u4EA4\u63A5\u540E Claude \u505C\u624B\uFF1B\u8981\u6C42 Codex \u5728\u5355 turn \u5185\u5C3D\u91CF\u5B8C\u6210\uFF0C\u5C3E\u5DF4\u5199 checkpoint\uFF0C\u6682\u505C\u671F\u4E0D\u8981\u671F\u5F85 Claude \u56DE\u590D\u3002"
|
|
2948
|
+
].join(`
|
|
2949
|
+
`);
|
|
1930
2950
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2951
|
+
if (side === "codex") {
|
|
2952
|
+
return [
|
|
2953
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u6682\u505C\u59D4\u6D3E\u3002",
|
|
2954
|
+
`\u89E6\u53D1\u65B9\uFF1ACodex\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
|
|
2955
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2956
|
+
`\u6062\u590D\u53C2\u8003\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
|
|
2957
|
+
"\u8BF7 Claude \u5199 checkpoint\uFF0C\u5E76\u53EF solo \u63A8\u8FDB\u4E0D\u4F9D\u8D56 Codex \u7684\u72EC\u7ACB\u90E8\u5206\uFF1B\u4E0D\u8981\u7EE7\u7EED\u5411 Codex \u59D4\u6D3E\uFF0C\u6807\u6CE8\u6E05\u695A\u5206\u5DE5\u65AD\u70B9\u3002"
|
|
2958
|
+
].join(`
|
|
2959
|
+
`);
|
|
2960
|
+
}
|
|
2961
|
+
return [
|
|
2962
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8FDB\u5165\u8054\u5408\u6682\u505C\u3002",
|
|
2963
|
+
`\u89E6\u53D1\u65B9\uFF1A\u53CC\u65B9\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
|
|
2964
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2965
|
+
`\u6062\u590D\u6761\u4EF6\uFF1AClaude \u4E0E Codex \u7684 gateUtil \u90FD\u4F4E\u4E8E ${pct(cfg.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
|
|
2966
|
+
"\u8BF7\u6536\u5C3E\u5F53\u524D\u6B65\u3001\u5199 checkpoint\u3001\u505C\u6B62\u7EE7\u7EED\u59D4\u6D3E\uFF1Bpause \u671F\u95F4\u4E0D\u8981\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\u3002"
|
|
2967
|
+
].join(`
|
|
2968
|
+
`);
|
|
2969
|
+
}
|
|
2970
|
+
function balanceDirective(claude, codex, drift, parallel) {
|
|
2971
|
+
const heavier = drift.heavier ? AGENT_LABEL[drift.heavier] : "\u672A\u77E5";
|
|
2972
|
+
const lighter = drift.lighter ? AGENT_LABEL[drift.lighter] : "\u672A\u77E5";
|
|
2973
|
+
const lines = [
|
|
2974
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u68C0\u6D4B\u5230\u53CC\u65B9\u7528\u91CF\u6BD4\u4F8B\u6F02\u79FB\u3002",
|
|
2975
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2976
|
+
`${heavier} \u6BD4 ${lighter} \u9AD8 ${pct(Math.abs(drift.pct))}\uFF0C\u8BF7\u4F18\u5148\u628A\u540E\u7EED\u53EF\u62C6\u5206\u4EFB\u52A1\u5206\u7ED9 ${lighter}\uFF0C\u76F4\u5230 warnUtil \u63A5\u8FD1\u3002`
|
|
2977
|
+
];
|
|
2978
|
+
if (parallel.recommended && parallel.reason) {
|
|
2979
|
+
lines.push(`${parallel.reason}\uFF1B\u53EF\u8BA9 ${lighter} \u627F\u62C5\u66F4\u591A\u5E76\u884C\u5B50\u4EFB\u52A1\uFF0C\u517C\u987E\u5747\u8861\u4E0E\u63D0\u901F\u3002`);
|
|
2980
|
+
}
|
|
2981
|
+
return lines.join(`
|
|
2982
|
+
`);
|
|
2983
|
+
}
|
|
2984
|
+
function parallelDirective(claude, codex, parallel) {
|
|
2985
|
+
return [
|
|
2986
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u5F53\u524D\u989D\u5EA6\u5BCC\u4F59\u4E14\u4E34\u8FD1 5h \u7ED3\u7B97\uFF0C\u5EFA\u8BAE\u52A8\u6001\u5E76\u884C\u3002",
|
|
2987
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2988
|
+
`${parallel.reason}\uFF1B\u53EF\u4EE5\u62C6\u66F4\u591A\u72EC\u7ACB\u5B50\u4EFB\u52A1\u5E76\u884C\u63A8\u8FDB\u3002`
|
|
2989
|
+
].join(`
|
|
2990
|
+
`);
|
|
2991
|
+
}
|
|
2992
|
+
function codexTierFor(codex, now) {
|
|
2993
|
+
if (!codex || !isDecisionGrade(codex, now))
|
|
2994
|
+
return "full";
|
|
2995
|
+
if (codex.warnUtil >= CODEX_ECO_WARN_UTIL)
|
|
2996
|
+
return "eco";
|
|
2997
|
+
if (codex.warnUtil >= CODEX_BALANCED_WARN_UTIL)
|
|
2998
|
+
return "balanced";
|
|
2999
|
+
return "full";
|
|
3000
|
+
}
|
|
3001
|
+
function claudeAdviceFor(claude, now) {
|
|
3002
|
+
if (!claude || !isDecisionGrade(claude, now))
|
|
3003
|
+
return null;
|
|
3004
|
+
if (claude.warnUtil < CLAUDE_ADVICE_WARN_UTIL)
|
|
3005
|
+
return null;
|
|
3006
|
+
return `Claude warnUtil ${pct(claude.warnUtil)} \u5DF2\u504F\u9AD8\uFF1B\u540E\u7EED\u53EF\u62C6\u5206 subagent \u5EFA\u8BAE\u964D\u6863\u5230 haiku/sonnet\uFF0C\u5E76\u4FDD\u7559\u9AD8\u96BE\u5EA6\u4E3B\u7EBF\u7ED9\u5F53\u524D\u4F1A\u8BDD\u3002`;
|
|
3007
|
+
}
|
|
3008
|
+
function computeBudgetState(claude, codex, cfg, now) {
|
|
3009
|
+
const triggers = [
|
|
3010
|
+
pauseTrigger("claude", claude, cfg, now),
|
|
3011
|
+
pauseTrigger("codex", codex, cfg, now)
|
|
3012
|
+
].filter((trigger) => trigger !== null);
|
|
3013
|
+
const paused = triggers.length > 0;
|
|
3014
|
+
const drift = driftFor(claude, codex, cfg);
|
|
3015
|
+
const parallel = paused ? { recommended: false, reason: null } : parallelState(claude, codex, cfg, now);
|
|
3016
|
+
const resetEpochs = {
|
|
3017
|
+
claude: matchingGateReset(claude),
|
|
3018
|
+
codex: matchingGateReset(codex)
|
|
3019
|
+
};
|
|
3020
|
+
const filteredResumeAfterEpoch = paused ? resumeAfterEpoch(claude, codex, cfg, now) : null;
|
|
3021
|
+
let phase = "normal";
|
|
3022
|
+
if (paused)
|
|
3023
|
+
phase = "paused";
|
|
3024
|
+
else if (drift.heavier && drift.lighter)
|
|
3025
|
+
phase = "balance";
|
|
3026
|
+
else if (parallel.recommended)
|
|
3027
|
+
phase = "parallel";
|
|
3028
|
+
const pauseSide = !paused ? null : triggers.length > 1 ? "both" : triggers[0].agent;
|
|
3029
|
+
let directiveToClaude = null;
|
|
3030
|
+
if (phase === "paused") {
|
|
3031
|
+
directiveToClaude = renderBudgetInterventionDirective(claude, codex, pauseSide ?? "both", triggers.map((trigger) => trigger.reason).join("\uFF1B"), filteredResumeAfterEpoch, cfg);
|
|
3032
|
+
} else if (phase === "balance" && claude && codex) {
|
|
3033
|
+
directiveToClaude = balanceDirective(claude, codex, drift, parallel);
|
|
3034
|
+
} else if (phase === "parallel" && claude && codex) {
|
|
3035
|
+
directiveToClaude = parallelDirective(claude, codex, parallel);
|
|
3036
|
+
}
|
|
3037
|
+
return {
|
|
3038
|
+
phase,
|
|
3039
|
+
now,
|
|
3040
|
+
perAgent: { claude, codex },
|
|
3041
|
+
drift,
|
|
3042
|
+
pause: {
|
|
3043
|
+
active: paused,
|
|
3044
|
+
side: pauseSide,
|
|
3045
|
+
reason: paused ? triggers.map((trigger) => trigger.reason).join("\uFF1B") : null,
|
|
3046
|
+
resumeBelow: cfg.resumeBelow,
|
|
3047
|
+
resumeAfterEpoch: filteredResumeAfterEpoch,
|
|
3048
|
+
resetEpochs
|
|
3049
|
+
},
|
|
3050
|
+
parallel,
|
|
3051
|
+
effort: { claudeAdvice: claudeAdviceFor(claude, now), codexTier: codexTierFor(codex, now) },
|
|
3052
|
+
directiveToClaude
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// src/budget/budget-coordinator.ts
|
|
3057
|
+
var RESET_FINGERPRINT_BUCKET_SEC = 600;
|
|
3058
|
+
var AGENT_LABEL2 = {
|
|
3059
|
+
claude: "Claude",
|
|
3060
|
+
codex: "Codex"
|
|
3061
|
+
};
|
|
3062
|
+
function pct2(value) {
|
|
3063
|
+
return `${Math.round(value * 10) / 10}%`;
|
|
3064
|
+
}
|
|
3065
|
+
function usageLine(agent, usage) {
|
|
3066
|
+
if (!usage)
|
|
3067
|
+
return `${AGENT_LABEL2[agent]} \u672A\u77E5`;
|
|
3068
|
+
return `${AGENT_LABEL2[agent]} gate=${pct2(usage.gateUtil)} warn=${pct2(usage.warnUtil)}`;
|
|
3069
|
+
}
|
|
3070
|
+
function matchingGateReset2(usage) {
|
|
3071
|
+
if (!usage)
|
|
3072
|
+
return 0;
|
|
3073
|
+
const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
|
|
3074
|
+
const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
|
|
3075
|
+
const candidates = matching.length > 0 ? matching : windows;
|
|
3076
|
+
if (candidates.length === 0)
|
|
3077
|
+
return 0;
|
|
3078
|
+
return Math.min(...candidates.map((window) => window.resetEpoch));
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
class BudgetCoordinator {
|
|
3082
|
+
source;
|
|
3083
|
+
config;
|
|
3084
|
+
emit;
|
|
3085
|
+
onPauseChange;
|
|
3086
|
+
now;
|
|
3087
|
+
log;
|
|
3088
|
+
timer = null;
|
|
3089
|
+
running = false;
|
|
3090
|
+
activeSides = new Set;
|
|
3091
|
+
lastDirectiveFingerprint = null;
|
|
3092
|
+
latestSnapshot = null;
|
|
3093
|
+
pauseReason = null;
|
|
3094
|
+
pauseResumeAfterEpoch = null;
|
|
3095
|
+
pendingOverrideTier = null;
|
|
3096
|
+
pendingOverrides = null;
|
|
3097
|
+
lastAppliedTier = "full";
|
|
3098
|
+
missingFullMappingLogged = false;
|
|
3099
|
+
sequence = 0;
|
|
3100
|
+
constructor(options) {
|
|
3101
|
+
this.source = options.source;
|
|
3102
|
+
this.config = options.config;
|
|
3103
|
+
this.emit = options.emit;
|
|
3104
|
+
this.onPauseChange = options.onPauseChange;
|
|
3105
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
3106
|
+
this.log = options.log ?? (() => {});
|
|
3107
|
+
}
|
|
3108
|
+
async start() {
|
|
3109
|
+
if (this.running || !this.config.enabled)
|
|
3110
|
+
return;
|
|
3111
|
+
this.running = true;
|
|
3112
|
+
await this.pollOnce();
|
|
3113
|
+
if (this.running)
|
|
3114
|
+
this.scheduleNext();
|
|
3115
|
+
}
|
|
3116
|
+
stop() {
|
|
3117
|
+
this.running = false;
|
|
3118
|
+
if (this.timer) {
|
|
3119
|
+
clearTimeout(this.timer);
|
|
3120
|
+
this.timer = null;
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
isPaused() {
|
|
3124
|
+
return this.activeSides.size > 0;
|
|
3125
|
+
}
|
|
3126
|
+
isGateClosed() {
|
|
3127
|
+
return this.activeSides.has("codex");
|
|
3128
|
+
}
|
|
3129
|
+
getSnapshot() {
|
|
3130
|
+
return this.latestSnapshot;
|
|
3131
|
+
}
|
|
3132
|
+
getCodexTurnOverrides() {
|
|
3133
|
+
if (!this.tierControlEnabled())
|
|
3134
|
+
return null;
|
|
3135
|
+
return this.pendingOverrides ? { ...this.pendingOverrides } : null;
|
|
3136
|
+
}
|
|
3137
|
+
notifyOverridesDelivered() {
|
|
3138
|
+
if (!this.pendingOverrideTier)
|
|
3139
|
+
return;
|
|
3140
|
+
this.lastAppliedTier = this.pendingOverrideTier;
|
|
3141
|
+
this.pendingOverrideTier = null;
|
|
3142
|
+
this.pendingOverrides = null;
|
|
3143
|
+
}
|
|
3144
|
+
resetAppliedTier() {
|
|
3145
|
+
this.lastAppliedTier = "full";
|
|
3146
|
+
this.pendingOverrideTier = null;
|
|
3147
|
+
this.pendingOverrides = null;
|
|
3148
|
+
}
|
|
3149
|
+
scheduleNext() {
|
|
3150
|
+
if (!this.running)
|
|
3151
|
+
return;
|
|
3152
|
+
if (this.timer)
|
|
3153
|
+
clearTimeout(this.timer);
|
|
3154
|
+
const delayMs = Math.max(0, this.config.pollSeconds * 1000);
|
|
3155
|
+
this.timer = setTimeout(() => {
|
|
3156
|
+
this.timer = null;
|
|
3157
|
+
this.pollAndReschedule();
|
|
3158
|
+
}, delayMs);
|
|
3159
|
+
}
|
|
3160
|
+
async pollAndReschedule() {
|
|
3161
|
+
await this.pollOnce();
|
|
3162
|
+
if (this.running)
|
|
3163
|
+
this.scheduleNext();
|
|
3164
|
+
}
|
|
3165
|
+
async pollOnce() {
|
|
3166
|
+
let usage;
|
|
3167
|
+
try {
|
|
3168
|
+
usage = await this.source.fetchBoth();
|
|
3169
|
+
} catch (error) {
|
|
3170
|
+
this.log(`budget coordinator poll failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
if (!usage) {
|
|
3174
|
+
if (!this.isPaused())
|
|
3175
|
+
this.latestSnapshot = null;
|
|
3176
|
+
return;
|
|
3177
|
+
}
|
|
3178
|
+
if (!this.running) {
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
const state = computeBudgetState(usage.claude, usage.codex, this.config, this.now());
|
|
3182
|
+
this.updatePendingOverrides(state.effort.codexTier);
|
|
3183
|
+
this.applyState(state);
|
|
3184
|
+
this.latestSnapshot = this.toSnapshot(state);
|
|
3185
|
+
}
|
|
3186
|
+
applyState(state) {
|
|
3187
|
+
const previousSide = this.pauseSide();
|
|
3188
|
+
this.updateActiveSides(state);
|
|
3189
|
+
const currentSide = this.pauseSide();
|
|
3190
|
+
if (currentSide) {
|
|
3191
|
+
this.pauseReason = this.interventionReason(state);
|
|
3192
|
+
const nextResumeAfterEpoch = this.resumeAfterEpoch(state);
|
|
3193
|
+
this.pauseResumeAfterEpoch = previousSide === currentSide ? nextResumeAfterEpoch ?? this.pauseResumeAfterEpoch : nextResumeAfterEpoch;
|
|
3194
|
+
const fingerprint2 = previousSide === currentSide && this.activeSideProbeUncertain(state) && this.lastDirectiveFingerprint ? this.lastDirectiveFingerprint : this.directiveFingerprint(state, currentSide);
|
|
3195
|
+
if (!previousSide) {
|
|
3196
|
+
this.onPauseChange(true);
|
|
3197
|
+
}
|
|
3198
|
+
if (!previousSide || previousSide !== currentSide || fingerprint2 !== this.lastDirectiveFingerprint) {
|
|
3199
|
+
this.emitDirective(this.interventionPrefix(currentSide), this.interventionDirective(state, currentSide));
|
|
3200
|
+
}
|
|
3201
|
+
this.lastDirectiveFingerprint = fingerprint2;
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
if (previousSide) {
|
|
3205
|
+
this.pauseReason = null;
|
|
3206
|
+
this.pauseResumeAfterEpoch = null;
|
|
3207
|
+
this.lastDirectiveFingerprint = null;
|
|
3208
|
+
this.onPauseChange(false);
|
|
3209
|
+
this.emitDirective(this.recoveryPrefix(previousSide), this.recoveryDirective(state, previousSide));
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
if (!state.directiveToClaude) {
|
|
3216
|
+
this.lastDirectiveFingerprint = null;
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
const fingerprint = this.directiveFingerprint(state);
|
|
3220
|
+
if (fingerprint !== this.lastDirectiveFingerprint) {
|
|
3221
|
+
const prefix = state.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
|
|
3222
|
+
this.emitDirective(prefix, state.directiveToClaude);
|
|
3223
|
+
this.lastDirectiveFingerprint = fingerprint;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
updateActiveSides(state) {
|
|
3227
|
+
for (const agent of ["claude", "codex"]) {
|
|
3228
|
+
const usage = state.perAgent[agent];
|
|
3229
|
+
if (this.shouldEnter(usage, state.now)) {
|
|
3230
|
+
this.activeSides.add(agent);
|
|
3231
|
+
} else if (this.activeSides.has(agent) && this.canAgentResume(usage, state.now)) {
|
|
3232
|
+
this.activeSides.delete(agent);
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
shouldEnter(usage, now) {
|
|
3237
|
+
if (!isDecisionGrade(usage, now))
|
|
3238
|
+
return false;
|
|
3239
|
+
return usage.gateUtil >= this.config.pauseAt;
|
|
3240
|
+
}
|
|
3241
|
+
canAgentResume(usage, now) {
|
|
3242
|
+
if (!isDecisionGrade(usage, now))
|
|
3243
|
+
return false;
|
|
3244
|
+
if (usage.rateLimitedUntil > now)
|
|
3245
|
+
return false;
|
|
3246
|
+
return usage.gateUtil < this.config.resumeBelow;
|
|
3247
|
+
}
|
|
3248
|
+
resumeAfterEpoch(state) {
|
|
3249
|
+
const epochs = ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.resumeBlockingEpoch(state.perAgent[agent], state.now)).filter((epoch) => epoch > 0);
|
|
3250
|
+
if (epochs.length === 0)
|
|
3251
|
+
return null;
|
|
3252
|
+
return Math.max(...epochs);
|
|
3253
|
+
}
|
|
3254
|
+
resumeBlockingEpoch(usage, now) {
|
|
3255
|
+
if (!usage)
|
|
3256
|
+
return 0;
|
|
3257
|
+
if (usage.rateLimitedUntil > now)
|
|
3258
|
+
return usage.rateLimitedUntil;
|
|
3259
|
+
if (usage.gateUtil >= this.config.resumeBelow)
|
|
3260
|
+
return matchingGateReset2(usage);
|
|
3261
|
+
return 0;
|
|
3262
|
+
}
|
|
3263
|
+
tierControlEnabled() {
|
|
3264
|
+
if (!this.config.codexTierControl)
|
|
3265
|
+
return false;
|
|
3266
|
+
if (this.config.codexTiers.full)
|
|
3267
|
+
return true;
|
|
3268
|
+
if (!this.missingFullMappingLogged) {
|
|
3269
|
+
this.missingFullMappingLogged = true;
|
|
3270
|
+
this.log("Codex tier control disabled: budget.codexTiers.full restore mapping is missing");
|
|
3271
|
+
}
|
|
3272
|
+
return false;
|
|
3273
|
+
}
|
|
3274
|
+
updatePendingOverrides(tier) {
|
|
3275
|
+
if (!this.tierControlEnabled()) {
|
|
3276
|
+
this.pendingOverrideTier = null;
|
|
3277
|
+
this.pendingOverrides = null;
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
if (this.lastAppliedTier === tier) {
|
|
3281
|
+
this.pendingOverrideTier = null;
|
|
3282
|
+
this.pendingOverrides = null;
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
if (this.pendingOverrideTier === tier)
|
|
3286
|
+
return;
|
|
3287
|
+
const overrides = this.config.codexTiers[tier];
|
|
3288
|
+
if (!overrides) {
|
|
3289
|
+
this.pendingOverrideTier = null;
|
|
3290
|
+
this.pendingOverrides = null;
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
this.pendingOverrideTier = tier;
|
|
3294
|
+
this.pendingOverrides = { ...overrides };
|
|
3295
|
+
}
|
|
3296
|
+
directiveFingerprint(state, activeSide) {
|
|
3297
|
+
const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
|
|
3298
|
+
let reset = 0;
|
|
3299
|
+
if (activeSide === "claude") {
|
|
3300
|
+
reset = state.pause.resetEpochs.claude;
|
|
3301
|
+
} else if (activeSide === "codex") {
|
|
3302
|
+
reset = state.pause.resetEpochs.codex;
|
|
3303
|
+
} else if (activeSide === "both") {
|
|
3304
|
+
reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
|
|
3305
|
+
} else if (state.phase === "balance" && state.drift.lighter) {
|
|
3306
|
+
reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
|
|
3307
|
+
} else if (side === "claude") {
|
|
3308
|
+
reset = state.pause.resetEpochs.claude;
|
|
3309
|
+
} else if (side === "codex") {
|
|
3310
|
+
reset = state.pause.resetEpochs.codex;
|
|
3311
|
+
} else if (side === "both") {
|
|
3312
|
+
reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
|
|
3313
|
+
}
|
|
3314
|
+
return [
|
|
3315
|
+
activeSide ? "paused" : state.phase,
|
|
3316
|
+
state.drift.heavier ?? "none",
|
|
3317
|
+
side,
|
|
3318
|
+
Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
|
|
3319
|
+
].join("|");
|
|
3320
|
+
}
|
|
3321
|
+
emitDirective(prefix, content) {
|
|
3322
|
+
this.emit(`${prefix}_${this.sequence++}`, content);
|
|
3323
|
+
}
|
|
3324
|
+
pauseSide() {
|
|
3325
|
+
const claude = this.activeSides.has("claude");
|
|
3326
|
+
const codex = this.activeSides.has("codex");
|
|
3327
|
+
if (claude && codex)
|
|
3328
|
+
return "both";
|
|
3329
|
+
if (claude)
|
|
3330
|
+
return "claude";
|
|
3331
|
+
if (codex)
|
|
3332
|
+
return "codex";
|
|
3333
|
+
return null;
|
|
3334
|
+
}
|
|
3335
|
+
interventionPrefix(side) {
|
|
3336
|
+
return side === "claude" ? "system_budget_handoff" : "system_budget_pause";
|
|
3337
|
+
}
|
|
3338
|
+
recoveryPrefix(previousSide) {
|
|
3339
|
+
return previousSide === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
|
|
3340
|
+
}
|
|
3341
|
+
interventionDirective(state, side) {
|
|
3342
|
+
return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, this.pauseReason ?? "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", this.pauseResumeAfterEpoch, this.config);
|
|
3343
|
+
}
|
|
3344
|
+
interventionReason(state) {
|
|
3345
|
+
return ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.activeSideReason(agent, state.perAgent[agent], state.now)).join("\uFF1B");
|
|
3346
|
+
}
|
|
3347
|
+
activeSideProbeUncertain(state) {
|
|
3348
|
+
return ["claude", "codex"].some((agent) => {
|
|
3349
|
+
if (!this.activeSides.has(agent))
|
|
3350
|
+
return false;
|
|
3351
|
+
const usage = state.perAgent[agent];
|
|
3352
|
+
return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
activeSideReason(agent, usage, now) {
|
|
3356
|
+
if (!usage)
|
|
3357
|
+
return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
|
|
3358
|
+
if (usage.rateLimitedUntil > now) {
|
|
3359
|
+
return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${this.formatEpoch(usage.rateLimitedUntil)}`;
|
|
3360
|
+
}
|
|
3361
|
+
if (usage.gateUtil >= this.config.pauseAt) {
|
|
3362
|
+
return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(this.config.pauseAt)}`;
|
|
3363
|
+
}
|
|
3364
|
+
return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(this.config.resumeBelow)}`;
|
|
3365
|
+
}
|
|
3366
|
+
recoveryDirective(state, previousSide) {
|
|
3367
|
+
if (previousSide === "claude") {
|
|
3368
|
+
return [
|
|
3369
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u9884\u7B97\u5DF2\u6062\u590D\u3002",
|
|
3370
|
+
`${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
|
|
3371
|
+
`Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
|
|
3372
|
+
"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"
|
|
3373
|
+
].join(`
|
|
3374
|
+
`);
|
|
3375
|
+
}
|
|
3376
|
+
if (previousSide === "codex") {
|
|
3377
|
+
return [
|
|
3378
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
|
|
3379
|
+
`${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
|
|
3380
|
+
`\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
|
|
3381
|
+
"\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"
|
|
3382
|
+
].join(`
|
|
3383
|
+
`);
|
|
3384
|
+
}
|
|
3385
|
+
return [
|
|
3386
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8054\u5408\u6682\u505C\u89E3\u9664\u3002",
|
|
3387
|
+
`${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
|
|
3388
|
+
`\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1A\u53CC\u65B9 gateUtil \u5747\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
|
|
3389
|
+
"\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"
|
|
3390
|
+
].join(`
|
|
3391
|
+
`);
|
|
3392
|
+
}
|
|
3393
|
+
formatEpoch(epoch) {
|
|
3394
|
+
return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
|
|
3395
|
+
}
|
|
3396
|
+
toSnapshot(state) {
|
|
3397
|
+
const paused = this.isPaused();
|
|
3398
|
+
return {
|
|
3399
|
+
phase: paused ? "paused" : state.phase,
|
|
3400
|
+
updatedAt: state.now,
|
|
3401
|
+
claude: state.perAgent.claude,
|
|
3402
|
+
codex: state.perAgent.codex,
|
|
3403
|
+
driftPct: state.drift.pct,
|
|
3404
|
+
paused,
|
|
3405
|
+
gateClosed: this.isGateClosed(),
|
|
3406
|
+
pauseSide: this.pauseSide(),
|
|
3407
|
+
pauseReason: paused ? this.pauseReason ?? state.pause.reason : null,
|
|
3408
|
+
resumeAfterEpoch: paused ? this.pauseResumeAfterEpoch ?? state.pause.resumeAfterEpoch : null,
|
|
3409
|
+
parallelRecommended: paused ? false : state.parallel.recommended,
|
|
3410
|
+
codexTier: state.effort.codexTier,
|
|
3411
|
+
claudeAdvice: state.effort.claudeAdvice
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// src/budget/quota-source.ts
|
|
3417
|
+
import { execFile } from "child_process";
|
|
3418
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3419
|
+
import { homedir as homedir2 } from "os";
|
|
3420
|
+
import { basename, join as join4 } from "path";
|
|
3421
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
3422
|
+
var MAX_BUFFER = 1024 * 1024;
|
|
3423
|
+
function defaultRunner(command, args, options) {
|
|
3424
|
+
return new Promise((resolve, reject) => {
|
|
3425
|
+
execFile(command, args, {
|
|
3426
|
+
env: options.env,
|
|
3427
|
+
timeout: options.timeoutMs,
|
|
3428
|
+
maxBuffer: MAX_BUFFER
|
|
3429
|
+
}, (error, stdout) => {
|
|
3430
|
+
if (error && !stdout) {
|
|
3431
|
+
reject(error);
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
resolve({ stdout });
|
|
3435
|
+
});
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
function commandKind(command) {
|
|
3439
|
+
return basename(command) === "probe.mjs" ? "probe-mjs" : "budget-probe";
|
|
3440
|
+
}
|
|
3441
|
+
function argsFor(candidate, agent) {
|
|
3442
|
+
if (candidate.kind === "probe-mjs")
|
|
3443
|
+
return [agent, "probe"];
|
|
3444
|
+
return ["--agent", agent];
|
|
3445
|
+
}
|
|
3446
|
+
function asFiniteNumber(value) {
|
|
3447
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
3448
|
+
return value;
|
|
3449
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
3450
|
+
const parsed = Number(value);
|
|
3451
|
+
if (Number.isFinite(parsed))
|
|
3452
|
+
return parsed;
|
|
3453
|
+
}
|
|
3454
|
+
return null;
|
|
3455
|
+
}
|
|
3456
|
+
function numberOr(value, fallback) {
|
|
3457
|
+
return asFiniteNumber(value) ?? fallback;
|
|
3458
|
+
}
|
|
3459
|
+
function clamp(value, min, max) {
|
|
3460
|
+
return Math.min(max, Math.max(min, value));
|
|
3461
|
+
}
|
|
3462
|
+
function asRecord(value) {
|
|
3463
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
3464
|
+
}
|
|
3465
|
+
function normalizeBucket(value, fetchedAt) {
|
|
3466
|
+
const bucket = asRecord(value);
|
|
3467
|
+
if (!bucket)
|
|
3468
|
+
return null;
|
|
3469
|
+
const id = typeof bucket.id === "string" ? bucket.id : "";
|
|
3470
|
+
const util = asFiniteNumber(bucket.util);
|
|
3471
|
+
if (util === null)
|
|
3472
|
+
return null;
|
|
3473
|
+
const resetAfter = asFiniteNumber(bucket.reset_after_seconds ?? bucket.resetAfterSeconds);
|
|
3474
|
+
let resetEpoch = numberOr(bucket.reset_epoch ?? bucket.resetEpoch, 0);
|
|
3475
|
+
if (resetEpoch <= 0 && resetAfter !== null && fetchedAt > 0) {
|
|
3476
|
+
resetEpoch = fetchedAt + resetAfter;
|
|
3477
|
+
}
|
|
3478
|
+
return {
|
|
3479
|
+
id,
|
|
3480
|
+
util: clamp(util, 0, 100),
|
|
3481
|
+
resetEpoch: Math.max(0, resetEpoch),
|
|
3482
|
+
resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
|
|
3483
|
+
};
|
|
3484
|
+
}
|
|
3485
|
+
function normalizeTopLevelBucket(record, util, fetchedAt) {
|
|
3486
|
+
const resetAfter = asFiniteNumber(record.reset_after_seconds ?? record.resetAfterSeconds);
|
|
3487
|
+
let resetEpoch = numberOr(record.reset_epoch ?? record.resetEpoch, 0);
|
|
3488
|
+
if (resetEpoch <= 0 && resetAfter !== null && fetchedAt > 0) {
|
|
3489
|
+
resetEpoch = fetchedAt + resetAfter;
|
|
3490
|
+
}
|
|
3491
|
+
return {
|
|
3492
|
+
id: "top_level",
|
|
3493
|
+
util: clamp(util, 0, 100),
|
|
3494
|
+
resetEpoch: Math.max(0, resetEpoch),
|
|
3495
|
+
resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
function toWindow(bucket) {
|
|
3499
|
+
if (!bucket)
|
|
3500
|
+
return null;
|
|
3501
|
+
return { util: bucket.util, resetEpoch: bucket.resetEpoch };
|
|
3502
|
+
}
|
|
3503
|
+
function bucketSortKey(bucket) {
|
|
3504
|
+
if (bucket.resetAfterSeconds !== null)
|
|
3505
|
+
return bucket.resetAfterSeconds;
|
|
3506
|
+
if (bucket.resetEpoch > 0)
|
|
3507
|
+
return bucket.resetEpoch;
|
|
3508
|
+
return Number.POSITIVE_INFINITY;
|
|
3509
|
+
}
|
|
3510
|
+
function sameBucketWindow(bucket, window) {
|
|
3511
|
+
return !!window && bucket.util === window.util && bucket.resetEpoch === window.resetEpoch;
|
|
3512
|
+
}
|
|
3513
|
+
function pickHighestUtil(buckets) {
|
|
3514
|
+
if (buckets.length === 0)
|
|
3515
|
+
return null;
|
|
3516
|
+
return buckets.reduce((best, current) => {
|
|
3517
|
+
if (current.util > best.util)
|
|
3518
|
+
return current;
|
|
3519
|
+
if (current.util === best.util && bucketSortKey(current) < bucketSortKey(best))
|
|
3520
|
+
return current;
|
|
3521
|
+
return best;
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
function identifyWindows(buckets) {
|
|
3525
|
+
const fiveHourMatches = buckets.filter((bucket) => bucket.id.includes("five_hour") || bucket.id.includes("primary_window"));
|
|
3526
|
+
const weeklyMatches = buckets.filter((bucket) => bucket.id.includes("seven_day") || bucket.id.includes("secondary_window"));
|
|
3527
|
+
let fiveHour = toWindow(pickHighestUtil(fiveHourMatches));
|
|
3528
|
+
let weekly = toWindow(pickHighestUtil(weeklyMatches));
|
|
3529
|
+
const sorted = [...buckets].sort((a, b) => bucketSortKey(a) - bucketSortKey(b));
|
|
3530
|
+
if (!fiveHour && sorted.length > 0) {
|
|
3531
|
+
fiveHour = toWindow(sorted[0]);
|
|
3532
|
+
}
|
|
3533
|
+
if (!weekly && sorted.length > 1) {
|
|
3534
|
+
const latestDistinct = [...sorted].reverse().find((bucket) => !sameBucketWindow(bucket, fiveHour));
|
|
3535
|
+
weekly = toWindow(latestDistinct);
|
|
3536
|
+
}
|
|
3537
|
+
return { fiveHour, weekly };
|
|
3538
|
+
}
|
|
3539
|
+
function normalizeProbeResult(raw) {
|
|
3540
|
+
const record = asRecord(raw);
|
|
3541
|
+
if (!record)
|
|
3542
|
+
return null;
|
|
3543
|
+
const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
|
|
3544
|
+
const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
|
|
3545
|
+
const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
|
|
3546
|
+
const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
|
|
3547
|
+
const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
|
|
3548
|
+
const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
|
|
3549
|
+
if (buckets.length === 0 && hasFiniteUtil) {
|
|
3550
|
+
const topLevelBucket = normalizeTopLevelBucket(record, gateUtil, fetchedAt);
|
|
3551
|
+
if (topLevelBucket)
|
|
3552
|
+
buckets.push(topLevelBucket);
|
|
3553
|
+
}
|
|
3554
|
+
const rateLimitedUntil = Math.max(0, numberOr(record.rate_limited_until ?? record.rateLimitedUntil, 0));
|
|
3555
|
+
const ok = record.ok === true;
|
|
3556
|
+
if (!ok && rateLimitedUntil <= 0 && buckets.length === 0)
|
|
3557
|
+
return null;
|
|
3558
|
+
const { fiveHour, weekly } = identifyWindows(buckets);
|
|
3559
|
+
if (!fiveHour && !weekly && rateLimitedUntil === 0 && !hasFiniteUtil)
|
|
3560
|
+
return null;
|
|
3561
|
+
return {
|
|
3562
|
+
ok,
|
|
3563
|
+
stale: record.stale === true,
|
|
3564
|
+
gateUtil,
|
|
3565
|
+
warnUtil,
|
|
3566
|
+
fiveHour,
|
|
3567
|
+
weekly,
|
|
3568
|
+
remaining: clamp(100 - gateUtil, 0, 100),
|
|
3569
|
+
rateLimitedUntil,
|
|
3570
|
+
fetchedAt
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
function withTimeout(promise, timeoutMs) {
|
|
3574
|
+
let timer = null;
|
|
3575
|
+
const timeout = new Promise((_, reject) => {
|
|
3576
|
+
timer = setTimeout(() => reject(new Error(`budget probe timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
3577
|
+
});
|
|
3578
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
3579
|
+
if (timer)
|
|
3580
|
+
clearTimeout(timer);
|
|
3581
|
+
});
|
|
3582
|
+
}
|
|
3583
|
+
function isDegradedUsage(usage, now = Math.floor(Date.now() / 1000)) {
|
|
3584
|
+
if (usage.stale || !usage.ok)
|
|
3585
|
+
return true;
|
|
3586
|
+
const hasFreshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
|
|
3587
|
+
return !hasFreshWindow;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
class QuotaSource {
|
|
3591
|
+
env;
|
|
3592
|
+
homeDir;
|
|
3593
|
+
timeoutMs;
|
|
3594
|
+
runner;
|
|
3595
|
+
log;
|
|
3596
|
+
now;
|
|
3597
|
+
degradedLogged = new Map;
|
|
3598
|
+
constructor(options = {}) {
|
|
3599
|
+
this.env = options.env ?? process.env;
|
|
3600
|
+
this.homeDir = options.homeDir ?? homedir2();
|
|
3601
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3602
|
+
this.runner = options.runner ?? defaultRunner;
|
|
3603
|
+
this.log = options.log ?? (() => {});
|
|
3604
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
3605
|
+
}
|
|
3606
|
+
async fetchBoth() {
|
|
3607
|
+
const candidates = this.findProbeCandidates();
|
|
3608
|
+
if (candidates.length === 0)
|
|
3609
|
+
return null;
|
|
3610
|
+
const [claude, codex] = await Promise.all([
|
|
3611
|
+
this.fetchAgent(candidates, "claude"),
|
|
3612
|
+
this.fetchAgent(candidates, "codex")
|
|
3613
|
+
]);
|
|
3614
|
+
return { claude, codex };
|
|
3615
|
+
}
|
|
3616
|
+
findProbeCandidates() {
|
|
3617
|
+
const candidates = [];
|
|
3618
|
+
const seen = new Set;
|
|
3619
|
+
const add = (command, kind) => {
|
|
3620
|
+
const key = `${kind}:${command}`;
|
|
3621
|
+
if (seen.has(key))
|
|
3622
|
+
return;
|
|
3623
|
+
seen.add(key);
|
|
3624
|
+
candidates.push({ command, kind });
|
|
3625
|
+
};
|
|
3626
|
+
const explicit = this.env.AGENTBRIDGE_QUOTA_PROBE || this.env.BUDGET_PROBE;
|
|
3627
|
+
if (explicit && explicit.trim() !== "") {
|
|
3628
|
+
const command = explicit.trim();
|
|
3629
|
+
add(command, commandKind(command));
|
|
3630
|
+
return candidates;
|
|
3631
|
+
}
|
|
3632
|
+
const binDir = join4(this.homeDir, ".budget-guard/bin");
|
|
3633
|
+
const installedBudgetProbe = join4(binDir, "budget-probe");
|
|
3634
|
+
if (existsSync5(installedBudgetProbe))
|
|
3635
|
+
add(installedBudgetProbe, "budget-probe");
|
|
3636
|
+
const installedProbeMjs = join4(binDir, "probe.mjs");
|
|
3637
|
+
if (existsSync5(installedProbeMjs))
|
|
3638
|
+
add(installedProbeMjs, "probe-mjs");
|
|
3639
|
+
return candidates;
|
|
3640
|
+
}
|
|
3641
|
+
async fetchAgent(candidates, agent) {
|
|
3642
|
+
for (const candidate of candidates) {
|
|
3643
|
+
try {
|
|
3644
|
+
const result = await withTimeout(this.runner(candidate.command, argsFor(candidate, agent), {
|
|
3645
|
+
env: this.env,
|
|
3646
|
+
timeoutMs: this.timeoutMs,
|
|
3647
|
+
agent
|
|
3648
|
+
}), this.timeoutMs);
|
|
3649
|
+
const text = String(result.stdout).trim();
|
|
3650
|
+
if (!text)
|
|
3651
|
+
continue;
|
|
3652
|
+
let parsed;
|
|
3653
|
+
try {
|
|
3654
|
+
parsed = JSON.parse(text);
|
|
3655
|
+
} catch {
|
|
3656
|
+
this.log(`budget probe output unparseable for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
|
|
3657
|
+
continue;
|
|
3658
|
+
}
|
|
3659
|
+
const usage = normalizeProbeResult(parsed);
|
|
3660
|
+
if (usage) {
|
|
3661
|
+
this.noteDegradation(agent, usage);
|
|
3662
|
+
return usage;
|
|
3663
|
+
}
|
|
3664
|
+
this.log(`budget probe returned no usable data for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
|
|
3665
|
+
} catch (error) {
|
|
3666
|
+
this.log(`budget probe failed for ${agent}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
return null;
|
|
3670
|
+
}
|
|
3671
|
+
noteDegradation(agent, usage) {
|
|
3672
|
+
const degraded = isDegradedUsage(usage, this.now());
|
|
3673
|
+
const wasDegraded = this.degradedLogged.get(agent) === true;
|
|
3674
|
+
if (degraded && !wasDegraded) {
|
|
3675
|
+
const gate = usage.rateLimitedUntil > 0 ? `, rate-limit gated until ${usage.rateLimitedUntil}` : "";
|
|
3676
|
+
this.log(`budget probe degraded data accepted for ${agent} (stale=${usage.stale}, ok=${usage.ok}${gate}) \u2014 display only, decisions hold`);
|
|
3677
|
+
} else if (!degraded && wasDegraded) {
|
|
3678
|
+
this.log(`budget probe recovered to fresh data for ${agent}`);
|
|
3679
|
+
}
|
|
3680
|
+
this.degradedLogged.set(agent, degraded);
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
function createQuotaSource(options) {
|
|
3684
|
+
return new QuotaSource(options);
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
// src/reply-required-tracker.ts
|
|
3688
|
+
class ReplyRequiredTracker {
|
|
3689
|
+
armed = false;
|
|
3690
|
+
forwardedDuringTurn = false;
|
|
3691
|
+
get isArmed() {
|
|
3692
|
+
return this.armed;
|
|
3693
|
+
}
|
|
3694
|
+
arm() {
|
|
3695
|
+
this.armed = true;
|
|
3696
|
+
this.forwardedDuringTurn = false;
|
|
3697
|
+
}
|
|
3698
|
+
noteForwarded() {
|
|
3699
|
+
if (this.armed)
|
|
3700
|
+
this.forwardedDuringTurn = true;
|
|
3701
|
+
}
|
|
3702
|
+
consumeOnTurnComplete() {
|
|
3703
|
+
const warnReplyMissing = this.armed && !this.forwardedDuringTurn;
|
|
3704
|
+
this.reset();
|
|
3705
|
+
return { warnReplyMissing };
|
|
3706
|
+
}
|
|
3707
|
+
reset() {
|
|
3708
|
+
this.armed = false;
|
|
3709
|
+
this.forwardedDuringTurn = false;
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
// src/thread-state.ts
|
|
3714
|
+
import {
|
|
3715
|
+
existsSync as existsSync6,
|
|
3716
|
+
mkdirSync as mkdirSync4,
|
|
3717
|
+
readdirSync,
|
|
3718
|
+
readFileSync as readFileSync3,
|
|
3719
|
+
renameSync as renameSync2,
|
|
3720
|
+
writeFileSync as writeFileSync3
|
|
3721
|
+
} from "fs";
|
|
3722
|
+
import { homedir as homedir3 } from "os";
|
|
3723
|
+
import { basename as basename2, dirname as dirname2, join as join5 } from "path";
|
|
3724
|
+
function nowIso() {
|
|
3725
|
+
return new Date().toISOString();
|
|
3726
|
+
}
|
|
3727
|
+
function threadTag(identity) {
|
|
3728
|
+
const name = identity.pairName ?? identity.pairId ?? "manual";
|
|
3729
|
+
return `abg:${name}:${identity.cwd}`;
|
|
3730
|
+
}
|
|
3731
|
+
function codexHome(env = process.env) {
|
|
3732
|
+
return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join5(homedir3(), ".codex");
|
|
3733
|
+
}
|
|
3734
|
+
function atomicWriteJson(path, value) {
|
|
3735
|
+
mkdirSync4(dirname2(path), { recursive: true });
|
|
3736
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
3737
|
+
writeFileSync3(tmp, JSON.stringify(value, null, 2) + `
|
|
3738
|
+
`, "utf-8");
|
|
3739
|
+
renameSync2(tmp, path);
|
|
3740
|
+
}
|
|
3741
|
+
function readRawCurrentThread(stateDir) {
|
|
3742
|
+
try {
|
|
3743
|
+
const parsed = JSON.parse(readFileSync3(stateDir.currentThreadFile, "utf-8"));
|
|
3744
|
+
if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
|
|
3745
|
+
return parsed;
|
|
3746
|
+
}
|
|
3747
|
+
} catch {}
|
|
3748
|
+
return null;
|
|
3749
|
+
}
|
|
3750
|
+
function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
|
|
3751
|
+
const sessionsDir = join5(codexHome(env), "sessions");
|
|
3752
|
+
if (!threadId || !existsSync6(sessionsDir))
|
|
3753
|
+
return null;
|
|
3754
|
+
const exactName = `rollout-${threadId}.jsonl`;
|
|
3755
|
+
const stack = [sessionsDir];
|
|
3756
|
+
let visited = 0;
|
|
3757
|
+
while (stack.length > 0 && visited < maxEntries) {
|
|
3758
|
+
const dir = stack.pop();
|
|
3759
|
+
let entries;
|
|
3760
|
+
try {
|
|
3761
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
3762
|
+
} catch {
|
|
3763
|
+
continue;
|
|
3764
|
+
}
|
|
3765
|
+
for (const entry of entries) {
|
|
3766
|
+
visited++;
|
|
3767
|
+
const path = join5(dir, entry.name);
|
|
3768
|
+
if (entry.isDirectory()) {
|
|
3769
|
+
stack.push(path);
|
|
3770
|
+
continue;
|
|
3771
|
+
}
|
|
3772
|
+
if (!entry.isFile())
|
|
3773
|
+
continue;
|
|
3774
|
+
const name = basename2(entry.name);
|
|
3775
|
+
if (name === exactName || name.startsWith("rollout-") && name.endsWith(".jsonl") && name.includes(threadId)) {
|
|
3776
|
+
return path;
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
return null;
|
|
3781
|
+
}
|
|
3782
|
+
function writePendingCurrentThread(identity, threadId, reason) {
|
|
3783
|
+
const state = {
|
|
3784
|
+
version: 1,
|
|
3785
|
+
status: "pending",
|
|
3786
|
+
pairId: identity.pairId,
|
|
3787
|
+
pairName: identity.pairName,
|
|
3788
|
+
cwd: identity.cwd,
|
|
3789
|
+
threadId,
|
|
3790
|
+
updatedAt: nowIso(),
|
|
3791
|
+
reason,
|
|
3792
|
+
tag: threadTag(identity)
|
|
3793
|
+
};
|
|
3794
|
+
atomicWriteJson(identity.stateDir.currentThreadFile, state);
|
|
3795
|
+
return state;
|
|
3796
|
+
}
|
|
3797
|
+
function promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env = process.env) {
|
|
3798
|
+
const rolloutPath = findCodexRolloutFile(threadId, env);
|
|
3799
|
+
const state = {
|
|
3800
|
+
version: 1,
|
|
3801
|
+
status: rolloutPath ? "current" : "pending",
|
|
3802
|
+
pairId: identity.pairId,
|
|
3803
|
+
pairName: identity.pairName,
|
|
3804
|
+
cwd: identity.cwd,
|
|
3805
|
+
threadId,
|
|
3806
|
+
updatedAt: nowIso(),
|
|
3807
|
+
reason,
|
|
3808
|
+
tag: threadTag(identity),
|
|
3809
|
+
...rolloutPath ? { rolloutPath, rolloutVerifiedAt: nowIso() } : {}
|
|
3810
|
+
};
|
|
3811
|
+
atomicWriteJson(identity.stateDir.currentThreadFile, state);
|
|
3812
|
+
return state;
|
|
3813
|
+
}
|
|
3814
|
+
async function persistCurrentThreadWithRolloutRetry(identity, threadId, reason, options = {}) {
|
|
3815
|
+
const env = options.env ?? process.env;
|
|
3816
|
+
const attempts = options.attempts ?? 20;
|
|
3817
|
+
const delayMs = options.delayMs ?? 250;
|
|
3818
|
+
const shouldContinue = options.shouldContinue ?? (() => true);
|
|
3819
|
+
if (!shouldContinue())
|
|
3820
|
+
return null;
|
|
3821
|
+
writePendingCurrentThread(identity, threadId, reason);
|
|
3822
|
+
for (let attempt = 1;attempt <= attempts; attempt++) {
|
|
3823
|
+
if (!shouldContinue()) {
|
|
3824
|
+
options.log?.(`Abandoned current-thread persistence for ${threadId}: a newer thread became active`);
|
|
3825
|
+
return null;
|
|
3826
|
+
}
|
|
3827
|
+
const state = promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env);
|
|
3828
|
+
if (state.status === "current") {
|
|
3829
|
+
options.log?.(`Current Codex thread persisted: ${threadId} (${state.rolloutPath})`);
|
|
3830
|
+
return state;
|
|
3831
|
+
}
|
|
3832
|
+
if (attempt < attempts) {
|
|
3833
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
if (!shouldContinue())
|
|
3837
|
+
return null;
|
|
3838
|
+
options.log?.(`Current Codex thread left pending because no rollout file was found: ${threadId}`);
|
|
3839
|
+
return readRawCurrentThread(identity.stateDir) ?? writePendingCurrentThread(identity, threadId, reason);
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
// src/waiting-message.ts
|
|
3843
|
+
function formatWaitingForCodexTuiMessage(options) {
|
|
3844
|
+
const pairName = options.pairName ?? "unknown";
|
|
3845
|
+
const pairId = options.pairId ?? "manual";
|
|
3846
|
+
const slot = options.slot === null || options.slot === undefined ? "manual" : String(options.slot);
|
|
3847
|
+
return [
|
|
3848
|
+
"\u23F3 Waiting for Codex TUI to connect.",
|
|
3849
|
+
`Current pair: cwd=${options.cwd} pair=${pairName} pairId=${pairId} slot=${slot} proxy=${options.proxyUrl}`,
|
|
3850
|
+
"If Codex was started from a different cwd, it belongs to another pair and will not attach here.",
|
|
3851
|
+
"Run in another terminal:",
|
|
3852
|
+
options.attachCmd,
|
|
3853
|
+
"For diagnostics: abg doctor"
|
|
3854
|
+
].join(`
|
|
3855
|
+
`);
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
// src/pair-registry.ts
|
|
3859
|
+
var PAIR_BASE_PORT = 4500;
|
|
3860
|
+
var PAIR_SLOT_STRIDE = 10;
|
|
3861
|
+
var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
|
|
3862
|
+
|
|
3863
|
+
// src/liveness-probe.ts
|
|
3864
|
+
var OPEN = 1;
|
|
3865
|
+
async function probeLiveness(target, options) {
|
|
3866
|
+
const {
|
|
3867
|
+
timeoutMs,
|
|
3868
|
+
pollMs = 50,
|
|
3869
|
+
now = Date.now,
|
|
3870
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
3871
|
+
} = options;
|
|
3872
|
+
if (target.readyState !== OPEN)
|
|
3873
|
+
return false;
|
|
3874
|
+
const baseline = target.pongCount;
|
|
3875
|
+
try {
|
|
3876
|
+
target.ping();
|
|
3877
|
+
} catch {
|
|
3878
|
+
return false;
|
|
3879
|
+
}
|
|
3880
|
+
const deadline = now() + timeoutMs;
|
|
3881
|
+
while (now() < deadline) {
|
|
3882
|
+
if (target.pongCount > baseline)
|
|
3883
|
+
return true;
|
|
3884
|
+
if (target.readyState !== OPEN)
|
|
3885
|
+
return false;
|
|
3886
|
+
await sleep(pollMs);
|
|
3887
|
+
}
|
|
3888
|
+
return target.pongCount > baseline;
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
// src/daemon.ts
|
|
3892
|
+
var stateDir = new StateDirResolver;
|
|
3893
|
+
stateDir.ensure();
|
|
3894
|
+
var configService = new ConfigService;
|
|
3895
|
+
var config = configService.loadOrDefault();
|
|
3896
|
+
var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
|
|
3897
|
+
var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
|
|
3898
|
+
var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
|
|
3899
|
+
var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
|
|
3900
|
+
var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
|
|
3901
|
+
var CLAUDE_DISCONNECT_GRACE_MS = 5000;
|
|
3902
|
+
var MAX_BUFFERED_MESSAGES = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
|
|
3903
|
+
var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "filtered";
|
|
3904
|
+
var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
|
|
3905
|
+
var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
|
|
3906
|
+
var BOOTSTRAP_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_BOOTSTRAP_TIMEOUT_MS", 45000);
|
|
3907
|
+
var CODEX_BOOT_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_CODEX_BOOT_RETRIES", 2);
|
|
3908
|
+
var ALLOW_IDENTITYLESS_CLIENT = process.env.AGENTBRIDGE_COMPAT_IDENTITYLESS === "1";
|
|
3909
|
+
var BUDGET_CONFIG = applyBudgetEnvOverrides(config.budget);
|
|
3910
|
+
var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
|
|
3911
|
+
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
|
|
3912
|
+
var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
|
|
3913
|
+
var controlServer = null;
|
|
3914
|
+
var attachedClaude = null;
|
|
3915
|
+
var nextControlClientId = 0;
|
|
3916
|
+
var nextSystemMessageId = 0;
|
|
3917
|
+
var codexBootstrapped = false;
|
|
3918
|
+
var attentionWindowTimer = null;
|
|
3919
|
+
var inAttentionWindow = false;
|
|
3920
|
+
var replyTracker = new ReplyRequiredTracker;
|
|
3921
|
+
var shuttingDown = false;
|
|
3922
|
+
var bootDeadlineTimer = null;
|
|
3923
|
+
var idleShutdownTimer = null;
|
|
3924
|
+
var claudeDisconnectTimer = null;
|
|
3925
|
+
var lastAttachStatusSentTs = 0;
|
|
3926
|
+
var ATTACH_STATUS_COOLDOWN_MS = 30000;
|
|
3927
|
+
var LIVENESS_PROBE_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_LIVENESS_PROBE_TIMEOUT_MS", 3000, log);
|
|
3928
|
+
var LIVENESS_PROBE_POLL_MS = 50;
|
|
3929
|
+
var challengeInProgress = false;
|
|
3930
|
+
var bufferedMessages = [];
|
|
3931
|
+
var budgetCoordinator = null;
|
|
3932
|
+
var budgetStatusTimer = null;
|
|
3933
|
+
function ensureBudgetCoordinatorStarted() {
|
|
3934
|
+
if (!BUDGET_CONFIG.enabled)
|
|
3935
|
+
return;
|
|
3936
|
+
if (!budgetCoordinator) {
|
|
3937
|
+
log(`Budget coordinator config: pollSeconds=${BUDGET_CONFIG.pollSeconds} pauseAt=${BUDGET_CONFIG.pauseAt} ` + `resumeBelow=${BUDGET_CONFIG.resumeBelow} syncDriftPct=${BUDGET_CONFIG.syncDriftPct} ` + `parallel=${BUDGET_CONFIG.parallel.minRemainingPct}%/${BUDGET_CONFIG.parallel.timeWindowSec}s ` + `codexTierControl=${BUDGET_CONFIG.codexTierControl} ` + `codexTiersFull=${BUDGET_CONFIG.codexTiers.full ? "configured" : "missing"}`);
|
|
3938
|
+
budgetCoordinator = new BudgetCoordinator({
|
|
3939
|
+
source: createQuotaSource({ log }),
|
|
3940
|
+
config: BUDGET_CONFIG,
|
|
3941
|
+
emit: (id, content) => {
|
|
3942
|
+
emitToClaude(systemMessage(id, content));
|
|
3943
|
+
queueMicrotask(() => broadcastStatus());
|
|
3944
|
+
},
|
|
3945
|
+
onPauseChange: (paused) => {
|
|
3946
|
+
log(`Budget intervention ${paused ? "ACTIVE" : "CLEARED"} ` + `(gate ${budgetCoordinator?.isGateClosed() ? "CLOSED" : "OPEN"})`);
|
|
3947
|
+
queueMicrotask(() => broadcastStatus());
|
|
3948
|
+
},
|
|
3949
|
+
log
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
budgetCoordinator.start();
|
|
3953
|
+
if (!budgetStatusTimer) {
|
|
3954
|
+
budgetStatusTimer = setInterval(() => broadcastStatus(), BUDGET_CONFIG.pollSeconds * 1000);
|
|
3955
|
+
budgetStatusTimer.unref?.();
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
function stopBudgetCoordinator() {
|
|
3959
|
+
budgetCoordinator?.stop();
|
|
3960
|
+
if (budgetStatusTimer) {
|
|
3961
|
+
clearInterval(budgetStatusTimer);
|
|
3962
|
+
budgetStatusTimer = null;
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
function budgetPauseGateError() {
|
|
3966
|
+
const snapshot = budgetCoordinator?.getSnapshot() ?? null;
|
|
3967
|
+
const reason = snapshot?.pauseReason ?? "Codex \u4FA7\u989D\u5EA6\u63A5\u8FD1\u8017\u5C3D";
|
|
3968
|
+
const resumeAt = snapshot?.resumeAfterEpoch ? new Date(snapshot.resumeAfterEpoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z") : null;
|
|
3969
|
+
const sideHint = snapshot?.pauseSide === "both" ? "\u53CC\u4FA7\u989D\u5EA6\u5747\u5DF2\u8017\u5C3D\uFF0C\u8BF7\u5199 checkpoint \u7B49\u5F85\u5237\u65B0" : "\u4F60\u53EF\u7EE7\u7EED solo \u63A8\u8FDB\u53EF\u72EC\u7ACB\u90E8\u5206\uFF0C\u5E76\u5199 checkpoint \u6807\u6CE8\u5206\u5DE5\u65AD\u70B9";
|
|
3970
|
+
return `\u9884\u7B97\u6682\u505C\uFF08\u95F8\u95E8\u5173\u95ED\uFF09\uFF0C\u5DF2\u62D2\u7EDD\u8F6C\u53D1\uFF1A${reason}\u3002` + `Codex \u4FA7 gateUtil \u4F4E\u4E8E ${BUDGET_CONFIG.resumeBelow}% \u540E\u95F8\u95E8\u81EA\u52A8\u653E\u5F00` + (resumeAt ? `\uFF08\u9884\u8BA1\u6062\u590D ${resumeAt}\uFF0C\u4EE5\u5B9E\u6D4B\u4E3A\u51C6\uFF1B\u63D0\u524D\u5237\u65B0\u4F1A\u66F4\u65E9\u89E3\u9664\uFF09` : "") + `\u3002\u6536\u5230 RESUME \u901A\u77E5\u524D\u8BF7\u52FF\u91CD\u8BD5\u5411 Codex \u53D1\u9001 reply\uFF1B${sideHint}\u3002`;
|
|
3971
|
+
}
|
|
3972
|
+
var tuiConnectionState = new TuiConnectionState({
|
|
3973
|
+
disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
|
|
3974
|
+
log,
|
|
3975
|
+
onDisconnectPersisted: (connId) => {
|
|
3976
|
+
emitToClaude(systemMessage("system_tui_disconnected", `\u26A0\uFE0F Codex TUI disconnected (conn #${connId}). Codex is still running in the background \u2014 reconnect the TUI to resume.`));
|
|
3977
|
+
},
|
|
3978
|
+
onReconnectAfterNotice: (connId) => {
|
|
3979
|
+
emitToClaude(systemMessage("system_tui_reconnected", `\u2705 Codex TUI reconnected (conn #${connId}). Bridge restored, communication can continue.`));
|
|
3980
|
+
}
|
|
3981
|
+
});
|
|
3982
|
+
var statusBuffer = new StatusBuffer((summary) => emitToClaude(summary));
|
|
3983
|
+
function tryWriteStatusFile(reason) {
|
|
3984
|
+
try {
|
|
3985
|
+
writeStatusFile();
|
|
3986
|
+
} catch (err) {
|
|
3987
|
+
log(`status file write failed (${reason}): ${err?.message ?? err}`);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
codex.on("turnPhaseChanged", ({ phase, previous }) => {
|
|
3991
|
+
log(`Codex turn phase: ${previous} \u2192 ${phase}`);
|
|
3992
|
+
tryWriteStatusFile(`turnPhase:${phase}`);
|
|
3993
|
+
broadcastStatus();
|
|
3994
|
+
});
|
|
3995
|
+
codex.on("steerFailed", (reason) => {
|
|
3996
|
+
log(`Steer rejected by app-server: ${reason}`);
|
|
3997
|
+
emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 wait for it to finish, or resend as a normal reply.`));
|
|
3998
|
+
});
|
|
3999
|
+
codex.on("steerAccepted", () => {
|
|
4000
|
+
log("Steer accepted by app-server");
|
|
4001
|
+
});
|
|
4002
|
+
codex.on("turnStarted", () => {
|
|
4003
|
+
log("Codex turn started");
|
|
4004
|
+
emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
|
|
4005
|
+
});
|
|
4006
|
+
codex.on("agentMessage", (msg) => {
|
|
4007
|
+
if (msg.source !== "codex")
|
|
4008
|
+
return;
|
|
4009
|
+
const result = classifyMessage(msg.content, FILTER_MODE);
|
|
4010
|
+
if (replyTracker.isArmed) {
|
|
4011
|
+
log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
|
|
4012
|
+
replyTracker.noteForwarded();
|
|
4013
|
+
if (statusBuffer.size > 0) {
|
|
4014
|
+
statusBuffer.flush("reply-required message arrived");
|
|
4015
|
+
}
|
|
4016
|
+
emitToClaude(msg);
|
|
4017
|
+
return;
|
|
4018
|
+
}
|
|
4019
|
+
if (inAttentionWindow && result.marker === "status") {
|
|
4020
|
+
log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
|
|
4021
|
+
statusBuffer.add(msg);
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
4024
|
+
log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
|
|
4025
|
+
switch (result.action) {
|
|
4026
|
+
case "forward":
|
|
4027
|
+
if (result.marker === "important" && statusBuffer.size > 0) {
|
|
4028
|
+
statusBuffer.flush("important message arrived");
|
|
4029
|
+
}
|
|
4030
|
+
emitToClaude(msg);
|
|
4031
|
+
if (result.marker === "important") {
|
|
4032
|
+
startAttentionWindow();
|
|
4033
|
+
}
|
|
4034
|
+
break;
|
|
4035
|
+
case "buffer":
|
|
4036
|
+
statusBuffer.add(msg);
|
|
4037
|
+
break;
|
|
4038
|
+
case "drop":
|
|
4039
|
+
break;
|
|
4040
|
+
}
|
|
4041
|
+
});
|
|
4042
|
+
codex.on("turnCompleted", () => {
|
|
4043
|
+
log("Codex turn completed");
|
|
4044
|
+
statusBuffer.flush("turn completed");
|
|
4045
|
+
const { warnReplyMissing } = replyTracker.consumeOnTurnComplete();
|
|
4046
|
+
if (warnReplyMissing) {
|
|
4047
|
+
log("\u26A0\uFE0F Reply was required but Codex did not send any agentMessage");
|
|
4048
|
+
emitToClaude(systemMessage("system_reply_missing", "\u26A0\uFE0F Codex completed the turn without sending a reply (require_reply was set). Codex may not have generated an agentMessage. You may want to retry or rephrase."));
|
|
1938
4049
|
}
|
|
1939
|
-
replyRequired = false;
|
|
1940
|
-
replyReceivedDuringTurn = false;
|
|
1941
4050
|
emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
|
|
1942
4051
|
startAttentionWindow();
|
|
1943
|
-
|
|
1944
|
-
|
|
4052
|
+
});
|
|
4053
|
+
codex.on("turnAborted", (reason) => {
|
|
4054
|
+
log(`Codex turn aborted (${reason}) \u2014 clearing reply-required state`);
|
|
4055
|
+
const replyWasRequired = replyTracker.isArmed;
|
|
4056
|
+
replyTracker.reset();
|
|
4057
|
+
const notice = buildTurnAbortedNotice(reason, replyWasRequired);
|
|
4058
|
+
if (notice) {
|
|
4059
|
+
emitToClaude(systemMessage("system_turn_aborted", notice));
|
|
1945
4060
|
}
|
|
1946
4061
|
});
|
|
4062
|
+
codex.on("turnStalled", (event) => {
|
|
4063
|
+
log(`Codex turn stalled (${event.turnId}, inactivity ${event.inactivityMs}ms)`);
|
|
4064
|
+
emitToClaude(systemMessage("system_turn_stalled", `\u26A0\uFE0F Codex has been silent for ${event.inactivityMs}ms while a turn is still in progress. AgentBridge is keeping the turn busy and will not send a fake completion; wait for Codex to finish or reconnect the TUI if it is stuck.`));
|
|
4065
|
+
});
|
|
1947
4066
|
codex.on("ready", (threadId) => {
|
|
1948
4067
|
tuiConnectionState.markBridgeReady();
|
|
1949
4068
|
log(`Codex ready \u2014 thread ${threadId}`);
|
|
1950
4069
|
log("Bridge fully operational");
|
|
1951
4070
|
emitToClaude(systemMessage("system_ready", currentReadyMessage()));
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
4071
|
+
budgetCoordinator?.resetAppliedTier();
|
|
4072
|
+
ensureBudgetCoordinatorStarted();
|
|
4073
|
+
});
|
|
4074
|
+
codex.on("threadChanged", (event) => {
|
|
4075
|
+
budgetCoordinator?.resetAppliedTier();
|
|
4076
|
+
broadcastStatus();
|
|
4077
|
+
persistCurrentThreadWithRolloutRetry({
|
|
4078
|
+
stateDir,
|
|
4079
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4080
|
+
pairName: process.env.AGENTBRIDGE_PAIR_NAME,
|
|
4081
|
+
cwd: process.cwd()
|
|
4082
|
+
}, event.threadId, event.reason, {
|
|
4083
|
+
log,
|
|
4084
|
+
shouldContinue: () => codex.activeThreadId === event.threadId
|
|
4085
|
+
}).catch((err) => {
|
|
4086
|
+
log(`Failed to persist current thread ${event.threadId}: ${err?.message ?? err}`);
|
|
4087
|
+
});
|
|
1955
4088
|
});
|
|
1956
4089
|
codex.on("tuiConnected", (connId) => {
|
|
1957
4090
|
tuiConnectionState.handleTuiConnected(connId);
|
|
@@ -1971,13 +4104,13 @@ codex.on("error", (err) => {
|
|
|
1971
4104
|
codex.on("exit", (code) => {
|
|
1972
4105
|
log(`Codex process exited (code ${code})`);
|
|
1973
4106
|
codexBootstrapped = false;
|
|
4107
|
+
replyTracker.reset();
|
|
1974
4108
|
statusBuffer.flush("codex exited");
|
|
1975
4109
|
tuiConnectionState.handleCodexExit();
|
|
1976
4110
|
clearPendingClaudeDisconnect("Codex process exited");
|
|
1977
|
-
|
|
1978
|
-
claudeOfflineNoticeShown = false;
|
|
1979
|
-
emitToClaude(systemMessage("system_codex_exit", `\u26A0\uFE0F Codex app-server exited (code ${code ?? "unknown"}). AgentBridge daemon is still running, but the Codex side needs to be restarted.`));
|
|
4111
|
+
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.`));
|
|
1980
4112
|
broadcastStatus();
|
|
4113
|
+
armBootDeadline();
|
|
1981
4114
|
});
|
|
1982
4115
|
function startControlServer() {
|
|
1983
4116
|
controlServer = Bun.serve({
|
|
@@ -1991,7 +4124,7 @@ function startControlServer() {
|
|
|
1991
4124
|
if (url.pathname === "/readyz") {
|
|
1992
4125
|
return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
|
|
1993
4126
|
}
|
|
1994
|
-
if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false } })) {
|
|
4127
|
+
if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
|
|
1995
4128
|
return;
|
|
1996
4129
|
}
|
|
1997
4130
|
return new Response("AgentBridge daemon");
|
|
@@ -2001,6 +4134,8 @@ function startControlServer() {
|
|
|
2001
4134
|
sendPings: true,
|
|
2002
4135
|
open: (ws) => {
|
|
2003
4136
|
ws.data.clientId = ++nextControlClientId;
|
|
4137
|
+
ws.data.lastPongAt = Date.now();
|
|
4138
|
+
ws.data.pendingBackpressure = [];
|
|
2004
4139
|
log(`Frontend socket opened (#${ws.data.clientId})`);
|
|
2005
4140
|
},
|
|
2006
4141
|
close: (ws, code, reason) => {
|
|
@@ -2011,6 +4146,18 @@ function startControlServer() {
|
|
|
2011
4146
|
},
|
|
2012
4147
|
message: (ws, raw) => {
|
|
2013
4148
|
handleControlMessage(ws, raw);
|
|
4149
|
+
},
|
|
4150
|
+
pong: (ws) => {
|
|
4151
|
+
ws.data.lastPongAt = Date.now();
|
|
4152
|
+
ws.data.pongCount++;
|
|
4153
|
+
},
|
|
4154
|
+
drain: (ws) => {
|
|
4155
|
+
if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
|
|
4156
|
+
ws.data.pendingBackpressure = [];
|
|
4157
|
+
}
|
|
4158
|
+
if (ws === attachedClaude && bufferedMessages.length > 0) {
|
|
4159
|
+
flushBufferedMessages(ws);
|
|
4160
|
+
}
|
|
2014
4161
|
}
|
|
2015
4162
|
}
|
|
2016
4163
|
});
|
|
@@ -2026,7 +4173,20 @@ function handleControlMessage(ws, raw) {
|
|
|
2026
4173
|
}
|
|
2027
4174
|
switch (message.type) {
|
|
2028
4175
|
case "claude_connect":
|
|
2029
|
-
|
|
4176
|
+
const admission = validateClaudeClientIdentity({
|
|
4177
|
+
expectedPairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4178
|
+
daemonCwd: process.cwd(),
|
|
4179
|
+
identity: message.identity,
|
|
4180
|
+
allowIdentityless: ALLOW_IDENTITYLESS_CLIENT
|
|
4181
|
+
});
|
|
4182
|
+
if (!admission.ok) {
|
|
4183
|
+
log(`Rejecting Claude frontend #${ws.data.clientId}: ${admission.reason}`);
|
|
4184
|
+
ws.close(admission.closeCode, admission.reason);
|
|
4185
|
+
return;
|
|
4186
|
+
}
|
|
4187
|
+
attachClaude(ws, message.identity).catch((err) => {
|
|
4188
|
+
log(`attachClaude threw for #${ws.data.clientId}: ${err?.message ?? err}`);
|
|
4189
|
+
});
|
|
2030
4190
|
return;
|
|
2031
4191
|
case "claude_disconnect":
|
|
2032
4192
|
detachClaude(ws, "frontend requested disconnect");
|
|
@@ -2034,6 +4194,11 @@ function handleControlMessage(ws, raw) {
|
|
|
2034
4194
|
case "status":
|
|
2035
4195
|
sendStatus(ws);
|
|
2036
4196
|
return;
|
|
4197
|
+
case "probe_incumbent":
|
|
4198
|
+
handleProbeIncumbent(ws).catch((err) => {
|
|
4199
|
+
log(`handleProbeIncumbent threw for #${ws.data.clientId}: ${err?.message ?? err}`);
|
|
4200
|
+
});
|
|
4201
|
+
return;
|
|
2037
4202
|
case "claude_to_codex": {
|
|
2038
4203
|
if (message.message.source !== "claude") {
|
|
2039
4204
|
sendProtocolMessage(ws, {
|
|
@@ -2053,20 +4218,54 @@ function handleControlMessage(ws, raw) {
|
|
|
2053
4218
|
});
|
|
2054
4219
|
return;
|
|
2055
4220
|
}
|
|
4221
|
+
if (budgetCoordinator?.isGateClosed()) {
|
|
4222
|
+
const reason = budgetPauseGateError();
|
|
4223
|
+
log(`Injection rejected by budget pause gate`);
|
|
4224
|
+
sendProtocolMessage(ws, {
|
|
4225
|
+
type: "claude_to_codex_result",
|
|
4226
|
+
requestId: message.requestId,
|
|
4227
|
+
success: false,
|
|
4228
|
+
error: reason
|
|
4229
|
+
});
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
2056
4232
|
const requireReply = !!message.requireReply;
|
|
2057
|
-
let
|
|
2058
|
-
|
|
2059
|
-
` + BRIDGE_CONTRACT_REMINDER;
|
|
4233
|
+
let contentToSend = message.message.content;
|
|
2060
4234
|
if (requireReply) {
|
|
2061
|
-
|
|
2062
|
-
replyRequired = true;
|
|
2063
|
-
replyReceivedDuringTurn = false;
|
|
2064
|
-
log(`Reply required flag set for this message`);
|
|
4235
|
+
contentToSend += REPLY_REQUIRED_INSTRUCTION;
|
|
2065
4236
|
}
|
|
2066
4237
|
log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
|
|
2067
|
-
const
|
|
4238
|
+
const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
|
|
4239
|
+
if (codex.turnInProgress && message.onBusy === "steer") {
|
|
4240
|
+
if (requireReply) {
|
|
4241
|
+
sendProtocolMessage(ws, {
|
|
4242
|
+
type: "claude_to_codex_result",
|
|
4243
|
+
requestId: message.requestId,
|
|
4244
|
+
success: false,
|
|
4245
|
+
error: 'require_reply is not supported together with on_busy="steer" yet. Send the steer without require_reply, or wait for the turn to finish.'
|
|
4246
|
+
});
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
const steerContent = `[STEER from Claude]
|
|
4250
|
+
` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
|
|
4251
|
+
|
|
4252
|
+
` + message.message.content;
|
|
4253
|
+
const steered = codex.steerMessage(steerContent);
|
|
4254
|
+
log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
|
|
4255
|
+
if (steered) {
|
|
4256
|
+
clearAttentionWindow();
|
|
4257
|
+
}
|
|
4258
|
+
sendProtocolMessage(ws, {
|
|
4259
|
+
type: "claude_to_codex_result",
|
|
4260
|
+
requestId: message.requestId,
|
|
4261
|
+
success: steered,
|
|
4262
|
+
error: steered ? undefined : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply."
|
|
4263
|
+
});
|
|
4264
|
+
return;
|
|
4265
|
+
}
|
|
4266
|
+
const injected = codex.injectMessage(contentToSend, tierOverrides);
|
|
2068
4267
|
if (!injected) {
|
|
2069
|
-
const reason = codex.turnInProgress ?
|
|
4268
|
+
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.";
|
|
2070
4269
|
log(`Injection rejected: ${reason}`);
|
|
2071
4270
|
sendProtocolMessage(ws, {
|
|
2072
4271
|
type: "claude_to_codex_result",
|
|
@@ -2076,6 +4275,13 @@ function handleControlMessage(ws, raw) {
|
|
|
2076
4275
|
});
|
|
2077
4276
|
return;
|
|
2078
4277
|
}
|
|
4278
|
+
if (tierOverrides) {
|
|
4279
|
+
budgetCoordinator?.notifyOverridesDelivered();
|
|
4280
|
+
}
|
|
4281
|
+
if (requireReply) {
|
|
4282
|
+
replyTracker.arm();
|
|
4283
|
+
log(`Reply required flag set for this message`);
|
|
4284
|
+
}
|
|
2079
4285
|
clearAttentionWindow();
|
|
2080
4286
|
sendProtocolMessage(ws, {
|
|
2081
4287
|
type: "claude_to_codex_result",
|
|
@@ -2086,24 +4292,57 @@ function handleControlMessage(ws, raw) {
|
|
|
2086
4292
|
}
|
|
2087
4293
|
}
|
|
2088
4294
|
}
|
|
2089
|
-
function attachClaude(ws) {
|
|
4295
|
+
async function attachClaude(ws, identity) {
|
|
4296
|
+
const occupant = attachedClaude;
|
|
4297
|
+
if (occupant && occupant !== ws && occupant.readyState !== WebSocket.CLOSED) {
|
|
4298
|
+
const msSincePong = Date.now() - occupant.data.lastPongAt;
|
|
4299
|
+
log(`Claude frontend contest: new=#${ws.data.clientId}, incumbent=#${occupant.data.clientId} ` + `(readyState=${occupant.readyState}, msSincePong=${msSincePong})`);
|
|
4300
|
+
if (challengeInProgress) {
|
|
4301
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 another liveness probe already in flight`);
|
|
4302
|
+
ws.close(CLOSE_CODE_PROBE_IN_PROGRESS, "liveness probe in progress, retry shortly");
|
|
4303
|
+
return;
|
|
4304
|
+
}
|
|
4305
|
+
challengeInProgress = true;
|
|
4306
|
+
let incumbentAlive = false;
|
|
4307
|
+
try {
|
|
4308
|
+
incumbentAlive = await probeLiveness2(occupant, LIVENESS_PROBE_TIMEOUT_MS);
|
|
4309
|
+
} finally {
|
|
4310
|
+
challengeInProgress = false;
|
|
4311
|
+
}
|
|
4312
|
+
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
|
4313
|
+
log(`Contestant #${ws.data.clientId} disappeared during probe \u2014 aborting`);
|
|
4314
|
+
if (!incumbentAlive) {
|
|
4315
|
+
evictStale(occupant, "contestant gone but probe still failed");
|
|
4316
|
+
}
|
|
4317
|
+
return;
|
|
4318
|
+
}
|
|
4319
|
+
if (incumbentAlive) {
|
|
4320
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 incumbent #${occupant.data.clientId} responded to liveness probe`);
|
|
4321
|
+
ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
evictStale(occupant, `liveness probe timed out after ${LIVENESS_PROBE_TIMEOUT_MS}ms`);
|
|
4325
|
+
}
|
|
2090
4326
|
if (attachedClaude && attachedClaude !== ws && attachedClaude.readyState !== WebSocket.CLOSED) {
|
|
2091
|
-
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014
|
|
4327
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 slot re-acquired by #${attachedClaude.data.clientId} after probe`);
|
|
2092
4328
|
ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
|
|
2093
4329
|
return;
|
|
2094
4330
|
}
|
|
2095
4331
|
clearPendingClaudeDisconnect("Claude frontend attached");
|
|
4332
|
+
ws.data.identity = identity;
|
|
2096
4333
|
attachedClaude = ws;
|
|
2097
4334
|
ws.data.attached = true;
|
|
2098
4335
|
cancelIdleShutdown();
|
|
2099
|
-
log(`Claude frontend attached (#${ws.data.clientId})`);
|
|
4336
|
+
log(`Claude frontend attached (#${ws.data.clientId}, pair=${identity?.pairId ?? "<none>"}, cwd=${identity?.cwd ?? "<unknown>"})`);
|
|
4337
|
+
const hadBacklog = bufferedMessages.length > 0;
|
|
4338
|
+
if (hadBacklog) {
|
|
4339
|
+
flushBufferedMessages(ws);
|
|
4340
|
+
}
|
|
2100
4341
|
statusBuffer.flush("claude reconnected");
|
|
2101
4342
|
sendStatus(ws);
|
|
2102
4343
|
const now = Date.now();
|
|
2103
4344
|
const isRapidReattach = now - lastAttachStatusSentTs < ATTACH_STATUS_COOLDOWN_MS;
|
|
2104
|
-
if (
|
|
2105
|
-
flushBufferedMessages(ws);
|
|
2106
|
-
} else if (!isRapidReattach) {
|
|
4345
|
+
if (!hadBacklog && !isRapidReattach) {
|
|
2107
4346
|
if (tuiConnectionState.canReply()) {
|
|
2108
4347
|
sendBridgeMessage(ws, systemMessage("system_ready", currentReadyMessage()));
|
|
2109
4348
|
} else if (codexBootstrapped) {
|
|
@@ -2111,9 +4350,6 @@ function attachClaude(ws) {
|
|
|
2111
4350
|
}
|
|
2112
4351
|
}
|
|
2113
4352
|
lastAttachStatusSentTs = now;
|
|
2114
|
-
if (tuiConnectionState.canReply() && shouldNotifyCodexClaudeOnline()) {
|
|
2115
|
-
notifyCodexClaudeOnline();
|
|
2116
|
-
}
|
|
2117
4353
|
}
|
|
2118
4354
|
function detachClaude(ws, reason) {
|
|
2119
4355
|
if (attachedClaude !== ws)
|
|
@@ -2121,19 +4357,75 @@ function detachClaude(ws, reason) {
|
|
|
2121
4357
|
attachedClaude = null;
|
|
2122
4358
|
ws.data.attached = false;
|
|
2123
4359
|
log(`Claude frontend detached (#${ws.data.clientId}, ${reason})`);
|
|
4360
|
+
if (ws.data.pendingBackpressure.length > 0) {
|
|
4361
|
+
bufferedMessages.unshift(...ws.data.pendingBackpressure);
|
|
4362
|
+
log(`Re-buffered ${ws.data.pendingBackpressure.length} backpressured message(s) for redelivery on reconnect`);
|
|
4363
|
+
ws.data.pendingBackpressure = [];
|
|
4364
|
+
if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
|
|
4365
|
+
const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
|
|
4366
|
+
bufferedMessages.splice(0, dropped);
|
|
4367
|
+
log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
2124
4370
|
scheduleClaudeDisconnectNotification(ws.data.clientId);
|
|
2125
4371
|
scheduleIdleShutdown();
|
|
2126
4372
|
}
|
|
4373
|
+
async function handleProbeIncumbent(ws) {
|
|
4374
|
+
const occupant = attachedClaude;
|
|
4375
|
+
log(`probe_incumbent from #${ws.data.clientId}: occupant=${occupant ? "#" + occupant.data.clientId : "none"} readyState=${occupant?.readyState}`);
|
|
4376
|
+
if (!occupant || occupant === ws || occupant.readyState !== WebSocket.OPEN) {
|
|
4377
|
+
sendProtocolMessage(ws, { type: "incumbent_status", connected: false, alive: false });
|
|
4378
|
+
return;
|
|
4379
|
+
}
|
|
4380
|
+
if (challengeInProgress) {
|
|
4381
|
+
sendProtocolMessage(ws, { type: "incumbent_status", connected: true, alive: true });
|
|
4382
|
+
return;
|
|
4383
|
+
}
|
|
4384
|
+
const alive = await probeLiveness2(occupant, LIVENESS_PROBE_TIMEOUT_MS);
|
|
4385
|
+
const stillConnected = attachedClaude === occupant && occupant.readyState === WebSocket.OPEN;
|
|
4386
|
+
log(`probe_incumbent reply to #${ws.data.clientId}: connected=${stillConnected} alive=${stillConnected && alive}`);
|
|
4387
|
+
sendProtocolMessage(ws, {
|
|
4388
|
+
type: "incumbent_status",
|
|
4389
|
+
connected: stillConnected,
|
|
4390
|
+
alive: stillConnected && alive
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
async function probeLiveness2(ws, timeoutMs) {
|
|
4394
|
+
return probeLiveness({
|
|
4395
|
+
get readyState() {
|
|
4396
|
+
return ws.readyState;
|
|
4397
|
+
},
|
|
4398
|
+
get pongCount() {
|
|
4399
|
+
return ws.data.pongCount;
|
|
4400
|
+
},
|
|
4401
|
+
ping: () => {
|
|
4402
|
+
ws.ping();
|
|
4403
|
+
}
|
|
4404
|
+
}, { timeoutMs, pollMs: LIVENESS_PROBE_POLL_MS });
|
|
4405
|
+
}
|
|
4406
|
+
function evictStale(ws, reason) {
|
|
4407
|
+
log(`Evicting stale Claude frontend #${ws.data.clientId}: ${reason}`);
|
|
4408
|
+
if (attachedClaude === ws) {
|
|
4409
|
+
detachClaude(ws, `evicted: ${reason}`);
|
|
4410
|
+
}
|
|
4411
|
+
try {
|
|
4412
|
+
ws.close(CLOSE_CODE_EVICTED_STALE, "stale frontend evicted by newer session");
|
|
4413
|
+
} catch (err) {
|
|
4414
|
+
log(`Evict close threw on #${ws.data.clientId}: ${err.message}`);
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
2127
4417
|
function startAttentionWindow() {
|
|
2128
4418
|
clearAttentionWindow();
|
|
2129
4419
|
inAttentionWindow = true;
|
|
2130
4420
|
statusBuffer.pause();
|
|
2131
4421
|
log(`Attention window started (${ATTENTION_WINDOW_MS}ms)`);
|
|
4422
|
+
tryWriteStatusFile("attentionWindowStarted");
|
|
2132
4423
|
attentionWindowTimer = setTimeout(() => {
|
|
2133
4424
|
attentionWindowTimer = null;
|
|
2134
4425
|
inAttentionWindow = false;
|
|
2135
4426
|
statusBuffer.resume();
|
|
2136
4427
|
log("Attention window ended");
|
|
4428
|
+
tryWriteStatusFile("attentionWindowEnded");
|
|
2137
4429
|
}, ATTENTION_WINDOW_MS);
|
|
2138
4430
|
}
|
|
2139
4431
|
function clearAttentionWindow() {
|
|
@@ -2143,8 +4435,9 @@ function clearAttentionWindow() {
|
|
|
2143
4435
|
}
|
|
2144
4436
|
if (inAttentionWindow) {
|
|
2145
4437
|
statusBuffer.resume();
|
|
4438
|
+
inAttentionWindow = false;
|
|
4439
|
+
tryWriteStatusFile("attentionWindowCleared");
|
|
2146
4440
|
}
|
|
2147
|
-
inAttentionWindow = false;
|
|
2148
4441
|
}
|
|
2149
4442
|
function scheduleIdleShutdown() {
|
|
2150
4443
|
cancelIdleShutdown();
|
|
@@ -2185,17 +4478,6 @@ function scheduleClaudeDisconnectNotification(clientId) {
|
|
|
2185
4478
|
log(`Skipping Claude disconnect notification for client #${clientId} because Claude already reconnected`);
|
|
2186
4479
|
return;
|
|
2187
4480
|
}
|
|
2188
|
-
if (!tuiConnectionState.canReply()) {
|
|
2189
|
-
log(`Suppressing Claude disconnect notification for client #${clientId} because Codex cannot reply`);
|
|
2190
|
-
return;
|
|
2191
|
-
}
|
|
2192
|
-
if (!claudeOnlineNoticeSent) {
|
|
2193
|
-
log(`Suppressing Claude disconnect notification for client #${clientId} because Claude was never announced online`);
|
|
2194
|
-
return;
|
|
2195
|
-
}
|
|
2196
|
-
codex.injectMessage("\u26A0\uFE0F Claude Code went offline. AgentBridge is still running in the background; it will reconnect automatically when Claude reopens.");
|
|
2197
|
-
claudeOnlineNoticeSent = false;
|
|
2198
|
-
claudeOfflineNoticeShown = true;
|
|
2199
4481
|
log(`Claude disconnect persisted past grace window (client #${clientId})`);
|
|
2200
4482
|
}, CLAUDE_DISCONNECT_GRACE_MS);
|
|
2201
4483
|
}
|
|
@@ -2215,10 +4497,18 @@ function emitToClaude(message) {
|
|
|
2215
4497
|
function trySendBridgeMessage(ws, message) {
|
|
2216
4498
|
try {
|
|
2217
4499
|
const result = ws.send(JSON.stringify({ type: "codex_to_claude", message }));
|
|
2218
|
-
if (typeof result === "number" && result
|
|
2219
|
-
log(
|
|
4500
|
+
if (typeof result === "number" && result === 0) {
|
|
4501
|
+
log("Bridge message send returned 0 (dropped)");
|
|
2220
4502
|
return false;
|
|
2221
4503
|
}
|
|
4504
|
+
if (typeof result === "number" && result === -1) {
|
|
4505
|
+
ws.data.pendingBackpressure.push(message);
|
|
4506
|
+
if (ws.data.pendingBackpressure.length > MAX_BUFFERED_MESSAGES) {
|
|
4507
|
+
const dropped = ws.data.pendingBackpressure.length - MAX_BUFFERED_MESSAGES;
|
|
4508
|
+
ws.data.pendingBackpressure.splice(0, dropped);
|
|
4509
|
+
log(`Backpressure overflow: dropped ${dropped} oldest tracked message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
2222
4512
|
return true;
|
|
2223
4513
|
} catch (err) {
|
|
2224
4514
|
log(`Failed to send bridge message: ${err.message}`);
|
|
@@ -2227,10 +4517,9 @@ function trySendBridgeMessage(ws, message) {
|
|
|
2227
4517
|
}
|
|
2228
4518
|
function flushBufferedMessages(ws) {
|
|
2229
4519
|
const messages = bufferedMessages.splice(0, bufferedMessages.length);
|
|
2230
|
-
for (
|
|
2231
|
-
if (!trySendBridgeMessage(ws,
|
|
2232
|
-
const
|
|
2233
|
-
const remaining = messages.slice(failedIndex);
|
|
4520
|
+
for (let i = 0;i < messages.length; i++) {
|
|
4521
|
+
if (!trySendBridgeMessage(ws, messages[i])) {
|
|
4522
|
+
const remaining = messages.slice(i);
|
|
2234
4523
|
bufferedMessages.unshift(...remaining);
|
|
2235
4524
|
log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
|
|
2236
4525
|
return;
|
|
@@ -2250,7 +4539,10 @@ function broadcastStatus() {
|
|
|
2250
4539
|
}
|
|
2251
4540
|
function sendProtocolMessage(ws, message) {
|
|
2252
4541
|
try {
|
|
2253
|
-
ws.send(JSON.stringify(message));
|
|
4542
|
+
const result = ws.send(JSON.stringify(message));
|
|
4543
|
+
if (typeof result === "number" && result === 0) {
|
|
4544
|
+
log(`Control message dropped (socket closed): type=${message.type}`);
|
|
4545
|
+
}
|
|
2254
4546
|
} catch (err) {
|
|
2255
4547
|
log(`Failed to send control message: ${err.message}`);
|
|
2256
4548
|
}
|
|
@@ -2261,41 +4553,36 @@ function currentStatus() {
|
|
|
2261
4553
|
bridgeReady: tuiConnectionState.canReply(),
|
|
2262
4554
|
tuiConnected: snapshot.tuiConnected,
|
|
2263
4555
|
threadId: codex.activeThreadId,
|
|
2264
|
-
queuedMessageCount: bufferedMessages.length + statusBuffer.size,
|
|
4556
|
+
queuedMessageCount: bufferedMessages.length + statusBuffer.size + (attachedClaude?.data.pendingBackpressure.length ?? 0),
|
|
2265
4557
|
proxyUrl: codex.proxyUrl,
|
|
2266
4558
|
appServerUrl: codex.appServerUrl,
|
|
2267
|
-
pid: process.pid
|
|
4559
|
+
pid: process.pid,
|
|
4560
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4561
|
+
cwd: process.cwd(),
|
|
4562
|
+
stateDir: stateDir.dir,
|
|
4563
|
+
build: daemonStatusBuildInfo(),
|
|
4564
|
+
budget: budgetCoordinator?.getSnapshot() ?? undefined,
|
|
4565
|
+
turnInProgress: codex.turnInProgress,
|
|
4566
|
+
turnPhase: codex.turnPhase,
|
|
4567
|
+
attentionWindowActive: inAttentionWindow
|
|
2268
4568
|
};
|
|
2269
4569
|
}
|
|
2270
4570
|
function currentWaitingMessage() {
|
|
2271
|
-
|
|
2272
|
-
|
|
4571
|
+
const pairId = process.env.AGENTBRIDGE_PAIR_ID ?? null;
|
|
4572
|
+
const offset = CODEX_PROXY_PORT - PAIR_BASE_PORT - 1;
|
|
4573
|
+
const slot = pairId !== null && offset >= 0 && offset % PAIR_SLOT_STRIDE === 0 ? offset / PAIR_SLOT_STRIDE : null;
|
|
4574
|
+
return formatWaitingForCodexTuiMessage({
|
|
4575
|
+
attachCmd,
|
|
4576
|
+
cwd: process.cwd(),
|
|
4577
|
+
pairId,
|
|
4578
|
+
pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
|
|
4579
|
+
slot,
|
|
4580
|
+
proxyUrl: codex.proxyUrl
|
|
4581
|
+
});
|
|
2273
4582
|
}
|
|
2274
4583
|
function currentReadyMessage() {
|
|
2275
4584
|
return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
|
|
2276
4585
|
}
|
|
2277
|
-
function notifyCodexClaudeOnline() {
|
|
2278
|
-
const message = !codexCollaborationKickoffSent ? [
|
|
2279
|
-
"\uD83E\uDD1D Claude Code has connected via AgentBridge.",
|
|
2280
|
-
"You are now in a multi-agent collaboration session.",
|
|
2281
|
-
"When you receive a complex task, propose a division of labor to Claude.",
|
|
2282
|
-
"Claude can send you messages \u2014 they will appear as injected user messages.",
|
|
2283
|
-
"Respond naturally and Claude will receive your output via AgentBridge."
|
|
2284
|
-
].join(`
|
|
2285
|
-
`) : "\u2705 AgentBridge connected to Claude Code.";
|
|
2286
|
-
const delivered = codex.injectMessage(message);
|
|
2287
|
-
if (!delivered) {
|
|
2288
|
-
log("Deferred Claude-online notice to Codex \u2014 will retry after current turn completes");
|
|
2289
|
-
return false;
|
|
2290
|
-
}
|
|
2291
|
-
claudeOnlineNoticeSent = true;
|
|
2292
|
-
claudeOfflineNoticeShown = false;
|
|
2293
|
-
codexCollaborationKickoffSent = true;
|
|
2294
|
-
return true;
|
|
2295
|
-
}
|
|
2296
|
-
function shouldNotifyCodexClaudeOnline() {
|
|
2297
|
-
return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;
|
|
2298
|
-
}
|
|
2299
4586
|
function systemMessage(idPrefix, content) {
|
|
2300
4587
|
return {
|
|
2301
4588
|
id: `${idPrefix}_${++nextSystemMessageId}`,
|
|
@@ -2315,34 +4602,82 @@ function writeStatusFile() {
|
|
|
2315
4602
|
proxyUrl: codex.proxyUrl,
|
|
2316
4603
|
appServerUrl: codex.appServerUrl,
|
|
2317
4604
|
controlPort: CONTROL_PORT,
|
|
2318
|
-
pid: process.pid
|
|
4605
|
+
pid: process.pid,
|
|
4606
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4607
|
+
cwd: process.cwd(),
|
|
4608
|
+
stateDir: stateDir.dir,
|
|
4609
|
+
build: daemonStatusBuildInfo(),
|
|
4610
|
+
turnInProgress: codex.turnInProgress,
|
|
4611
|
+
turnPhase: codex.turnPhase,
|
|
4612
|
+
attentionWindowActive: inAttentionWindow
|
|
2319
4613
|
});
|
|
2320
4614
|
}
|
|
2321
4615
|
function removeStatusFile() {
|
|
2322
4616
|
daemonLifecycle.removeStatusFile();
|
|
2323
4617
|
}
|
|
4618
|
+
function armBootDeadline() {
|
|
4619
|
+
if (bootDeadlineTimer)
|
|
4620
|
+
return;
|
|
4621
|
+
bootDeadlineTimer = setTimeout(() => {
|
|
4622
|
+
bootDeadlineTimer = null;
|
|
4623
|
+
if (codexBootstrapped)
|
|
4624
|
+
return;
|
|
4625
|
+
if (tuiConnectionState.snapshot().tuiConnected)
|
|
4626
|
+
return;
|
|
4627
|
+
log(`Codex not ready within bootstrap deadline (${BOOTSTRAP_TIMEOUT_MS}ms) \u2014 self-exiting to release control port`);
|
|
4628
|
+
if (attachedClaude) {
|
|
4629
|
+
emitToClaude(systemMessage("system_daemon_self_replace", "\u26A0\uFE0F Codex did not become ready within the bootstrap deadline \u2014 the AgentBridge daemon is restarting itself to release a clean slot. The bridge will reconnect automatically."));
|
|
4630
|
+
}
|
|
4631
|
+
shutdown("codex not ready within bootstrap deadline", 1);
|
|
4632
|
+
}, BOOTSTRAP_TIMEOUT_MS);
|
|
4633
|
+
bootDeadlineTimer.unref?.();
|
|
4634
|
+
}
|
|
4635
|
+
function clearBootDeadline() {
|
|
4636
|
+
if (bootDeadlineTimer) {
|
|
4637
|
+
clearTimeout(bootDeadlineTimer);
|
|
4638
|
+
bootDeadlineTimer = null;
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
2324
4641
|
async function bootCodex() {
|
|
2325
4642
|
log("Starting AgentBridge daemon...");
|
|
2326
4643
|
log(`Codex app-server: ${codex.appServerUrl}`);
|
|
2327
4644
|
log(`Codex proxy: ${codex.proxyUrl}`);
|
|
2328
4645
|
log(`Control server: ws://127.0.0.1:${CONTROL_PORT}/ws`);
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
4646
|
+
for (let attempt = 0;attempt <= CODEX_BOOT_RETRIES; attempt++) {
|
|
4647
|
+
try {
|
|
4648
|
+
await codex.start();
|
|
4649
|
+
codexBootstrapped = true;
|
|
4650
|
+
clearBootDeadline();
|
|
4651
|
+
writeStatusFile();
|
|
4652
|
+
emitToClaude(systemMessage("system_waiting", currentWaitingMessage()));
|
|
4653
|
+
broadcastStatus();
|
|
4654
|
+
scheduleIdleShutdown();
|
|
4655
|
+
return;
|
|
4656
|
+
} catch (err) {
|
|
4657
|
+
const attemptsLeft = CODEX_BOOT_RETRIES - attempt;
|
|
4658
|
+
log(`Failed to start Codex (attempt ${attempt + 1}/${CODEX_BOOT_RETRIES + 1}): ${err.message}`);
|
|
4659
|
+
if (attemptsLeft > 0) {
|
|
4660
|
+
const backoffMs = 1000 * (attempt + 1);
|
|
4661
|
+
log(`Retrying Codex bootstrap in ${backoffMs}ms (${attemptsLeft} attempt(s) left)...`);
|
|
4662
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
4663
|
+
if (shuttingDown)
|
|
4664
|
+
return;
|
|
4665
|
+
continue;
|
|
4666
|
+
}
|
|
4667
|
+
emitToClaude(systemMessage("system_codex_start_failed", `\u274C AgentBridge failed to start Codex app-server after ${CODEX_BOOT_RETRIES + 1} attempts: ${err.message}`));
|
|
4668
|
+
broadcastStatus();
|
|
4669
|
+
shutdown("codex bootstrap failed", 1);
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
2339
4672
|
}
|
|
2340
4673
|
}
|
|
2341
|
-
function shutdown(reason) {
|
|
4674
|
+
function shutdown(reason, exitCode = 0) {
|
|
2342
4675
|
if (shuttingDown)
|
|
2343
4676
|
return;
|
|
2344
4677
|
shuttingDown = true;
|
|
2345
4678
|
log(`Shutting down daemon (${reason})...`);
|
|
4679
|
+
clearBootDeadline();
|
|
4680
|
+
stopBudgetCoordinator();
|
|
2346
4681
|
tuiConnectionState.dispose(`daemon shutdown (${reason})`);
|
|
2347
4682
|
clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
|
|
2348
4683
|
controlServer?.stop();
|
|
@@ -2350,27 +4685,23 @@ function shutdown(reason) {
|
|
|
2350
4685
|
codex.stop();
|
|
2351
4686
|
removePidFile();
|
|
2352
4687
|
removeStatusFile();
|
|
2353
|
-
process.exit(
|
|
4688
|
+
process.exit(exitCode);
|
|
2354
4689
|
}
|
|
2355
4690
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
2356
4691
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
2357
4692
|
process.on("exit", () => {
|
|
4693
|
+
codex.forceKillAppServerSync();
|
|
2358
4694
|
removePidFile();
|
|
2359
4695
|
removeStatusFile();
|
|
2360
4696
|
});
|
|
2361
4697
|
process.on("uncaughtException", (err) => {
|
|
2362
|
-
|
|
4698
|
+
processLogger.fatal("UNCAUGHT EXCEPTION", err);
|
|
2363
4699
|
});
|
|
2364
4700
|
process.on("unhandledRejection", (reason) => {
|
|
2365
|
-
|
|
4701
|
+
processLogger.fatal("UNHANDLED REJECTION", reason);
|
|
2366
4702
|
});
|
|
2367
4703
|
function log(msg) {
|
|
2368
|
-
|
|
2369
|
-
`;
|
|
2370
|
-
process.stderr.write(line);
|
|
2371
|
-
try {
|
|
2372
|
-
appendFileSync2(stateDir.logFile, line);
|
|
2373
|
-
} catch {}
|
|
4704
|
+
processLogger.log(msg);
|
|
2374
4705
|
}
|
|
2375
4706
|
if (daemonLifecycle.wasKilled()) {
|
|
2376
4707
|
log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
|
|
@@ -2378,4 +4709,5 @@ if (daemonLifecycle.wasKilled()) {
|
|
|
2378
4709
|
}
|
|
2379
4710
|
writePidFile();
|
|
2380
4711
|
startControlServer();
|
|
4712
|
+
armBootDeadline();
|
|
2381
4713
|
bootCodex();
|