@raysonmeng/agentbridge 0.1.5 → 0.1.7
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 +55 -9
- package/README.zh-CN.md +39 -4
- package/dist/cli.js +4242 -464
- package/dist/daemon.js +4634 -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 +1247 -235
- package/plugins/agentbridge/server/daemon.js +2897 -432
- package/scripts/install-safety.cjs +209 -0
- package/scripts/postinstall.cjs +129 -9
|
@@ -1,14 +1,323 @@
|
|
|
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.7", "0.0.0-source"),
|
|
21
|
+
commit: defineString("1df8b91", "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
|
-
|
|
48
|
+
|
|
49
|
+
// src/state-dir.ts
|
|
50
|
+
import { mkdirSync, existsSync } from "fs";
|
|
51
|
+
import { join } from "path";
|
|
52
|
+
import { homedir, platform } from "os";
|
|
53
|
+
|
|
54
|
+
class StateDirResolver {
|
|
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
|
+
}
|
|
63
|
+
constructor(envOverride) {
|
|
64
|
+
const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
|
|
65
|
+
this.stateDir = override && override.length > 0 ? override : StateDirResolver.platformBaseDir();
|
|
66
|
+
}
|
|
67
|
+
ensure() {
|
|
68
|
+
if (!existsSync(this.stateDir)) {
|
|
69
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
get dir() {
|
|
73
|
+
return this.stateDir;
|
|
74
|
+
}
|
|
75
|
+
get pidFile() {
|
|
76
|
+
return join(this.stateDir, "daemon.pid");
|
|
77
|
+
}
|
|
78
|
+
get tuiPidFile() {
|
|
79
|
+
return join(this.stateDir, "codex-tui.pid");
|
|
80
|
+
}
|
|
81
|
+
get lockFile() {
|
|
82
|
+
return join(this.stateDir, "daemon.lock");
|
|
83
|
+
}
|
|
84
|
+
get statusFile() {
|
|
85
|
+
return join(this.stateDir, "status.json");
|
|
86
|
+
}
|
|
87
|
+
get portsFile() {
|
|
88
|
+
return join(this.stateDir, "ports.json");
|
|
89
|
+
}
|
|
90
|
+
get currentThreadFile() {
|
|
91
|
+
return join(this.stateDir, "current-thread.json");
|
|
92
|
+
}
|
|
93
|
+
get logFile() {
|
|
94
|
+
return join(this.stateDir, "agentbridge.log");
|
|
95
|
+
}
|
|
96
|
+
get codexWrapperLogFile() {
|
|
97
|
+
return join(this.stateDir, "codex-wrapper.log");
|
|
98
|
+
}
|
|
99
|
+
get killedFile() {
|
|
100
|
+
return join(this.stateDir, "killed");
|
|
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);
|
|
320
|
+
}
|
|
12
321
|
|
|
13
322
|
// src/app-server-protocol.ts
|
|
14
323
|
var APP_SERVER_TRACKED_REQUEST_METHODS = [
|
|
@@ -53,19 +362,263 @@ function isAppServerResponseMessage(value) {
|
|
|
53
362
|
return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
|
|
54
363
|
}
|
|
55
364
|
|
|
56
|
-
// src/codex-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
604
|
|
|
605
|
+
// src/codex-adapter.ts
|
|
59
606
|
class CodexAdapter extends EventEmitter {
|
|
60
607
|
static RESPONSE_TRACKING_TTL_MS = 30000;
|
|
61
608
|
proc = null;
|
|
609
|
+
appServerPid = null;
|
|
62
610
|
appServerWs = null;
|
|
63
611
|
tuiWs = null;
|
|
64
612
|
proxyServer = null;
|
|
613
|
+
transport = "ws";
|
|
614
|
+
socketPath = null;
|
|
615
|
+
relay = null;
|
|
65
616
|
threadId = null;
|
|
66
617
|
nextInjectionId = -1;
|
|
67
618
|
appPort;
|
|
68
619
|
proxyPort;
|
|
620
|
+
logFile;
|
|
621
|
+
logger;
|
|
69
622
|
tuiConnId = 0;
|
|
70
623
|
connIdCounter = 0;
|
|
71
624
|
secondaryConnections = new Map;
|
|
@@ -73,6 +626,12 @@ class CodexAdapter extends EventEmitter {
|
|
|
73
626
|
pendingRequests = new Map;
|
|
74
627
|
activeTurnIds = new Set;
|
|
75
628
|
turnInProgress = false;
|
|
629
|
+
turnWatchdogs = new Map;
|
|
630
|
+
stalledTurnIds = new Set;
|
|
631
|
+
currentlyStalledTurnIds = new Set;
|
|
632
|
+
lastTurnEndedAbnormally = false;
|
|
633
|
+
lastEmittedPhase = "idle";
|
|
634
|
+
threadSwitchSeq = 0;
|
|
76
635
|
nextProxyId = 1e5;
|
|
77
636
|
upstreamToClient = new Map;
|
|
78
637
|
serverRequestToProxy = new Map;
|
|
@@ -85,10 +644,21 @@ class CodexAdapter extends EventEmitter {
|
|
|
85
644
|
reconnectingForNewSession = false;
|
|
86
645
|
replayingBufferedMessages = false;
|
|
87
646
|
appServerGeneration = 0;
|
|
88
|
-
|
|
647
|
+
outageQueue = [];
|
|
648
|
+
outageTimer = null;
|
|
649
|
+
static OUTAGE_QUEUE_MAX = 64;
|
|
650
|
+
static OUTAGE_TIMEOUT_MS = 1e4;
|
|
651
|
+
lastInitializeRaw = null;
|
|
652
|
+
lastInitializedRaw = null;
|
|
653
|
+
sessionRestoreInProgress = false;
|
|
654
|
+
replayPending = new Map;
|
|
655
|
+
static SESSION_REPLAY_TIMEOUT_MS = 5000;
|
|
656
|
+
constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) {
|
|
89
657
|
super();
|
|
90
658
|
this.appPort = appPort;
|
|
91
659
|
this.proxyPort = proxyPort;
|
|
660
|
+
this.logFile = logFile;
|
|
661
|
+
this.logger = createProcessLogger({ component: "CodexAdapter", logFile: this.logFile });
|
|
92
662
|
}
|
|
93
663
|
get appServerUrl() {
|
|
94
664
|
return `ws://127.0.0.1:${this.appPort}`;
|
|
@@ -102,27 +672,52 @@ class CodexAdapter extends EventEmitter {
|
|
|
102
672
|
async start() {
|
|
103
673
|
this.intentionalDisconnect = false;
|
|
104
674
|
await this.checkPorts();
|
|
105
|
-
this.
|
|
106
|
-
|
|
675
|
+
this.resolveTransport();
|
|
676
|
+
const listen = codexListenArg(this.transport, this.appPort, this.socketPath ?? "");
|
|
677
|
+
if (this.transport === "unix" && this.socketPath) {
|
|
678
|
+
ensureSocketDir(this.socketPath);
|
|
679
|
+
removeSocketFile(this.socketPath);
|
|
680
|
+
}
|
|
681
|
+
this.log(`Spawning codex app-server (transport=${this.transport}) --listen ${listen}`);
|
|
682
|
+
this.proc = spawn("codex", ["app-server", "--listen", listen], {
|
|
107
683
|
stdio: ["pipe", "pipe", "pipe"]
|
|
108
684
|
});
|
|
685
|
+
this.appServerPid = this.proc.pid ?? null;
|
|
109
686
|
this.proc.on("error", (err) => this.emit("error", err));
|
|
110
|
-
this.proc.on("exit", (code) =>
|
|
687
|
+
this.proc.on("exit", (code) => {
|
|
688
|
+
this.appServerPid = null;
|
|
689
|
+
this.emit("exit", code);
|
|
690
|
+
});
|
|
111
691
|
const stderrRl = createInterface({ input: this.proc.stderr });
|
|
112
692
|
stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
|
|
113
693
|
const stdoutRl = createInterface({ input: this.proc.stdout });
|
|
114
694
|
stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
|
|
115
|
-
|
|
695
|
+
if (this.transport === "unix" && this.socketPath) {
|
|
696
|
+
await waitForUnixWsReady(this.socketPath);
|
|
697
|
+
this.relay = new TcpToUnixRelay("127.0.0.1", this.appPort, this.socketPath, (m) => this.log(`[relay] ${m}`));
|
|
698
|
+
await this.relay.start();
|
|
699
|
+
this.log(`Transport relay ready: ws://127.0.0.1:${this.appPort} \u2192 unix://${this.socketPath}`);
|
|
700
|
+
} else {
|
|
701
|
+
await this.waitForHealthy();
|
|
702
|
+
}
|
|
116
703
|
await this.connectToAppServer();
|
|
117
704
|
this.startProxy();
|
|
118
705
|
this.log(`Proxy ready on ${this.proxyUrl}`);
|
|
119
706
|
}
|
|
707
|
+
resolveTransport() {
|
|
708
|
+
const mode = parseTransportMode(process.env[CODEX_TRANSPORT_ENV]);
|
|
709
|
+
this.transport = resolveCodexTransport(mode);
|
|
710
|
+
this.socketPath = this.transport === "unix" ? codexSocketPath(this.appPort) : null;
|
|
711
|
+
this.log(`Codex transport mode=${mode} resolved=${this.transport}`);
|
|
712
|
+
}
|
|
120
713
|
disconnect() {
|
|
121
714
|
this.intentionalDisconnect = true;
|
|
122
715
|
if (this.reconnectTimer) {
|
|
123
716
|
clearTimeout(this.reconnectTimer);
|
|
124
717
|
this.reconnectTimer = null;
|
|
125
718
|
}
|
|
719
|
+
this.outageQueue = [];
|
|
720
|
+
this.clearOutageTimer();
|
|
126
721
|
this.appServerWs?.close();
|
|
127
722
|
this.appServerWs = null;
|
|
128
723
|
for (const [id, sec] of this.secondaryConnections) {
|
|
@@ -133,7 +728,14 @@ class CodexAdapter extends EventEmitter {
|
|
|
133
728
|
}
|
|
134
729
|
this.proxyServer?.stop();
|
|
135
730
|
this.proxyServer = null;
|
|
731
|
+
if (this.relay) {
|
|
732
|
+
this.relay.stop();
|
|
733
|
+
this.relay = null;
|
|
734
|
+
}
|
|
735
|
+
if (this.socketPath)
|
|
736
|
+
removeSocketFile(this.socketPath);
|
|
136
737
|
this.clearResponseTrackingState();
|
|
738
|
+
this.resetTurnState(ADAPTER_DISCONNECT_REASON);
|
|
137
739
|
}
|
|
138
740
|
stop() {
|
|
139
741
|
this.intentionalDisconnect = true;
|
|
@@ -150,7 +752,15 @@ class CodexAdapter extends EventEmitter {
|
|
|
150
752
|
proc.on("exit", () => clearTimeout(killTimer));
|
|
151
753
|
}
|
|
152
754
|
}
|
|
153
|
-
|
|
755
|
+
forceKillAppServerSync() {
|
|
756
|
+
const pid = this.appServerPid;
|
|
757
|
+
if (pid === null)
|
|
758
|
+
return;
|
|
759
|
+
try {
|
|
760
|
+
process.kill(pid, "SIGKILL");
|
|
761
|
+
} catch {}
|
|
762
|
+
}
|
|
763
|
+
injectMessage(text, overrides) {
|
|
154
764
|
if (!this.threadId) {
|
|
155
765
|
this.log("Cannot inject: no active thread");
|
|
156
766
|
return false;
|
|
@@ -166,11 +776,19 @@ class CodexAdapter extends EventEmitter {
|
|
|
166
776
|
this.log(`Injecting message into Codex (${text.length} chars)`);
|
|
167
777
|
const requestId = this.nextInjectionId--;
|
|
168
778
|
this.trackBridgeRequestId(requestId);
|
|
779
|
+
const params = { threadId: this.threadId, input: [{ type: "text", text }] };
|
|
780
|
+
if (overrides?.model)
|
|
781
|
+
params.model = overrides.model;
|
|
782
|
+
if (overrides?.effort)
|
|
783
|
+
params.effort = overrides.effort;
|
|
784
|
+
if (overrides?.model || overrides?.effort) {
|
|
785
|
+
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`);
|
|
786
|
+
}
|
|
169
787
|
try {
|
|
170
788
|
this.appServerWs.send(JSON.stringify({
|
|
171
789
|
method: "turn/start",
|
|
172
790
|
id: requestId,
|
|
173
|
-
params
|
|
791
|
+
params
|
|
174
792
|
}));
|
|
175
793
|
return true;
|
|
176
794
|
} catch (err) {
|
|
@@ -204,6 +822,14 @@ class CodexAdapter extends EventEmitter {
|
|
|
204
822
|
this.reconnectAttempts = 0;
|
|
205
823
|
this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server");
|
|
206
824
|
this.flushPendingServerResponses();
|
|
825
|
+
if (isReconnect) {
|
|
826
|
+
this.handleSessionRestoreAfterReconnect().finally(() => this.drainOutageQueue()).catch((e) => {
|
|
827
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
828
|
+
this.log(`session restore unexpected error: ${m}`);
|
|
829
|
+
});
|
|
830
|
+
} else {
|
|
831
|
+
this.drainOutageQueue();
|
|
832
|
+
}
|
|
207
833
|
resolve();
|
|
208
834
|
};
|
|
209
835
|
appWs.onmessage = (event) => {
|
|
@@ -252,8 +878,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
252
878
|
} catch {}
|
|
253
879
|
}
|
|
254
880
|
this.clearResponseTrackingStateForAppServerReconnect();
|
|
255
|
-
this.
|
|
256
|
-
this.turnInProgress = false;
|
|
881
|
+
this.resetTurnState(APP_SERVER_RECONNECT_NEW_TUI_REASON);
|
|
257
882
|
try {
|
|
258
883
|
await this.connectToAppServer(false);
|
|
259
884
|
this.log("App-server reconnected for new TUI session \u2014 replaying buffered messages");
|
|
@@ -302,64 +927,233 @@ class CodexAdapter extends EventEmitter {
|
|
|
302
927
|
}, delay);
|
|
303
928
|
}
|
|
304
929
|
handleAppServerClose() {
|
|
305
|
-
this.
|
|
930
|
+
const intentional = this.intentionalDisconnect;
|
|
931
|
+
const tuiConnected = this.tuiWs !== null;
|
|
932
|
+
this.log(`App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`);
|
|
306
933
|
this.appServerWs = null;
|
|
307
934
|
this.clearResponseTrackingState();
|
|
308
|
-
this.
|
|
309
|
-
|
|
310
|
-
if (!this.intentionalDisconnect) {
|
|
935
|
+
this.resetTurnState("app-server connection closed");
|
|
936
|
+
if (!intentional) {
|
|
311
937
|
this.scheduleReconnect();
|
|
312
938
|
}
|
|
313
939
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
|
|
940
|
+
bufferDuringOutage(ws, raw) {
|
|
941
|
+
if (this.outageQueue.length >= CodexAdapter.OUTAGE_QUEUE_MAX) {
|
|
942
|
+
this.log(`ERROR: outage queue overflow (${this.outageQueue.length}/${CodexAdapter.OUTAGE_QUEUE_MAX}) \u2014 closing TUI with 1011`);
|
|
943
|
+
this.outageQueue = [];
|
|
944
|
+
this.clearOutageTimer();
|
|
945
|
+
if (this.tuiWs && this.tuiWs === ws) {
|
|
946
|
+
try {
|
|
947
|
+
ws.close(1011, "agentbridge: app-server unavailable; pending TUI queue overflow");
|
|
948
|
+
} catch (e) {
|
|
949
|
+
this.log(`Failed to close TUI WS after outage queue overflow: ${e.message}`);
|
|
325
950
|
}
|
|
326
|
-
if (server.upgrade(req, { data: { connId: 0 } }))
|
|
327
|
-
return;
|
|
328
|
-
self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
|
|
329
|
-
return new Response("AgentBridge Codex Proxy");
|
|
330
|
-
},
|
|
331
|
-
websocket: {
|
|
332
|
-
open: (ws) => self.onTuiConnect(ws),
|
|
333
|
-
close: (ws, code, reason) => {
|
|
334
|
-
self.log(`WebSocket close event: conn #${ws.data.connId}, code=${code}, reason=${reason || "none"}`);
|
|
335
|
-
self.onTuiDisconnect(ws);
|
|
336
|
-
},
|
|
337
|
-
message: (ws, msg) => self.onTuiMessage(ws, msg)
|
|
338
951
|
}
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
onTuiConnect(ws) {
|
|
342
|
-
const connId = ++this.connIdCounter;
|
|
343
|
-
ws.data.connId = connId;
|
|
344
|
-
if (this.tuiWs) {
|
|
345
|
-
this.log(`Secondary TUI connected (conn #${connId}, primary is #${this.tuiConnId})`);
|
|
346
|
-
this.setupSecondaryConnection(ws, connId);
|
|
347
952
|
return;
|
|
348
953
|
}
|
|
349
|
-
|
|
350
|
-
this.
|
|
351
|
-
this.
|
|
352
|
-
this.threadId = null;
|
|
353
|
-
this.log(`TUI connected (conn #${this.tuiConnId})`);
|
|
354
|
-
this.emit("tuiConnected", this.tuiConnId);
|
|
355
|
-
if (previousConnId !== null) {
|
|
356
|
-
this.retireConnectionState(previousConnId);
|
|
357
|
-
}
|
|
954
|
+
this.outageQueue.push({ raw, connId: ws.data.connId });
|
|
955
|
+
this.log(`DIAGNOSTIC: buffered TUI message while app-server unavailable (queue size=${this.outageQueue.length}/${CodexAdapter.OUTAGE_QUEUE_MAX})`);
|
|
956
|
+
this.ensureOutageTimer();
|
|
358
957
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.
|
|
958
|
+
ensureOutageTimer() {
|
|
959
|
+
if (this.outageTimer !== null)
|
|
960
|
+
return;
|
|
961
|
+
this.outageTimer = setTimeout(() => {
|
|
962
|
+
this.outageTimer = null;
|
|
963
|
+
const buffered = this.outageQueue.length;
|
|
964
|
+
this.outageQueue = [];
|
|
965
|
+
this.log(`ERROR: app-server did not return within ${CodexAdapter.OUTAGE_TIMEOUT_MS}ms (buffered=${buffered}) \u2014 closing TUI with 1011`);
|
|
966
|
+
const ws = this.tuiWs;
|
|
967
|
+
if (ws) {
|
|
968
|
+
try {
|
|
969
|
+
ws.close(1011, `agentbridge: app-server unavailable after ${CodexAdapter.OUTAGE_TIMEOUT_MS}ms; buffered=${buffered}`);
|
|
970
|
+
} catch (e) {
|
|
971
|
+
this.log(`Failed to close TUI WS on outage timeout: ${e.message}`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}, CodexAdapter.OUTAGE_TIMEOUT_MS);
|
|
975
|
+
}
|
|
976
|
+
clearOutageTimer() {
|
|
977
|
+
if (this.outageTimer !== null) {
|
|
978
|
+
clearTimeout(this.outageTimer);
|
|
979
|
+
this.outageTimer = null;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async handleSessionRestoreAfterReconnect() {
|
|
983
|
+
if (!this.lastInitializeRaw) {
|
|
984
|
+
this.log("DIAGNOSTIC: no cached initialize to replay after unintentional reconnect");
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
988
|
+
this.log("DIAGNOSTIC: app-server not open at session restore start \u2014 skipping");
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
this.sessionRestoreInProgress = true;
|
|
992
|
+
try {
|
|
993
|
+
this.log(`DIAGNOSTIC: replaying cached initialize to restore session (threadId=${this.threadId ?? "none"})`);
|
|
994
|
+
await this.sendReplayAndAwait(this.lastInitializeRaw, "initialize");
|
|
995
|
+
if (this.lastInitializedRaw && this.appServerWs.readyState === WebSocket.OPEN) {
|
|
996
|
+
this.appServerWs.send(this.lastInitializedRaw);
|
|
997
|
+
}
|
|
998
|
+
if (this.threadId && this.appServerWs.readyState === WebSocket.OPEN) {
|
|
999
|
+
const replayId = `agentbridge-replay-thread-resume-${Date.now()}`;
|
|
1000
|
+
const resumeRaw = JSON.stringify({
|
|
1001
|
+
jsonrpc: "2.0",
|
|
1002
|
+
id: replayId,
|
|
1003
|
+
method: "thread/resume",
|
|
1004
|
+
params: { threadId: this.threadId }
|
|
1005
|
+
});
|
|
1006
|
+
await this.sendReplayAndAwait(resumeRaw, "thread/resume");
|
|
1007
|
+
}
|
|
1008
|
+
this.log(`DIAGNOSTIC: session restored after unintentional reconnect (threadId=${this.threadId ?? "none"})`);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1011
|
+
this.log(`ERROR: session restore failed (${msg}) \u2014 closing TUI with 1011`);
|
|
1012
|
+
const tuiWs = this.tuiWs;
|
|
1013
|
+
if (tuiWs) {
|
|
1014
|
+
try {
|
|
1015
|
+
tuiWs.close(1011, `agentbridge: session restore failed: ${msg}`);
|
|
1016
|
+
} catch (closeErr) {
|
|
1017
|
+
const cm = closeErr instanceof Error ? closeErr.message : String(closeErr);
|
|
1018
|
+
this.log(`Failed to close TUI after session restore failure: ${cm}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
} finally {
|
|
1022
|
+
this.sessionRestoreInProgress = false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
sendReplayAndAwait(raw, method) {
|
|
1026
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
1027
|
+
return Promise.reject(new Error("app-server not open"));
|
|
1028
|
+
}
|
|
1029
|
+
let id;
|
|
1030
|
+
try {
|
|
1031
|
+
const parsed = JSON.parse(raw);
|
|
1032
|
+
if (parsed.id === undefined) {
|
|
1033
|
+
return Promise.reject(new Error(`replay payload for ${method} has no id`));
|
|
1034
|
+
}
|
|
1035
|
+
id = parsed.id;
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
1038
|
+
return Promise.reject(new Error(`replay parse failed for ${method}: ${m}`));
|
|
1039
|
+
}
|
|
1040
|
+
return new Promise((resolve, reject) => {
|
|
1041
|
+
const timer = setTimeout(() => {
|
|
1042
|
+
this.replayPending.delete(id);
|
|
1043
|
+
reject(new Error(`replay timeout (${CodexAdapter.SESSION_REPLAY_TIMEOUT_MS}ms) for ${method} id=${JSON.stringify(id)}`));
|
|
1044
|
+
}, CodexAdapter.SESSION_REPLAY_TIMEOUT_MS);
|
|
1045
|
+
this.replayPending.set(id, { method, resolve, reject, timer });
|
|
1046
|
+
try {
|
|
1047
|
+
this.appServerWs.send(raw);
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
clearTimeout(timer);
|
|
1050
|
+
this.replayPending.delete(id);
|
|
1051
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
1052
|
+
reject(new Error(`replay send failed for ${method}: ${m}`));
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
tryConsumeReplayResponse(payload) {
|
|
1057
|
+
const id = payload.id;
|
|
1058
|
+
if (id === undefined)
|
|
1059
|
+
return false;
|
|
1060
|
+
const pending = this.replayPending.get(id);
|
|
1061
|
+
if (!pending)
|
|
1062
|
+
return false;
|
|
1063
|
+
clearTimeout(pending.timer);
|
|
1064
|
+
this.replayPending.delete(id);
|
|
1065
|
+
if (payload.error !== undefined) {
|
|
1066
|
+
const errMsg = typeof payload.error === "object" && payload.error !== null && "message" in payload.error ? String(payload.error.message ?? "unknown") : JSON.stringify(payload.error);
|
|
1067
|
+
pending.reject(new Error(`${pending.method} rejected: ${errMsg}`));
|
|
1068
|
+
} else {
|
|
1069
|
+
pending.resolve(payload);
|
|
1070
|
+
}
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
drainOutageQueue() {
|
|
1074
|
+
if (this.outageQueue.length === 0) {
|
|
1075
|
+
this.clearOutageTimer();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
|
|
1079
|
+
return;
|
|
1080
|
+
const ws = this.tuiWs;
|
|
1081
|
+
if (!ws) {
|
|
1082
|
+
this.outageQueue = [];
|
|
1083
|
+
this.clearOutageTimer();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const messages = this.outageQueue;
|
|
1087
|
+
this.outageQueue = [];
|
|
1088
|
+
this.clearOutageTimer();
|
|
1089
|
+
this.log(`DIAGNOSTIC: replaying ${messages.length} buffered TUI messages after app-server reconnect`);
|
|
1090
|
+
for (const msg of messages) {
|
|
1091
|
+
try {
|
|
1092
|
+
this.onTuiMessage(ws, msg.raw);
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
this.log(`Failed to replay buffered TUI message (conn #${msg.connId}): ${e.message}`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
startProxy() {
|
|
1099
|
+
const self = this;
|
|
1100
|
+
this.proxyServer = Bun.serve({
|
|
1101
|
+
port: this.proxyPort,
|
|
1102
|
+
hostname: "127.0.0.1",
|
|
1103
|
+
fetch(req, server) {
|
|
1104
|
+
const url = new URL(req.url);
|
|
1105
|
+
const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
1106
|
+
self.log(`HTTP ${req.method} ${url.pathname} (upgrade=${isUpgrade})`);
|
|
1107
|
+
if (url.pathname === "/healthz" || url.pathname === "/readyz") {
|
|
1108
|
+
if (self.transport === "unix") {
|
|
1109
|
+
const up = self.appServerWs?.readyState === WebSocket.OPEN;
|
|
1110
|
+
return new Response(up ? "ok" : "upstream not connected", { status: up ? 200 : 503 });
|
|
1111
|
+
}
|
|
1112
|
+
return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
|
|
1113
|
+
}
|
|
1114
|
+
if (server.upgrade(req, { data: { connId: 0 } }))
|
|
1115
|
+
return;
|
|
1116
|
+
self.log(`WARNING: non-upgrade HTTP request not handled: ${req.method} ${url.pathname}`);
|
|
1117
|
+
return new Response("AgentBridge Codex Proxy");
|
|
1118
|
+
},
|
|
1119
|
+
websocket: {
|
|
1120
|
+
open: (ws) => self.onTuiConnect(ws),
|
|
1121
|
+
close: (ws, code, reason) => {
|
|
1122
|
+
self.log(`WebSocket close event: conn #${ws.data.connId}, code=${code}, reason=${reason || "none"}`);
|
|
1123
|
+
self.onTuiDisconnect(ws);
|
|
1124
|
+
},
|
|
1125
|
+
message: (ws, msg) => self.onTuiMessage(ws, msg)
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
onTuiConnect(ws) {
|
|
1130
|
+
const connId = ++this.connIdCounter;
|
|
1131
|
+
ws.data.connId = connId;
|
|
1132
|
+
if (this.tuiWs) {
|
|
1133
|
+
this.log(`Secondary TUI connected (conn #${connId}, primary is #${this.tuiConnId})`);
|
|
1134
|
+
this.setupSecondaryConnection(ws, connId);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const previousConnId = this.tuiConnId > 0 ? this.tuiConnId : null;
|
|
1138
|
+
this.tuiConnId = connId;
|
|
1139
|
+
this.tuiWs = ws;
|
|
1140
|
+
this.threadId = null;
|
|
1141
|
+
this.log(`TUI connected (conn #${this.tuiConnId})`);
|
|
1142
|
+
this.emit("tuiConnected", this.tuiConnId);
|
|
1143
|
+
if (previousConnId !== null) {
|
|
1144
|
+
this.retireConnectionState(previousConnId);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
setupSecondaryConnection(ws, connId) {
|
|
1148
|
+
const appWs = new WebSocket(this.appServerUrl);
|
|
1149
|
+
const entry = {
|
|
1150
|
+
tuiWs: ws,
|
|
1151
|
+
appServerWs: appWs,
|
|
1152
|
+
buffer: [],
|
|
1153
|
+
initialized: false,
|
|
1154
|
+
initializationReplayed: false
|
|
1155
|
+
};
|
|
1156
|
+
this.secondaryConnections.set(connId, entry);
|
|
363
1157
|
appWs.onopen = () => {
|
|
364
1158
|
if (!this.secondaryConnections.has(connId)) {
|
|
365
1159
|
appWs.close();
|
|
@@ -456,13 +1250,19 @@ class CodexAdapter extends EventEmitter {
|
|
|
456
1250
|
return;
|
|
457
1251
|
}
|
|
458
1252
|
if (this.tuiWs === ws) {
|
|
459
|
-
this.
|
|
1253
|
+
const appServerOpen = this.appServerWs?.readyState === WebSocket.OPEN;
|
|
1254
|
+
this.log(`TUI disconnected (conn #${connId}, appServerOpen=${appServerOpen}, turnInProgress=${this.turnInProgress}, pendingTuiMessages=${this.pendingTuiMessages.length}, outageQueue=${this.outageQueue.length}, reconnectingForNewSession=${this.reconnectingForNewSession})`);
|
|
460
1255
|
this.tuiWs = null;
|
|
461
1256
|
if (this.reconnectingForNewSession) {
|
|
462
1257
|
this.log("Clearing pending TUI message buffer (TUI disconnected during app-server reconnect)");
|
|
463
1258
|
this.pendingTuiMessages = [];
|
|
464
1259
|
this.reconnectingForNewSession = false;
|
|
465
1260
|
}
|
|
1261
|
+
if (this.outageQueue.length > 0 || this.outageTimer !== null) {
|
|
1262
|
+
this.log(`Clearing outage queue on TUI disconnect (buffered=${this.outageQueue.length})`);
|
|
1263
|
+
this.outageQueue = [];
|
|
1264
|
+
this.clearOutageTimer();
|
|
1265
|
+
}
|
|
466
1266
|
this.emit("tuiDisconnected", connId);
|
|
467
1267
|
} else {
|
|
468
1268
|
this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
|
|
@@ -474,13 +1274,13 @@ class CodexAdapter extends EventEmitter {
|
|
|
474
1274
|
const connId = ws.data.connId;
|
|
475
1275
|
const secondary = this.secondaryConnections.get(connId);
|
|
476
1276
|
if (secondary) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
secondary.buffer.push(data);
|
|
1277
|
+
const method = this.detectJsonMethod(data);
|
|
1278
|
+
if (method === "initialize" || method === "initialized") {
|
|
1279
|
+
secondary.initialized = true;
|
|
1280
|
+
} else if (!secondary.initialized) {
|
|
1281
|
+
this.ensureSecondaryInitialized(secondary, connId);
|
|
483
1282
|
}
|
|
1283
|
+
this.sendOrBufferSecondary(secondary, data);
|
|
484
1284
|
return;
|
|
485
1285
|
}
|
|
486
1286
|
if (connId !== this.tuiConnId) {
|
|
@@ -525,6 +1325,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
525
1325
|
} catch {}
|
|
526
1326
|
if (!this.replayingBufferedMessages) {
|
|
527
1327
|
if (detectedMethod === "initialize") {
|
|
1328
|
+
this.lastInitializeRaw = data;
|
|
528
1329
|
this.log("Detected initialize \u2014 reconnecting app-server for fresh session");
|
|
529
1330
|
this.reconnectingForNewSession = true;
|
|
530
1331
|
this.pendingTuiMessages = [data];
|
|
@@ -536,6 +1337,17 @@ class CodexAdapter extends EventEmitter {
|
|
|
536
1337
|
return;
|
|
537
1338
|
}
|
|
538
1339
|
}
|
|
1340
|
+
if (detectedMethod === "initialized") {
|
|
1341
|
+
this.lastInitializedRaw = data;
|
|
1342
|
+
}
|
|
1343
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN || this.sessionRestoreInProgress) {
|
|
1344
|
+
if (this.tuiWs && this.tuiWs === ws) {
|
|
1345
|
+
this.bufferDuringOutage(ws, data);
|
|
1346
|
+
} else {
|
|
1347
|
+
this.log(`WARNING: non-primary TUI attempted to send while app-server down \u2014 dropped (connId=${connId})`);
|
|
1348
|
+
}
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
539
1351
|
let forwarded = data;
|
|
540
1352
|
try {
|
|
541
1353
|
const parsed = JSON.parse(data);
|
|
@@ -556,14 +1368,59 @@ class CodexAdapter extends EventEmitter {
|
|
|
556
1368
|
if (this.appServerWs?.readyState === WebSocket.OPEN) {
|
|
557
1369
|
this.appServerWs.send(forwarded);
|
|
558
1370
|
} else {
|
|
559
|
-
this.log(`WARNING: app-server
|
|
1371
|
+
this.log(`WARNING: app-server closed between OPEN check and send \u2014 message lost (connId=${ws.data.connId})`);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
detectJsonMethod(raw) {
|
|
1375
|
+
try {
|
|
1376
|
+
const parsed = JSON.parse(raw);
|
|
1377
|
+
return typeof parsed?.method === "string" ? parsed.method : undefined;
|
|
1378
|
+
} catch {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
ensureSecondaryInitialized(secondary, connId) {
|
|
1383
|
+
if (secondary.initializationReplayed)
|
|
1384
|
+
return;
|
|
1385
|
+
secondary.initializationReplayed = true;
|
|
1386
|
+
if (!this.lastInitializeRaw) {
|
|
1387
|
+
this.log(`Secondary conn #${connId}: no cached initialize available before first non-initialize request`);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
this.log(`Secondary conn #${connId}: replaying cached initialize before picker request`);
|
|
1391
|
+
this.sendOrBufferSecondary(secondary, this.lastInitializeRaw);
|
|
1392
|
+
if (this.lastInitializedRaw) {
|
|
1393
|
+
this.sendOrBufferSecondary(secondary, this.lastInitializedRaw);
|
|
1394
|
+
}
|
|
1395
|
+
secondary.initialized = true;
|
|
1396
|
+
}
|
|
1397
|
+
sendOrBufferSecondary(secondary, raw) {
|
|
1398
|
+
if (secondary.appServerWs && secondary.appServerWs.readyState === WebSocket.OPEN) {
|
|
1399
|
+
try {
|
|
1400
|
+
secondary.appServerWs.send(raw);
|
|
1401
|
+
} catch {}
|
|
1402
|
+
} else {
|
|
1403
|
+
secondary.buffer.push(raw);
|
|
560
1404
|
}
|
|
561
1405
|
}
|
|
562
1406
|
handleAppServerPayload(raw) {
|
|
563
1407
|
try {
|
|
564
1408
|
const parsed = JSON.parse(raw);
|
|
1409
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1410
|
+
this.refreshTurnWatchdogs();
|
|
1411
|
+
}
|
|
1412
|
+
if (typeof parsed === "object" && parsed !== null && "id" in parsed) {
|
|
1413
|
+
if (this.tryConsumeReplayResponse(parsed)) {
|
|
1414
|
+
return null;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
565
1417
|
if (isAppServerNotification(parsed) || typeof parsed === "object" && parsed !== null && !("id" in parsed)) {
|
|
566
1418
|
const notificationLike = parsed;
|
|
1419
|
+
if (notificationLike.method === "thread/closed") {
|
|
1420
|
+
const params = notificationLike.params;
|
|
1421
|
+
const threadId = typeof params?.threadId === "string" ? params.threadId : "unknown";
|
|
1422
|
+
this.log(`DIAGNOSTIC: app-server emitted thread/closed (threadId=${threadId}) \u2014 TUI will exit(0) silently`);
|
|
1423
|
+
}
|
|
567
1424
|
const forwarded = this.patchResponse(notificationLike, raw);
|
|
568
1425
|
this.interceptServerMessage(notificationLike);
|
|
569
1426
|
return forwarded;
|
|
@@ -630,7 +1487,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
630
1487
|
timestamp: Date.now()
|
|
631
1488
|
});
|
|
632
1489
|
this.serverRequestToProxy.delete(proxyId);
|
|
633
|
-
this.log(`
|
|
1490
|
+
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})`);
|
|
634
1491
|
}
|
|
635
1492
|
flushPendingServerResponses() {
|
|
636
1493
|
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
|
|
@@ -665,6 +1522,9 @@ class CodexAdapter extends EventEmitter {
|
|
|
665
1522
|
if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
|
|
666
1523
|
if (parsed.error) {
|
|
667
1524
|
this.log(`Bridge-originated request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
|
|
1525
|
+
this.lastTurnEndedAbnormally = true;
|
|
1526
|
+
this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
|
|
1527
|
+
this.notifyPhaseIfChanged();
|
|
668
1528
|
} else {
|
|
669
1529
|
this.log(`Bridge-originated request completed (id ${responseId})`);
|
|
670
1530
|
}
|
|
@@ -780,6 +1640,9 @@ class CodexAdapter extends EventEmitter {
|
|
|
780
1640
|
pending.threadId = threadId;
|
|
781
1641
|
}
|
|
782
1642
|
}
|
|
1643
|
+
if (method === "thread/start" || method === "thread/resume") {
|
|
1644
|
+
pending.threadSwitchSeq = ++this.threadSwitchSeq;
|
|
1645
|
+
}
|
|
783
1646
|
if (this.pendingRequests.has(key)) {
|
|
784
1647
|
this.log(`WARNING: overwriting pending request for key ${key}`);
|
|
785
1648
|
}
|
|
@@ -803,6 +1666,10 @@ class CodexAdapter extends EventEmitter {
|
|
|
803
1666
|
}
|
|
804
1667
|
switch (pending.method) {
|
|
805
1668
|
case "thread/start": {
|
|
1669
|
+
if (!this.isLatestThreadSwitch(pending)) {
|
|
1670
|
+
this.log(`Ignoring stale thread/start response ${key} (seq=${pending.threadSwitchSeq} < latest=${this.threadSwitchSeq})`);
|
|
1671
|
+
break;
|
|
1672
|
+
}
|
|
806
1673
|
const threadId = message?.result?.thread?.id;
|
|
807
1674
|
if (typeof threadId === "string" && threadId.length > 0) {
|
|
808
1675
|
this.setActiveThreadId(threadId, `thread/start response ${key}`);
|
|
@@ -811,6 +1678,10 @@ class CodexAdapter extends EventEmitter {
|
|
|
811
1678
|
break;
|
|
812
1679
|
}
|
|
813
1680
|
case "thread/resume": {
|
|
1681
|
+
if (!this.isLatestThreadSwitch(pending)) {
|
|
1682
|
+
this.log(`Ignoring stale thread/resume response ${key} (seq=${pending.threadSwitchSeq} < latest=${this.threadSwitchSeq})`);
|
|
1683
|
+
break;
|
|
1684
|
+
}
|
|
814
1685
|
const threadId = message?.result?.thread?.id;
|
|
815
1686
|
if (typeof threadId === "string" && threadId.length > 0) {
|
|
816
1687
|
this.setActiveThreadId(threadId, `thread/resume response ${key}`);
|
|
@@ -823,16 +1694,24 @@ class CodexAdapter extends EventEmitter {
|
|
|
823
1694
|
}
|
|
824
1695
|
case "turn/start":
|
|
825
1696
|
if (pending.threadId) {
|
|
826
|
-
this.
|
|
1697
|
+
if (this.threadId === null || this.threadId === pending.threadId) {
|
|
1698
|
+
this.setActiveThreadId(pending.threadId, `turn/start response ${key}`);
|
|
1699
|
+
} else {
|
|
1700
|
+
this.log(`Ignoring turn/start response ${key} threadId=${pending.threadId} (active thread is ${this.threadId})`);
|
|
1701
|
+
}
|
|
827
1702
|
}
|
|
828
1703
|
break;
|
|
829
1704
|
}
|
|
830
1705
|
}
|
|
1706
|
+
isLatestThreadSwitch(pending) {
|
|
1707
|
+
return pending.threadSwitchSeq === this.threadSwitchSeq;
|
|
1708
|
+
}
|
|
831
1709
|
setActiveThreadId(threadId, reason) {
|
|
832
1710
|
if (this.threadId === threadId)
|
|
833
1711
|
return;
|
|
834
1712
|
const previousThreadId = this.threadId;
|
|
835
1713
|
this.threadId = threadId;
|
|
1714
|
+
this.emit("threadChanged", { threadId, previousThreadId, reason });
|
|
836
1715
|
if (previousThreadId) {
|
|
837
1716
|
this.log(`Active thread changed: ${previousThreadId} \u2192 ${threadId} (${reason})`);
|
|
838
1717
|
return;
|
|
@@ -840,25 +1719,118 @@ class CodexAdapter extends EventEmitter {
|
|
|
840
1719
|
this.log(`Thread detected: ${threadId} (${reason})`);
|
|
841
1720
|
this.emit("ready", threadId);
|
|
842
1721
|
}
|
|
1722
|
+
get turnPhase() {
|
|
1723
|
+
if (this.activeTurnIds.size > 0) {
|
|
1724
|
+
const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
|
|
1725
|
+
return allStalled ? "stalled" : "running";
|
|
1726
|
+
}
|
|
1727
|
+
return this.lastTurnEndedAbnormally ? "aborted" : "idle";
|
|
1728
|
+
}
|
|
1729
|
+
notifyPhaseIfChanged() {
|
|
1730
|
+
const phase = this.turnPhase;
|
|
1731
|
+
if (phase === this.lastEmittedPhase)
|
|
1732
|
+
return;
|
|
1733
|
+
const previous = this.lastEmittedPhase;
|
|
1734
|
+
this.lastEmittedPhase = phase;
|
|
1735
|
+
this.emit("turnPhaseChanged", { phase, previous });
|
|
1736
|
+
}
|
|
843
1737
|
markTurnStarted(turnId) {
|
|
844
1738
|
const wasInProgress = this.turnInProgress;
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1739
|
+
const turnKey = typeof turnId === "string" && turnId.length > 0 ? turnId : `unknown:${Date.now()}`;
|
|
1740
|
+
this.activeTurnIds.add(turnKey);
|
|
1741
|
+
this.stalledTurnIds.delete(turnKey);
|
|
1742
|
+
this.currentlyStalledTurnIds.delete(turnKey);
|
|
1743
|
+
this.lastTurnEndedAbnormally = false;
|
|
1744
|
+
this.scheduleTurnWatchdog(turnKey);
|
|
850
1745
|
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
851
1746
|
if (!wasInProgress && this.turnInProgress) {
|
|
852
1747
|
this.emit("turnStarted");
|
|
853
1748
|
}
|
|
1749
|
+
this.notifyPhaseIfChanged();
|
|
854
1750
|
}
|
|
855
1751
|
markTurnCompleted(turnId) {
|
|
856
1752
|
if (typeof turnId === "string" && turnId.length > 0) {
|
|
857
1753
|
this.activeTurnIds.delete(turnId);
|
|
1754
|
+
this.clearTurnWatchdog(turnId);
|
|
1755
|
+
this.stalledTurnIds.delete(turnId);
|
|
1756
|
+
this.currentlyStalledTurnIds.delete(turnId);
|
|
858
1757
|
} else {
|
|
859
1758
|
this.activeTurnIds.clear();
|
|
1759
|
+
this.clearAllTurnWatchdogs();
|
|
1760
|
+
this.stalledTurnIds.clear();
|
|
1761
|
+
this.currentlyStalledTurnIds.clear();
|
|
860
1762
|
}
|
|
1763
|
+
this.lastTurnEndedAbnormally = false;
|
|
861
1764
|
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
1765
|
+
this.notifyPhaseIfChanged();
|
|
1766
|
+
}
|
|
1767
|
+
turnWatchdogMs() {
|
|
1768
|
+
const v = Number(process.env.AGENTBRIDGE_TURN_WATCHDOG_MS);
|
|
1769
|
+
return Number.isFinite(v) && v > 0 ? v : 300000;
|
|
1770
|
+
}
|
|
1771
|
+
scheduleTurnWatchdog(turnKey) {
|
|
1772
|
+
this.clearTurnWatchdog(turnKey);
|
|
1773
|
+
const timer = setTimeout(() => {
|
|
1774
|
+
if (!this.activeTurnIds.has(turnKey))
|
|
1775
|
+
return;
|
|
1776
|
+
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`);
|
|
1777
|
+
this.markTurnStalled(turnKey);
|
|
1778
|
+
}, this.turnWatchdogMs());
|
|
1779
|
+
timer.unref?.();
|
|
1780
|
+
this.turnWatchdogs.set(turnKey, timer);
|
|
1781
|
+
}
|
|
1782
|
+
clearTurnWatchdog(turnKey) {
|
|
1783
|
+
const timer = this.turnWatchdogs.get(turnKey);
|
|
1784
|
+
if (timer) {
|
|
1785
|
+
clearTimeout(timer);
|
|
1786
|
+
this.turnWatchdogs.delete(turnKey);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
clearAllTurnWatchdogs() {
|
|
1790
|
+
for (const timer of this.turnWatchdogs.values())
|
|
1791
|
+
clearTimeout(timer);
|
|
1792
|
+
this.turnWatchdogs.clear();
|
|
1793
|
+
}
|
|
1794
|
+
refreshTurnWatchdogs() {
|
|
1795
|
+
if (this.turnWatchdogs.size === 0)
|
|
1796
|
+
return;
|
|
1797
|
+
for (const turnKey of [...this.turnWatchdogs.keys()]) {
|
|
1798
|
+
this.scheduleTurnWatchdog(turnKey);
|
|
1799
|
+
}
|
|
1800
|
+
this.currentlyStalledTurnIds.clear();
|
|
1801
|
+
this.notifyPhaseIfChanged();
|
|
1802
|
+
}
|
|
1803
|
+
markTurnStalled(turnKey) {
|
|
1804
|
+
if (!this.activeTurnIds.has(turnKey))
|
|
1805
|
+
return;
|
|
1806
|
+
this.turnInProgress = true;
|
|
1807
|
+
this.currentlyStalledTurnIds.add(turnKey);
|
|
1808
|
+
this.notifyPhaseIfChanged();
|
|
1809
|
+
if (this.stalledTurnIds.has(turnKey))
|
|
1810
|
+
return;
|
|
1811
|
+
this.stalledTurnIds.add(turnKey);
|
|
1812
|
+
this.emit("turnStalled", {
|
|
1813
|
+
turnId: turnKey,
|
|
1814
|
+
inactivityMs: this.turnWatchdogMs()
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
resetTurnState(reason, emitCompleted = false) {
|
|
1818
|
+
const wasInProgress = this.turnInProgress;
|
|
1819
|
+
this.activeTurnIds.clear();
|
|
1820
|
+
this.clearAllTurnWatchdogs();
|
|
1821
|
+
this.stalledTurnIds.clear();
|
|
1822
|
+
this.currentlyStalledTurnIds.clear();
|
|
1823
|
+
this.turnInProgress = false;
|
|
1824
|
+
if (wasInProgress) {
|
|
1825
|
+
this.lastTurnEndedAbnormally = !emitCompleted;
|
|
1826
|
+
if (emitCompleted) {
|
|
1827
|
+
this.emit("turnCompleted");
|
|
1828
|
+
} else {
|
|
1829
|
+
this.emit("turnAborted", reason);
|
|
1830
|
+
}
|
|
1831
|
+
this.log(`Turn state reset (${reason})`);
|
|
1832
|
+
}
|
|
1833
|
+
this.notifyPhaseIfChanged();
|
|
862
1834
|
}
|
|
863
1835
|
requestKey(id) {
|
|
864
1836
|
if (typeof id === "number" || typeof id === "string")
|
|
@@ -959,61 +1931,54 @@ class CodexAdapter extends EventEmitter {
|
|
|
959
1931
|
this.serverRequestToProxy.clear();
|
|
960
1932
|
this.pendingServerResponses.clear();
|
|
961
1933
|
}
|
|
1934
|
+
static buildPortListenLsofCommand(port) {
|
|
1935
|
+
const { cmd, args } = portPidsCommand(port, "linux");
|
|
1936
|
+
return [cmd, ...args].join(" ");
|
|
1937
|
+
}
|
|
962
1938
|
async checkPorts() {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
for (const pid of pidList) {
|
|
973
|
-
try {
|
|
974
|
-
const cmdline = execSync(`ps -p ${pid} -o args=`, { encoding: "utf-8" }).trim();
|
|
975
|
-
if (cmdline.includes("codex") && cmdline.includes("app-server")) {
|
|
976
|
-
staleCodexPids.push(pid);
|
|
977
|
-
} else {
|
|
978
|
-
foreignPids.push(pid);
|
|
979
|
-
}
|
|
980
|
-
} catch {}
|
|
981
|
-
}
|
|
982
|
-
if (staleCodexPids.length > 0) {
|
|
983
|
-
this.log(`Cleaning up stale codex app-server on port ${port}: PID(s) ${staleCodexPids.join(", ")}`);
|
|
984
|
-
for (const pid of staleCodexPids) {
|
|
985
|
-
try {
|
|
986
|
-
execSync(`kill ${pid}`, { encoding: "utf-8" });
|
|
987
|
-
} catch {}
|
|
988
|
-
}
|
|
989
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
990
|
-
}
|
|
991
|
-
if (foreignPids.length > 0) {
|
|
992
|
-
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.`);
|
|
993
|
-
}
|
|
994
|
-
try {
|
|
995
|
-
const remaining = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
996
|
-
if (remaining) {
|
|
997
|
-
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.`);
|
|
998
|
-
}
|
|
999
|
-
} catch (err) {
|
|
1000
|
-
if (err.message?.includes("Port"))
|
|
1001
|
-
throw err;
|
|
1002
|
-
}
|
|
1003
|
-
} catch (err) {
|
|
1004
|
-
if (err.message?.includes("Port") || err.message?.includes("non-Codex"))
|
|
1005
|
-
throw err;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1939
|
+
await cleanupPorts({
|
|
1940
|
+
ports: [
|
|
1941
|
+
{ port: this.appPort, envVar: "CODEX_WS_PORT" },
|
|
1942
|
+
{ port: this.proxyPort, envVar: "CODEX_PROXY_PORT" }
|
|
1943
|
+
],
|
|
1944
|
+
run: ({ cmd, args }) => execFileSync(cmd, args, { encoding: "utf-8" }),
|
|
1945
|
+
log: (message) => this.log(message),
|
|
1946
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
1947
|
+
});
|
|
1008
1948
|
}
|
|
1009
1949
|
log(msg) {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1950
|
+
this.logger.log(msg);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// src/control-protocol.ts
|
|
1955
|
+
var CLOSE_CODE_REPLACED = 4001;
|
|
1956
|
+
var CLOSE_CODE_EVICTED_STALE = 4002;
|
|
1957
|
+
var CLOSE_CODE_PROBE_IN_PROGRESS = 4003;
|
|
1958
|
+
var CLOSE_CODE_PAIR_MISMATCH = 4004;
|
|
1959
|
+
|
|
1960
|
+
// src/daemon-identity.ts
|
|
1961
|
+
function validateClaudeClientIdentity(input) {
|
|
1962
|
+
if (!input.expectedPairId)
|
|
1963
|
+
return { ok: true };
|
|
1964
|
+
if (!input.identity) {
|
|
1965
|
+
return input.allowIdentityless ? { ok: true } : { ok: false, closeCode: CLOSE_CODE_PAIR_MISMATCH, reason: "missing client identity" };
|
|
1966
|
+
}
|
|
1967
|
+
if (input.identity.pairId !== input.expectedPairId) {
|
|
1968
|
+
return {
|
|
1969
|
+
ok: false,
|
|
1970
|
+
closeCode: CLOSE_CODE_PAIR_MISMATCH,
|
|
1971
|
+
reason: `pair mismatch: expected ${input.expectedPairId}, got ${input.identity.pairId ?? "<none>"}`
|
|
1972
|
+
};
|
|
1016
1973
|
}
|
|
1974
|
+
if (!input.identity.cwd || input.identity.cwd !== input.daemonCwd) {
|
|
1975
|
+
return {
|
|
1976
|
+
ok: false,
|
|
1977
|
+
closeCode: CLOSE_CODE_PAIR_MISMATCH,
|
|
1978
|
+
reason: `cwd mismatch: expected ${input.daemonCwd}, got ${input.identity.cwd ?? "<none>"}`
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
return { ok: true };
|
|
1017
1982
|
}
|
|
1018
1983
|
|
|
1019
1984
|
// src/message-filter.ts
|
|
@@ -1042,27 +2007,6 @@ function classifyMessage(content, mode) {
|
|
|
1042
2007
|
return { action: "forward", marker };
|
|
1043
2008
|
}
|
|
1044
2009
|
}
|
|
1045
|
-
var BRIDGE_CONTRACT_REMINDER = `[Bridge Contract] When sending agentMessage, put the marker at the very start of the message:
|
|
1046
|
-
- [IMPORTANT] for decisions, reviews, completions, blockers
|
|
1047
|
-
- [STATUS] for progress updates
|
|
1048
|
-
- [FYI] for background context
|
|
1049
|
-
The marker MUST be the first text in the message (e.g. "[IMPORTANT] Task done", not "Task done [IMPORTANT]").
|
|
1050
|
-
Keep agentMessage for high-value communication only.
|
|
1051
|
-
|
|
1052
|
-
[Git Operations \u2014 FORBIDDEN]
|
|
1053
|
-
You MUST NOT execute any git write commands. This includes but is not limited to:
|
|
1054
|
-
git commit, git push, git pull, git fetch, git checkout -b, git branch, git merge, git rebase, git cherry-pick, git tag, git stash.
|
|
1055
|
-
These commands write to the .git directory, which is blocked by your sandbox. Attempting them will cause your session to hang indefinitely.
|
|
1056
|
-
Read-only git commands (git status, git log, git diff, git show, git rev-parse) are allowed.
|
|
1057
|
-
All git write operations must be delegated to Claude Code via agentMessage. Report what you changed and let Claude handle branching, committing, and pushing.
|
|
1058
|
-
|
|
1059
|
-
[Role Guidance for Codex]
|
|
1060
|
-
- Your default role: Implementer, Executor, Verifier
|
|
1061
|
-
- Analytical/review tasks: Independent Analysis & Convergence
|
|
1062
|
-
- Implementation tasks: Architect -> Builder -> Critic
|
|
1063
|
-
- Debugging tasks: Hypothesis -> Experiment -> Interpretation
|
|
1064
|
-
- Do not blindly follow Claude - challenge with evidence when you disagree
|
|
1065
|
-
- Use explicit collaboration phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"`;
|
|
1066
2010
|
var REPLY_REQUIRED_INSTRUCTION = `
|
|
1067
2011
|
|
|
1068
2012
|
[\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.`;
|
|
@@ -1218,11 +2162,31 @@ class TuiConnectionState {
|
|
|
1218
2162
|
}
|
|
1219
2163
|
|
|
1220
2164
|
// src/daemon-lifecycle.ts
|
|
1221
|
-
import { spawn as spawn2, execFileSync } from "child_process";
|
|
1222
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
2165
|
+
import { spawn as spawn2, execFileSync as execFileSync2 } from "child_process";
|
|
2166
|
+
import { existsSync as existsSync3, readFileSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
1223
2167
|
import { fileURLToPath } from "url";
|
|
1224
|
-
|
|
2168
|
+
|
|
2169
|
+
// src/env-utils.ts
|
|
2170
|
+
function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env) {
|
|
2171
|
+
const raw = env[name];
|
|
2172
|
+
if (raw == null || raw === "")
|
|
2173
|
+
return fallback;
|
|
2174
|
+
const parsed = Number(raw);
|
|
2175
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
2176
|
+
log(`Invalid ${name}=${JSON.stringify(raw)} (must be a positive integer within ` + `Number.MAX_SAFE_INTEGER); falling back to ${fallback}`);
|
|
2177
|
+
return fallback;
|
|
2178
|
+
}
|
|
2179
|
+
return parsed;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// src/daemon-lifecycle.ts
|
|
2183
|
+
var DEFAULT_DAEMON_ENTRY = import.meta.url.endsWith(".ts") ? "./daemon.ts" : "./daemon.js";
|
|
2184
|
+
var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY || DEFAULT_DAEMON_ENTRY;
|
|
1225
2185
|
var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
|
|
2186
|
+
var REUSE_READY_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_REUSE_READY_RETRIES", 12);
|
|
2187
|
+
var REUSE_READY_DELAY_MS = 250;
|
|
2188
|
+
var HEALTH_FETCH_TIMEOUT_MS = 500;
|
|
2189
|
+
var LOCK_IDENTITY_GRACE_MS = parsePositiveIntEnv("AGENTBRIDGE_LOCK_IDENTITY_GRACE_MS", 120000);
|
|
1226
2190
|
|
|
1227
2191
|
class DaemonLifecycle {
|
|
1228
2192
|
stateDir;
|
|
@@ -1242,42 +2206,120 @@ class DaemonLifecycle {
|
|
|
1242
2206
|
get controlWsUrl() {
|
|
1243
2207
|
return `ws://127.0.0.1:${this.controlPort}/ws`;
|
|
1244
2208
|
}
|
|
2209
|
+
get expectedPairId() {
|
|
2210
|
+
return process.env.AGENTBRIDGE_PAIR_ID || null;
|
|
2211
|
+
}
|
|
2212
|
+
async fetchStatus() {
|
|
2213
|
+
try {
|
|
2214
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
2215
|
+
if (!response.ok)
|
|
2216
|
+
return null;
|
|
2217
|
+
return await response.json();
|
|
2218
|
+
} catch {
|
|
2219
|
+
return null;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
isForeignDaemon(status) {
|
|
2223
|
+
const expected = this.expectedPairId;
|
|
2224
|
+
if (!expected)
|
|
2225
|
+
return false;
|
|
2226
|
+
if (!status)
|
|
2227
|
+
return false;
|
|
2228
|
+
const reported = status.pairId;
|
|
2229
|
+
if (reported == null)
|
|
2230
|
+
return true;
|
|
2231
|
+
return reported !== expected;
|
|
2232
|
+
}
|
|
2233
|
+
isRegisteredPairDaemonInManualMode(status) {
|
|
2234
|
+
return !this.expectedPairId && status?.pairId != null;
|
|
2235
|
+
}
|
|
2236
|
+
isBuildDrifted(status) {
|
|
2237
|
+
if (process.env.AGENTBRIDGE_ALLOW_BUILD_DRIFT === "1")
|
|
2238
|
+
return false;
|
|
2239
|
+
const runtime = status?.build;
|
|
2240
|
+
if (!runtime)
|
|
2241
|
+
return true;
|
|
2242
|
+
return !sameRuntimeContract(runtime, BUILD_INFO);
|
|
2243
|
+
}
|
|
2244
|
+
canReuseDespiteDrift(status) {
|
|
2245
|
+
if (!compatibleContractVersion(status?.build, BUILD_INFO))
|
|
2246
|
+
return false;
|
|
2247
|
+
return status?.tuiConnected === true;
|
|
2248
|
+
}
|
|
1245
2249
|
async ensureRunning() {
|
|
1246
2250
|
if (await this.isHealthy()) {
|
|
1247
|
-
await this.
|
|
1248
|
-
|
|
2251
|
+
const status = await this.fetchStatus();
|
|
2252
|
+
if (this.isRegisteredPairDaemonInManualMode(status)) {
|
|
2253
|
+
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.`);
|
|
2254
|
+
}
|
|
2255
|
+
if (this.isForeignDaemon(status)) {
|
|
2256
|
+
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`);
|
|
2257
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
if (this.isBuildDrifted(status)) {
|
|
2261
|
+
if (this.canReuseDespiteDrift(status)) {
|
|
2262
|
+
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)`);
|
|
2263
|
+
} else {
|
|
2264
|
+
this.log(`Daemon on control port ${this.controlPort} is running build ${formatBuildInfo(status?.build)} ` + `but launcher is ${formatBuildInfo(BUILD_INFO)} \u2014 replacing drifted daemon`);
|
|
2265
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
try {
|
|
2270
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2271
|
+
return;
|
|
2272
|
+
} catch {
|
|
2273
|
+
this.log(`Daemon on control port ${this.controlPort} is healthy but not ready within reuse window \u2014 replacing`);
|
|
2274
|
+
await this.replaceUnhealthyDaemon(status?.pid);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
1249
2277
|
}
|
|
1250
2278
|
const existingPid = this.readPid();
|
|
1251
2279
|
if (existingPid) {
|
|
1252
2280
|
if (isProcessAlive(existingPid)) {
|
|
1253
2281
|
if (this.isDaemonProcess(existingPid)) {
|
|
1254
2282
|
try {
|
|
1255
|
-
await this.waitForReady(
|
|
2283
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
1256
2284
|
return;
|
|
1257
2285
|
} catch {
|
|
1258
|
-
|
|
2286
|
+
this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
|
|
2287
|
+
await this.replaceUnhealthyDaemon(existingPid);
|
|
2288
|
+
return;
|
|
1259
2289
|
}
|
|
1260
2290
|
}
|
|
1261
2291
|
this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
|
|
1262
2292
|
}
|
|
1263
2293
|
this.removeStalePidFile();
|
|
1264
2294
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
2295
|
+
await this.withStartupLockStrict(async (locked) => {
|
|
2296
|
+
if (!locked) {
|
|
2297
|
+
this.log("Another process holds the startup lock, waiting for readiness+identity...");
|
|
2298
|
+
await this.waitForReadyAndOurs();
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
if (await this.isHealthy()) {
|
|
2302
|
+
const status = await this.fetchStatus();
|
|
2303
|
+
if (this.isForeignDaemon(status) || this.isBuildDrifted(status) && !this.canReuseDespiteDrift(status)) {
|
|
2304
|
+
this.log(`Daemon on control port ${this.controlPort} is not reusable under startup lock ` + `(pair=${status?.pairId ?? "<none>"}, build=${formatBuildInfo(status?.build)}) \u2014 replacing`);
|
|
2305
|
+
await this.kill(3000, status?.pid);
|
|
2306
|
+
} else {
|
|
2307
|
+
try {
|
|
2308
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2309
|
+
return;
|
|
2310
|
+
} catch {
|
|
2311
|
+
this.log(`Daemon on control port ${this.controlPort} is healthy but not ready under startup lock \u2014 replacing`);
|
|
2312
|
+
await this.kill(3000, status?.pid);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
1272
2316
|
this.launch();
|
|
1273
2317
|
await this.waitForReady();
|
|
1274
|
-
}
|
|
1275
|
-
this.releaseLock();
|
|
1276
|
-
}
|
|
2318
|
+
});
|
|
1277
2319
|
}
|
|
1278
2320
|
async isHealthy() {
|
|
1279
2321
|
try {
|
|
1280
|
-
const response = await
|
|
2322
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
1281
2323
|
return response.ok;
|
|
1282
2324
|
} catch {
|
|
1283
2325
|
return false;
|
|
@@ -1293,7 +2335,7 @@ class DaemonLifecycle {
|
|
|
1293
2335
|
}
|
|
1294
2336
|
async isReady() {
|
|
1295
2337
|
try {
|
|
1296
|
-
const response = await
|
|
2338
|
+
const response = await fetchWithTimeout(this.readyUrl);
|
|
1297
2339
|
return response.ok;
|
|
1298
2340
|
} catch {
|
|
1299
2341
|
return false;
|
|
@@ -1307,9 +2349,21 @@ class DaemonLifecycle {
|
|
|
1307
2349
|
}
|
|
1308
2350
|
throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
|
|
1309
2351
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
2352
|
+
async waitForReadyAndOurs(maxRetries = 40, delayMs = 250) {
|
|
2353
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
2354
|
+
if (await this.isReady()) {
|
|
2355
|
+
const status = await this.fetchStatus();
|
|
2356
|
+
if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2361
|
+
}
|
|
2362
|
+
throw new Error(`Timed out waiting for AgentBridge daemon readiness+identity on ${this.readyUrl} (control port ${this.controlPort})`);
|
|
2363
|
+
}
|
|
2364
|
+
readStatus() {
|
|
2365
|
+
try {
|
|
2366
|
+
const raw = readFileSync(this.stateDir.statusFile, "utf-8");
|
|
1313
2367
|
return JSON.parse(raw);
|
|
1314
2368
|
} catch {
|
|
1315
2369
|
return null;
|
|
@@ -1338,12 +2392,12 @@ class DaemonLifecycle {
|
|
|
1338
2392
|
}
|
|
1339
2393
|
removePidFile() {
|
|
1340
2394
|
try {
|
|
1341
|
-
|
|
2395
|
+
unlinkSync2(this.stateDir.pidFile);
|
|
1342
2396
|
} catch {}
|
|
1343
2397
|
}
|
|
1344
2398
|
removeStatusFile() {
|
|
1345
2399
|
try {
|
|
1346
|
-
|
|
2400
|
+
unlinkSync2(this.stateDir.statusFile);
|
|
1347
2401
|
} catch {}
|
|
1348
2402
|
}
|
|
1349
2403
|
markKilled() {
|
|
@@ -1353,11 +2407,11 @@ class DaemonLifecycle {
|
|
|
1353
2407
|
}
|
|
1354
2408
|
clearKilled() {
|
|
1355
2409
|
try {
|
|
1356
|
-
|
|
2410
|
+
unlinkSync2(this.stateDir.killedFile);
|
|
1357
2411
|
} catch {}
|
|
1358
2412
|
}
|
|
1359
2413
|
wasKilled() {
|
|
1360
|
-
return
|
|
2414
|
+
return existsSync3(this.stateDir.killedFile);
|
|
1361
2415
|
}
|
|
1362
2416
|
launch() {
|
|
1363
2417
|
this.stateDir.ensure();
|
|
@@ -1378,45 +2432,99 @@ class DaemonLifecycle {
|
|
|
1378
2432
|
this.log("Removing stale pid file");
|
|
1379
2433
|
this.removePidFile();
|
|
1380
2434
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2435
|
+
async replaceUnhealthyDaemon(statusPid) {
|
|
2436
|
+
await this.withStartupLockStrict(async (locked) => {
|
|
2437
|
+
if (!locked) {
|
|
2438
|
+
this.log("Another process holds the startup lock, waiting for readiness+identity...");
|
|
2439
|
+
await this.waitForReadyAndOurs();
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
if (await this.isHealthy()) {
|
|
2443
|
+
const status = await this.fetchStatus();
|
|
2444
|
+
if (!this.isForeignDaemon(status) && (!this.isBuildDrifted(status) || this.canReuseDespiteDrift(status))) {
|
|
2445
|
+
try {
|
|
2446
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2447
|
+
return;
|
|
2448
|
+
} catch {}
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
this.log(`Killing unhealthy daemon on control port ${this.controlPort} and relaunching`);
|
|
2452
|
+
await this.kill(3000, statusPid);
|
|
2453
|
+
this.launch();
|
|
2454
|
+
await this.waitForReady();
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
async withStartupLockStrict(fn) {
|
|
2458
|
+
const locked = this.acquireLockStrict();
|
|
2459
|
+
try {
|
|
2460
|
+
return await fn(locked);
|
|
2461
|
+
} finally {
|
|
2462
|
+
if (locked)
|
|
2463
|
+
this.releaseLock();
|
|
1385
2464
|
}
|
|
2465
|
+
}
|
|
2466
|
+
acquireLockStrict(reclaimed = false) {
|
|
1386
2467
|
this.stateDir.ensure();
|
|
2468
|
+
let fd = null;
|
|
1387
2469
|
try {
|
|
1388
|
-
|
|
2470
|
+
fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
1389
2471
|
writeFileSync(fd, `${process.pid}
|
|
1390
2472
|
`);
|
|
1391
2473
|
closeSync(fd);
|
|
1392
2474
|
return true;
|
|
1393
2475
|
} catch (err) {
|
|
2476
|
+
if (fd !== null && err.code !== "EEXIST") {
|
|
2477
|
+
try {
|
|
2478
|
+
closeSync(fd);
|
|
2479
|
+
} catch {}
|
|
2480
|
+
this.releaseLock();
|
|
2481
|
+
}
|
|
1394
2482
|
if (err.code === "EEXIST") {
|
|
2483
|
+
if (reclaimed)
|
|
2484
|
+
return false;
|
|
1395
2485
|
try {
|
|
1396
2486
|
const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
|
|
1397
2487
|
if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
|
|
1398
|
-
this.log(`Stale lock
|
|
2488
|
+
this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
|
|
1399
2489
|
this.releaseLock();
|
|
1400
|
-
return this.
|
|
2490
|
+
return this.acquireLockStrict(true);
|
|
2491
|
+
}
|
|
2492
|
+
if (Number.isFinite(holderPid) && this.lockAgeMs() > LOCK_IDENTITY_GRACE_MS && !this.isAgentBridgeProcess(holderPid)) {
|
|
2493
|
+
this.log(`Startup lock is ${Math.round(this.lockAgeMs() / 1000)}s old and holder pid ${holderPid} ` + `is an unrelated process (pid recycled), reclaiming`);
|
|
2494
|
+
this.releaseLock();
|
|
2495
|
+
return this.acquireLockStrict(true);
|
|
1401
2496
|
}
|
|
1402
2497
|
} catch {
|
|
1403
|
-
|
|
1404
|
-
this.releaseLock();
|
|
1405
|
-
return this.acquireLock(depth + 1);
|
|
2498
|
+
return false;
|
|
1406
2499
|
}
|
|
1407
2500
|
return false;
|
|
1408
2501
|
}
|
|
1409
|
-
this.log(`
|
|
1410
|
-
return
|
|
2502
|
+
this.log(`Could not acquire strict startup lock: ${err.message}`);
|
|
2503
|
+
return false;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
lockAgeMs() {
|
|
2507
|
+
try {
|
|
2508
|
+
return Date.now() - statSync2(this.stateDir.lockFile).mtimeMs;
|
|
2509
|
+
} catch {
|
|
2510
|
+
return 0;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
isAgentBridgeProcess(pid) {
|
|
2514
|
+
try {
|
|
2515
|
+
const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
2516
|
+
return cmd.includes("agentbridge") || cmd.includes("agent_bridge");
|
|
2517
|
+
} catch {
|
|
2518
|
+
return false;
|
|
1411
2519
|
}
|
|
1412
2520
|
}
|
|
1413
2521
|
releaseLock() {
|
|
1414
2522
|
try {
|
|
1415
|
-
|
|
2523
|
+
unlinkSync2(this.stateDir.lockFile);
|
|
1416
2524
|
} catch {}
|
|
1417
2525
|
}
|
|
1418
|
-
async kill(gracefulTimeoutMs = 3000) {
|
|
1419
|
-
const pid = this.readPid();
|
|
2526
|
+
async kill(gracefulTimeoutMs = 3000, pidOverride) {
|
|
2527
|
+
const pid = pidOverride ?? this.readPid();
|
|
1420
2528
|
if (!pid) {
|
|
1421
2529
|
this.log("No daemon pid file found");
|
|
1422
2530
|
this.cleanup();
|
|
@@ -1457,8 +2565,10 @@ class DaemonLifecycle {
|
|
|
1457
2565
|
}
|
|
1458
2566
|
isDaemonProcess(pid) {
|
|
1459
2567
|
try {
|
|
1460
|
-
const cmd =
|
|
1461
|
-
|
|
2568
|
+
const cmd = execFileSync2("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
2569
|
+
const hasDaemonEntry = /(?:^|[\s/\\])[\w.-]*-?daemon\.(?:ts|js)(?:\s|$)/.test(cmd);
|
|
2570
|
+
const hasAgentbridge = cmd.includes("agentbridge") || cmd.includes("agent_bridge");
|
|
2571
|
+
return hasDaemonEntry && hasAgentbridge;
|
|
1462
2572
|
} catch {
|
|
1463
2573
|
return false;
|
|
1464
2574
|
}
|
|
@@ -1466,7 +2576,15 @@ class DaemonLifecycle {
|
|
|
1466
2576
|
cleanup() {
|
|
1467
2577
|
this.removePidFile();
|
|
1468
2578
|
this.removeStatusFile();
|
|
1469
|
-
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
async function fetchWithTimeout(url, timeoutMs = HEALTH_FETCH_TIMEOUT_MS) {
|
|
2582
|
+
const controller = new AbortController;
|
|
2583
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2584
|
+
try {
|
|
2585
|
+
return await fetch(url, { signal: controller.signal });
|
|
2586
|
+
} finally {
|
|
2587
|
+
clearTimeout(timer);
|
|
1470
2588
|
}
|
|
1471
2589
|
}
|
|
1472
2590
|
function isProcessAlive(pid) {
|
|
@@ -1478,183 +2596,1261 @@ function isProcessAlive(pid) {
|
|
|
1478
2596
|
}
|
|
1479
2597
|
}
|
|
1480
2598
|
|
|
1481
|
-
// src/
|
|
1482
|
-
import { mkdirSync, existsSync as
|
|
1483
|
-
import { join } from "path";
|
|
1484
|
-
|
|
2599
|
+
// src/config-service.ts
|
|
2600
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, existsSync as existsSync4 } from "fs";
|
|
2601
|
+
import { join as join3 } from "path";
|
|
2602
|
+
var DEFAULT_BUDGET_CONFIG = {
|
|
2603
|
+
enabled: true,
|
|
2604
|
+
pollSeconds: 60,
|
|
2605
|
+
pauseAt: 90,
|
|
2606
|
+
resumeBelow: 30,
|
|
2607
|
+
syncDriftPct: 10,
|
|
2608
|
+
parallel: {
|
|
2609
|
+
minRemainingPct: 60,
|
|
2610
|
+
timeWindowSec: 3600
|
|
2611
|
+
},
|
|
2612
|
+
codexTierControl: false,
|
|
2613
|
+
codexTiers: {
|
|
2614
|
+
full: null,
|
|
2615
|
+
balanced: { effort: "medium" },
|
|
2616
|
+
eco: { effort: "low" }
|
|
2617
|
+
}
|
|
2618
|
+
};
|
|
2619
|
+
var DEFAULT_CONFIG = {
|
|
2620
|
+
version: "1.0",
|
|
2621
|
+
codex: {
|
|
2622
|
+
appPort: 4500,
|
|
2623
|
+
proxyPort: 4501
|
|
2624
|
+
},
|
|
2625
|
+
turnCoordination: {
|
|
2626
|
+
attentionWindowSeconds: 15
|
|
2627
|
+
},
|
|
2628
|
+
idleShutdownSeconds: 30,
|
|
2629
|
+
budget: DEFAULT_BUDGET_CONFIG
|
|
2630
|
+
};
|
|
2631
|
+
var CONFIG_DIR = ".agentbridge";
|
|
2632
|
+
var CONFIG_FILE = "config.json";
|
|
2633
|
+
function isRecord(value) {
|
|
2634
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2635
|
+
}
|
|
2636
|
+
function normalizeInteger(value, fallback) {
|
|
2637
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
2638
|
+
return value;
|
|
2639
|
+
if (typeof value === "string") {
|
|
2640
|
+
const parsed = Number(value);
|
|
2641
|
+
if (Number.isFinite(parsed))
|
|
2642
|
+
return parsed;
|
|
2643
|
+
}
|
|
2644
|
+
return fallback;
|
|
2645
|
+
}
|
|
2646
|
+
function normalizeBoundedInteger(value, fallback, min, max) {
|
|
2647
|
+
const parsed = normalizeInteger(value, fallback);
|
|
2648
|
+
if (parsed < min || parsed > max)
|
|
2649
|
+
return fallback;
|
|
2650
|
+
return parsed;
|
|
2651
|
+
}
|
|
2652
|
+
function normalizeBoolean(value, fallback) {
|
|
2653
|
+
if (typeof value === "boolean")
|
|
2654
|
+
return value;
|
|
2655
|
+
if (value === "true" || value === "1")
|
|
2656
|
+
return true;
|
|
2657
|
+
if (value === "false" || value === "0")
|
|
2658
|
+
return false;
|
|
2659
|
+
return fallback;
|
|
2660
|
+
}
|
|
2661
|
+
function normalizeCodexOverride(raw) {
|
|
2662
|
+
if (!isRecord(raw))
|
|
2663
|
+
return null;
|
|
2664
|
+
const override = {};
|
|
2665
|
+
if (typeof raw.model === "string" && raw.model.trim() !== "")
|
|
2666
|
+
override.model = raw.model.trim();
|
|
2667
|
+
if (typeof raw.effort === "string" && raw.effort.trim() !== "")
|
|
2668
|
+
override.effort = raw.effort.trim();
|
|
2669
|
+
return Object.keys(override).length > 0 ? override : null;
|
|
2670
|
+
}
|
|
2671
|
+
function normalizeCodexTiers(raw) {
|
|
2672
|
+
const tiers = isRecord(raw) ? raw : {};
|
|
2673
|
+
return {
|
|
2674
|
+
full: normalizeCodexOverride(tiers.full),
|
|
2675
|
+
balanced: normalizeCodexOverride(tiers.balanced) ?? DEFAULT_BUDGET_CONFIG.codexTiers.balanced,
|
|
2676
|
+
eco: normalizeCodexOverride(tiers.eco) ?? DEFAULT_BUDGET_CONFIG.codexTiers.eco
|
|
2677
|
+
};
|
|
2678
|
+
}
|
|
2679
|
+
function normalizeBudgetConfig(raw) {
|
|
2680
|
+
const budget = isRecord(raw) ? raw : {};
|
|
2681
|
+
const parallel = isRecord(budget.parallel) ? budget.parallel : {};
|
|
2682
|
+
const codexTiers = normalizeCodexTiers(budget.codexTiers);
|
|
2683
|
+
let pauseAt = normalizeBoundedInteger(budget.pauseAt, DEFAULT_BUDGET_CONFIG.pauseAt, 1, 100);
|
|
2684
|
+
let resumeBelow = normalizeBoundedInteger(budget.resumeBelow, DEFAULT_BUDGET_CONFIG.resumeBelow, 0, 99);
|
|
2685
|
+
if (pauseAt <= resumeBelow) {
|
|
2686
|
+
pauseAt = DEFAULT_BUDGET_CONFIG.pauseAt;
|
|
2687
|
+
resumeBelow = DEFAULT_BUDGET_CONFIG.resumeBelow;
|
|
2688
|
+
}
|
|
2689
|
+
return {
|
|
2690
|
+
enabled: normalizeBoolean(budget.enabled, DEFAULT_BUDGET_CONFIG.enabled),
|
|
2691
|
+
pollSeconds: normalizeBoundedInteger(budget.pollSeconds, DEFAULT_BUDGET_CONFIG.pollSeconds, 5, 3600),
|
|
2692
|
+
pauseAt,
|
|
2693
|
+
resumeBelow,
|
|
2694
|
+
syncDriftPct: normalizeBoundedInteger(budget.syncDriftPct, DEFAULT_BUDGET_CONFIG.syncDriftPct, 1, 100),
|
|
2695
|
+
parallel: {
|
|
2696
|
+
minRemainingPct: normalizeBoundedInteger(parallel.minRemainingPct, DEFAULT_BUDGET_CONFIG.parallel.minRemainingPct, 1, 100),
|
|
2697
|
+
timeWindowSec: normalizeBoundedInteger(parallel.timeWindowSec, DEFAULT_BUDGET_CONFIG.parallel.timeWindowSec, 60, 604800)
|
|
2698
|
+
},
|
|
2699
|
+
codexTierControl: normalizeBoolean(budget.codexTierControl, DEFAULT_BUDGET_CONFIG.codexTierControl) && codexTiers.full !== null,
|
|
2700
|
+
codexTiers
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
function applyBudgetEnvOverrides(budget, env = process.env) {
|
|
2704
|
+
const overlay = {
|
|
2705
|
+
enabled: env.AGENTBRIDGE_BUDGET_ENABLED ?? budget.enabled,
|
|
2706
|
+
pollSeconds: env.AGENTBRIDGE_BUDGET_POLL_SECONDS ?? budget.pollSeconds,
|
|
2707
|
+
pauseAt: env.AGENTBRIDGE_BUDGET_PAUSE_AT ?? budget.pauseAt,
|
|
2708
|
+
resumeBelow: env.AGENTBRIDGE_BUDGET_RESUME_BELOW ?? budget.resumeBelow,
|
|
2709
|
+
syncDriftPct: env.AGENTBRIDGE_BUDGET_SYNC_DRIFT_PCT ?? budget.syncDriftPct,
|
|
2710
|
+
parallel: {
|
|
2711
|
+
minRemainingPct: env.AGENTBRIDGE_BUDGET_PARALLEL_MIN_REMAINING_PCT ?? budget.parallel.minRemainingPct,
|
|
2712
|
+
timeWindowSec: env.AGENTBRIDGE_BUDGET_PARALLEL_TIME_WINDOW_SEC ?? budget.parallel.timeWindowSec
|
|
2713
|
+
},
|
|
2714
|
+
codexTierControl: env.AGENTBRIDGE_BUDGET_CODEX_TIER_CONTROL ?? budget.codexTierControl,
|
|
2715
|
+
codexTiers: budget.codexTiers
|
|
2716
|
+
};
|
|
2717
|
+
return normalizeBudgetConfig(overlay);
|
|
2718
|
+
}
|
|
2719
|
+
function normalizeConfig(raw) {
|
|
2720
|
+
if (!isRecord(raw))
|
|
2721
|
+
return null;
|
|
2722
|
+
const config = raw;
|
|
2723
|
+
const codex = isRecord(config.codex) ? config.codex : {};
|
|
2724
|
+
const daemon = isRecord(config.daemon) ? config.daemon : {};
|
|
2725
|
+
const turnCoordination = isRecord(config.turnCoordination) ? config.turnCoordination : {};
|
|
2726
|
+
return {
|
|
2727
|
+
version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version,
|
|
2728
|
+
codex: {
|
|
2729
|
+
appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
|
|
2730
|
+
proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
|
|
2731
|
+
},
|
|
2732
|
+
turnCoordination: {
|
|
2733
|
+
attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
|
|
2734
|
+
},
|
|
2735
|
+
idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds),
|
|
2736
|
+
budget: normalizeBudgetConfig(config.budget)
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
class ConfigService {
|
|
2741
|
+
configDir;
|
|
2742
|
+
configPath;
|
|
2743
|
+
constructor(projectRoot) {
|
|
2744
|
+
const root = projectRoot ?? process.cwd();
|
|
2745
|
+
this.configDir = join3(root, CONFIG_DIR);
|
|
2746
|
+
this.configPath = join3(this.configDir, CONFIG_FILE);
|
|
2747
|
+
}
|
|
2748
|
+
hasConfig() {
|
|
2749
|
+
return existsSync4(this.configPath);
|
|
2750
|
+
}
|
|
2751
|
+
load() {
|
|
2752
|
+
try {
|
|
2753
|
+
const raw = readFileSync2(this.configPath, "utf-8");
|
|
2754
|
+
return normalizeConfig(JSON.parse(raw));
|
|
2755
|
+
} catch {
|
|
2756
|
+
return null;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
loadOrDefault() {
|
|
2760
|
+
return this.load() ?? structuredClone(DEFAULT_CONFIG);
|
|
2761
|
+
}
|
|
2762
|
+
save(config) {
|
|
2763
|
+
this.ensureConfigDir();
|
|
2764
|
+
writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
|
|
2765
|
+
`, "utf-8");
|
|
2766
|
+
}
|
|
2767
|
+
initDefaults() {
|
|
2768
|
+
this.ensureConfigDir();
|
|
2769
|
+
const created = [];
|
|
2770
|
+
if (!existsSync4(this.configPath)) {
|
|
2771
|
+
this.save(DEFAULT_CONFIG);
|
|
2772
|
+
created.push(this.configPath);
|
|
2773
|
+
}
|
|
2774
|
+
return created;
|
|
2775
|
+
}
|
|
2776
|
+
get configFilePath() {
|
|
2777
|
+
return this.configPath;
|
|
2778
|
+
}
|
|
2779
|
+
ensureConfigDir() {
|
|
2780
|
+
if (!existsSync4(this.configDir)) {
|
|
2781
|
+
mkdirSync3(this.configDir, { recursive: true });
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// src/budget/types.ts
|
|
2787
|
+
var STALE_MAX_AGE_SEC = 600;
|
|
2788
|
+
|
|
2789
|
+
// src/budget/budget-state.ts
|
|
2790
|
+
var AGENT_LABEL = {
|
|
2791
|
+
claude: "Claude",
|
|
2792
|
+
codex: "Codex"
|
|
2793
|
+
};
|
|
2794
|
+
var CODEX_BALANCED_WARN_UTIL = 60;
|
|
2795
|
+
var CODEX_ECO_WARN_UTIL = 80;
|
|
2796
|
+
var CLAUDE_ADVICE_WARN_UTIL = 80;
|
|
2797
|
+
function pct(value) {
|
|
2798
|
+
return `${Math.round(value * 10) / 10}%`;
|
|
2799
|
+
}
|
|
2800
|
+
function formatEpoch(epoch) {
|
|
2801
|
+
if (!epoch || epoch <= 0)
|
|
2802
|
+
return "\u672A\u77E5";
|
|
2803
|
+
return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
|
|
2804
|
+
}
|
|
2805
|
+
function usageSummary(name, usage) {
|
|
2806
|
+
if (!usage)
|
|
2807
|
+
return `${AGENT_LABEL[name]} \u672A\u77E5`;
|
|
2808
|
+
return `${AGENT_LABEL[name]} gate=${pct(usage.gateUtil)} warn=${pct(usage.warnUtil)} 5h\u91CD\u7F6E=${formatEpoch(usage.fiveHour?.resetEpoch ?? 0)}`;
|
|
2809
|
+
}
|
|
2810
|
+
function matchingGateReset(usage) {
|
|
2811
|
+
if (!usage)
|
|
2812
|
+
return 0;
|
|
2813
|
+
const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
|
|
2814
|
+
const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
|
|
2815
|
+
const candidates = matching.length > 0 ? matching : windows;
|
|
2816
|
+
if (candidates.length === 0)
|
|
2817
|
+
return 0;
|
|
2818
|
+
return Math.min(...candidates.map((window) => window.resetEpoch));
|
|
2819
|
+
}
|
|
2820
|
+
function resumeBlockingEpoch(usage, cfg, now) {
|
|
2821
|
+
if (!usage)
|
|
2822
|
+
return 0;
|
|
2823
|
+
if (usage.rateLimitedUntil > now)
|
|
2824
|
+
return usage.rateLimitedUntil;
|
|
2825
|
+
if (usage.gateUtil >= cfg.resumeBelow)
|
|
2826
|
+
return matchingGateReset(usage);
|
|
2827
|
+
return 0;
|
|
2828
|
+
}
|
|
2829
|
+
function resumeAfterEpoch(claude, codex, cfg, now) {
|
|
2830
|
+
const epochs = [
|
|
2831
|
+
resumeBlockingEpoch(claude, cfg, now),
|
|
2832
|
+
resumeBlockingEpoch(codex, cfg, now)
|
|
2833
|
+
].filter((epoch) => epoch > 0);
|
|
2834
|
+
if (epochs.length === 0)
|
|
2835
|
+
return null;
|
|
2836
|
+
return Math.max(...epochs);
|
|
2837
|
+
}
|
|
2838
|
+
function isDecisionGrade(usage, now) {
|
|
2839
|
+
if (!usage)
|
|
2840
|
+
return false;
|
|
2841
|
+
const freshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
|
|
2842
|
+
if (!freshWindow)
|
|
2843
|
+
return false;
|
|
2844
|
+
if (usage.fetchedAt > 0 && now - usage.fetchedAt > STALE_MAX_AGE_SEC)
|
|
2845
|
+
return false;
|
|
2846
|
+
return true;
|
|
2847
|
+
}
|
|
2848
|
+
function pauseTrigger(agent, usage, cfg, now) {
|
|
2849
|
+
if (!usage)
|
|
2850
|
+
return null;
|
|
2851
|
+
if (!isDecisionGrade(usage, now))
|
|
2852
|
+
return null;
|
|
2853
|
+
if (usage.gateUtil >= cfg.pauseAt) {
|
|
2854
|
+
return {
|
|
2855
|
+
agent,
|
|
2856
|
+
reason: `${AGENT_LABEL[agent]} gateUtil ${pct(usage.gateUtil)} \u2265 pauseAt ${pct(cfg.pauseAt)}`
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
return null;
|
|
2860
|
+
}
|
|
2861
|
+
function driftFor(claude, codex, cfg) {
|
|
2862
|
+
if (!claude || !codex)
|
|
2863
|
+
return { pct: 0, heavier: null, lighter: null };
|
|
2864
|
+
const drift = Math.round((claude.warnUtil - codex.warnUtil) * 10) / 10;
|
|
2865
|
+
if (Math.abs(drift) <= cfg.syncDriftPct) {
|
|
2866
|
+
return { pct: drift, heavier: null, lighter: null };
|
|
2867
|
+
}
|
|
2868
|
+
return {
|
|
2869
|
+
pct: drift,
|
|
2870
|
+
heavier: drift > 0 ? "claude" : "codex",
|
|
2871
|
+
lighter: drift > 0 ? "codex" : "claude"
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
function parallelState(claude, codex, cfg, now) {
|
|
2875
|
+
if (!claude || !codex)
|
|
2876
|
+
return { recommended: false, reason: null };
|
|
2877
|
+
if (claude.remaining <= cfg.parallel.minRemainingPct || codex.remaining <= cfg.parallel.minRemainingPct) {
|
|
2878
|
+
return { recommended: false, reason: null };
|
|
2879
|
+
}
|
|
2880
|
+
const claudeReset = claude.fiveHour?.resetEpoch ?? 0;
|
|
2881
|
+
const codexReset = codex.fiveHour?.resetEpoch ?? 0;
|
|
2882
|
+
if (claudeReset <= now || codexReset <= now)
|
|
2883
|
+
return { recommended: false, reason: null };
|
|
2884
|
+
const nearestResetSec = Math.min(claudeReset - now, codexReset - now);
|
|
2885
|
+
if (nearestResetSec >= cfg.parallel.timeWindowSec)
|
|
2886
|
+
return { recommended: false, reason: null };
|
|
2887
|
+
const minutes = Math.ceil(nearestResetSec / 60);
|
|
2888
|
+
return {
|
|
2889
|
+
recommended: true,
|
|
2890
|
+
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`
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
function renderBudgetInterventionDirective(claude, codex, side, reason, resumeEpoch, cfg) {
|
|
2894
|
+
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`;
|
|
2895
|
+
if (side === "claude") {
|
|
2896
|
+
return [
|
|
2897
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u8FDB\u5165\u63A5\u529B\u6A21\u5F0F\u3002",
|
|
2898
|
+
`\u89E6\u53D1\u65B9\uFF1AClaude\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
|
|
2899
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2900
|
+
`\u6062\u590D\u53C2\u8003\uFF1AClaude gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
|
|
2901
|
+
"\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",
|
|
2902
|
+
"\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"
|
|
2903
|
+
].join(`
|
|
2904
|
+
`);
|
|
2905
|
+
}
|
|
2906
|
+
if (side === "codex") {
|
|
2907
|
+
return [
|
|
2908
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u989D\u5EA6\u7D27\u5F20\uFF0C\u6682\u505C\u59D4\u6D3E\u3002",
|
|
2909
|
+
`\u89E6\u53D1\u65B9\uFF1ACodex\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
|
|
2910
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2911
|
+
`\u6062\u590D\u53C2\u8003\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct(cfg.resumeBelow)} \u4E14\u6CA1\u6709\u6709\u6548 rate_limit\uFF1B${resumeText}`,
|
|
2912
|
+
"\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"
|
|
2913
|
+
].join(`
|
|
2914
|
+
`);
|
|
2915
|
+
}
|
|
2916
|
+
return [
|
|
2917
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8FDB\u5165\u8054\u5408\u6682\u505C\u3002",
|
|
2918
|
+
`\u89E6\u53D1\u65B9\uFF1A\u53CC\u65B9\uFF1B\u539F\u56E0\uFF1A${reason}\u3002`,
|
|
2919
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2920
|
+
`\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}`,
|
|
2921
|
+
"\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"
|
|
2922
|
+
].join(`
|
|
2923
|
+
`);
|
|
2924
|
+
}
|
|
2925
|
+
function balanceDirective(claude, codex, drift, parallel) {
|
|
2926
|
+
const heavier = drift.heavier ? AGENT_LABEL[drift.heavier] : "\u672A\u77E5";
|
|
2927
|
+
const lighter = drift.lighter ? AGENT_LABEL[drift.lighter] : "\u672A\u77E5";
|
|
2928
|
+
const lines = [
|
|
2929
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u68C0\u6D4B\u5230\u53CC\u65B9\u7528\u91CF\u6BD4\u4F8B\u6F02\u79FB\u3002",
|
|
2930
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2931
|
+
`${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`
|
|
2932
|
+
];
|
|
2933
|
+
if (parallel.recommended && parallel.reason) {
|
|
2934
|
+
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`);
|
|
2935
|
+
}
|
|
2936
|
+
return lines.join(`
|
|
2937
|
+
`);
|
|
2938
|
+
}
|
|
2939
|
+
function parallelDirective(claude, codex, parallel) {
|
|
2940
|
+
return [
|
|
2941
|
+
"\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",
|
|
2942
|
+
`${usageSummary("claude", claude)}\uFF1B${usageSummary("codex", codex)}\u3002`,
|
|
2943
|
+
`${parallel.reason}\uFF1B\u53EF\u4EE5\u62C6\u66F4\u591A\u72EC\u7ACB\u5B50\u4EFB\u52A1\u5E76\u884C\u63A8\u8FDB\u3002`
|
|
2944
|
+
].join(`
|
|
2945
|
+
`);
|
|
2946
|
+
}
|
|
2947
|
+
function codexTierFor(codex, now) {
|
|
2948
|
+
if (!codex || !isDecisionGrade(codex, now))
|
|
2949
|
+
return "full";
|
|
2950
|
+
if (codex.warnUtil >= CODEX_ECO_WARN_UTIL)
|
|
2951
|
+
return "eco";
|
|
2952
|
+
if (codex.warnUtil >= CODEX_BALANCED_WARN_UTIL)
|
|
2953
|
+
return "balanced";
|
|
2954
|
+
return "full";
|
|
2955
|
+
}
|
|
2956
|
+
function claudeAdviceFor(claude, now) {
|
|
2957
|
+
if (!claude || !isDecisionGrade(claude, now))
|
|
2958
|
+
return null;
|
|
2959
|
+
if (claude.warnUtil < CLAUDE_ADVICE_WARN_UTIL)
|
|
2960
|
+
return null;
|
|
2961
|
+
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`;
|
|
2962
|
+
}
|
|
2963
|
+
function computeBudgetState(claude, codex, cfg, now) {
|
|
2964
|
+
const triggers = [
|
|
2965
|
+
pauseTrigger("claude", claude, cfg, now),
|
|
2966
|
+
pauseTrigger("codex", codex, cfg, now)
|
|
2967
|
+
].filter((trigger) => trigger !== null);
|
|
2968
|
+
const paused = triggers.length > 0;
|
|
2969
|
+
const drift = driftFor(claude, codex, cfg);
|
|
2970
|
+
const parallel = paused ? { recommended: false, reason: null } : parallelState(claude, codex, cfg, now);
|
|
2971
|
+
const resetEpochs = {
|
|
2972
|
+
claude: matchingGateReset(claude),
|
|
2973
|
+
codex: matchingGateReset(codex)
|
|
2974
|
+
};
|
|
2975
|
+
const filteredResumeAfterEpoch = paused ? resumeAfterEpoch(claude, codex, cfg, now) : null;
|
|
2976
|
+
let phase = "normal";
|
|
2977
|
+
if (paused)
|
|
2978
|
+
phase = "paused";
|
|
2979
|
+
else if (drift.heavier && drift.lighter)
|
|
2980
|
+
phase = "balance";
|
|
2981
|
+
else if (parallel.recommended)
|
|
2982
|
+
phase = "parallel";
|
|
2983
|
+
const pauseSide = !paused ? null : triggers.length > 1 ? "both" : triggers[0].agent;
|
|
2984
|
+
let directiveToClaude = null;
|
|
2985
|
+
if (phase === "paused") {
|
|
2986
|
+
directiveToClaude = renderBudgetInterventionDirective(claude, codex, pauseSide ?? "both", triggers.map((trigger) => trigger.reason).join("\uFF1B"), filteredResumeAfterEpoch, cfg);
|
|
2987
|
+
} else if (phase === "balance" && claude && codex) {
|
|
2988
|
+
directiveToClaude = balanceDirective(claude, codex, drift, parallel);
|
|
2989
|
+
} else if (phase === "parallel" && claude && codex) {
|
|
2990
|
+
directiveToClaude = parallelDirective(claude, codex, parallel);
|
|
2991
|
+
}
|
|
2992
|
+
return {
|
|
2993
|
+
phase,
|
|
2994
|
+
now,
|
|
2995
|
+
perAgent: { claude, codex },
|
|
2996
|
+
drift,
|
|
2997
|
+
pause: {
|
|
2998
|
+
active: paused,
|
|
2999
|
+
side: pauseSide,
|
|
3000
|
+
reason: paused ? triggers.map((trigger) => trigger.reason).join("\uFF1B") : null,
|
|
3001
|
+
resumeBelow: cfg.resumeBelow,
|
|
3002
|
+
resumeAfterEpoch: filteredResumeAfterEpoch,
|
|
3003
|
+
resetEpochs
|
|
3004
|
+
},
|
|
3005
|
+
parallel,
|
|
3006
|
+
effort: { claudeAdvice: claudeAdviceFor(claude, now), codexTier: codexTierFor(codex, now) },
|
|
3007
|
+
directiveToClaude
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
// src/budget/budget-coordinator.ts
|
|
3012
|
+
var RESET_FINGERPRINT_BUCKET_SEC = 600;
|
|
3013
|
+
var AGENT_LABEL2 = {
|
|
3014
|
+
claude: "Claude",
|
|
3015
|
+
codex: "Codex"
|
|
3016
|
+
};
|
|
3017
|
+
function pct2(value) {
|
|
3018
|
+
return `${Math.round(value * 10) / 10}%`;
|
|
3019
|
+
}
|
|
3020
|
+
function usageLine(agent, usage) {
|
|
3021
|
+
if (!usage)
|
|
3022
|
+
return `${AGENT_LABEL2[agent]} \u672A\u77E5`;
|
|
3023
|
+
return `${AGENT_LABEL2[agent]} gate=${pct2(usage.gateUtil)} warn=${pct2(usage.warnUtil)}`;
|
|
3024
|
+
}
|
|
3025
|
+
function matchingGateReset2(usage) {
|
|
3026
|
+
if (!usage)
|
|
3027
|
+
return 0;
|
|
3028
|
+
const windows = [usage.fiveHour, usage.weekly].filter((window) => !!window && window.resetEpoch > 0);
|
|
3029
|
+
const matching = windows.filter((window) => Math.abs(window.util - usage.gateUtil) < 0.0001);
|
|
3030
|
+
const candidates = matching.length > 0 ? matching : windows;
|
|
3031
|
+
if (candidates.length === 0)
|
|
3032
|
+
return 0;
|
|
3033
|
+
return Math.min(...candidates.map((window) => window.resetEpoch));
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
class BudgetCoordinator {
|
|
3037
|
+
source;
|
|
3038
|
+
config;
|
|
3039
|
+
emit;
|
|
3040
|
+
onPauseChange;
|
|
3041
|
+
now;
|
|
3042
|
+
log;
|
|
3043
|
+
timer = null;
|
|
3044
|
+
running = false;
|
|
3045
|
+
activeSides = new Set;
|
|
3046
|
+
lastDirectiveFingerprint = null;
|
|
3047
|
+
latestSnapshot = null;
|
|
3048
|
+
pauseReason = null;
|
|
3049
|
+
pauseResumeAfterEpoch = null;
|
|
3050
|
+
pendingOverrideTier = null;
|
|
3051
|
+
pendingOverrides = null;
|
|
3052
|
+
lastAppliedTier = "full";
|
|
3053
|
+
missingFullMappingLogged = false;
|
|
3054
|
+
sequence = 0;
|
|
3055
|
+
constructor(options) {
|
|
3056
|
+
this.source = options.source;
|
|
3057
|
+
this.config = options.config;
|
|
3058
|
+
this.emit = options.emit;
|
|
3059
|
+
this.onPauseChange = options.onPauseChange;
|
|
3060
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
3061
|
+
this.log = options.log ?? (() => {});
|
|
3062
|
+
}
|
|
3063
|
+
async start() {
|
|
3064
|
+
if (this.running || !this.config.enabled)
|
|
3065
|
+
return;
|
|
3066
|
+
this.running = true;
|
|
3067
|
+
await this.pollOnce();
|
|
3068
|
+
if (this.running)
|
|
3069
|
+
this.scheduleNext();
|
|
3070
|
+
}
|
|
3071
|
+
stop() {
|
|
3072
|
+
this.running = false;
|
|
3073
|
+
if (this.timer) {
|
|
3074
|
+
clearTimeout(this.timer);
|
|
3075
|
+
this.timer = null;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
isPaused() {
|
|
3079
|
+
return this.activeSides.size > 0;
|
|
3080
|
+
}
|
|
3081
|
+
isGateClosed() {
|
|
3082
|
+
return this.activeSides.has("codex");
|
|
3083
|
+
}
|
|
3084
|
+
getSnapshot() {
|
|
3085
|
+
return this.latestSnapshot;
|
|
3086
|
+
}
|
|
3087
|
+
getCodexTurnOverrides() {
|
|
3088
|
+
if (!this.tierControlEnabled())
|
|
3089
|
+
return null;
|
|
3090
|
+
return this.pendingOverrides ? { ...this.pendingOverrides } : null;
|
|
3091
|
+
}
|
|
3092
|
+
notifyOverridesDelivered() {
|
|
3093
|
+
if (!this.pendingOverrideTier)
|
|
3094
|
+
return;
|
|
3095
|
+
this.lastAppliedTier = this.pendingOverrideTier;
|
|
3096
|
+
this.pendingOverrideTier = null;
|
|
3097
|
+
this.pendingOverrides = null;
|
|
3098
|
+
}
|
|
3099
|
+
resetAppliedTier() {
|
|
3100
|
+
this.lastAppliedTier = "full";
|
|
3101
|
+
this.pendingOverrideTier = null;
|
|
3102
|
+
this.pendingOverrides = null;
|
|
3103
|
+
}
|
|
3104
|
+
scheduleNext() {
|
|
3105
|
+
if (!this.running)
|
|
3106
|
+
return;
|
|
3107
|
+
if (this.timer)
|
|
3108
|
+
clearTimeout(this.timer);
|
|
3109
|
+
const delayMs = Math.max(0, this.config.pollSeconds * 1000);
|
|
3110
|
+
this.timer = setTimeout(() => {
|
|
3111
|
+
this.timer = null;
|
|
3112
|
+
this.pollAndReschedule();
|
|
3113
|
+
}, delayMs);
|
|
3114
|
+
}
|
|
3115
|
+
async pollAndReschedule() {
|
|
3116
|
+
await this.pollOnce();
|
|
3117
|
+
if (this.running)
|
|
3118
|
+
this.scheduleNext();
|
|
3119
|
+
}
|
|
3120
|
+
async pollOnce() {
|
|
3121
|
+
let usage;
|
|
3122
|
+
try {
|
|
3123
|
+
usage = await this.source.fetchBoth();
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
this.log(`budget coordinator poll failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
if (!usage) {
|
|
3129
|
+
if (!this.isPaused())
|
|
3130
|
+
this.latestSnapshot = null;
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
if (!this.running) {
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
const state = computeBudgetState(usage.claude, usage.codex, this.config, this.now());
|
|
3137
|
+
this.updatePendingOverrides(state.effort.codexTier);
|
|
3138
|
+
this.applyState(state);
|
|
3139
|
+
this.latestSnapshot = this.toSnapshot(state);
|
|
3140
|
+
}
|
|
3141
|
+
applyState(state) {
|
|
3142
|
+
const previousSide = this.pauseSide();
|
|
3143
|
+
this.updateActiveSides(state);
|
|
3144
|
+
const currentSide = this.pauseSide();
|
|
3145
|
+
if (currentSide) {
|
|
3146
|
+
this.pauseReason = this.interventionReason(state);
|
|
3147
|
+
const nextResumeAfterEpoch = this.resumeAfterEpoch(state);
|
|
3148
|
+
this.pauseResumeAfterEpoch = previousSide === currentSide ? nextResumeAfterEpoch ?? this.pauseResumeAfterEpoch : nextResumeAfterEpoch;
|
|
3149
|
+
const fingerprint2 = previousSide === currentSide && this.activeSideProbeUncertain(state) && this.lastDirectiveFingerprint ? this.lastDirectiveFingerprint : this.directiveFingerprint(state, currentSide);
|
|
3150
|
+
if (!previousSide) {
|
|
3151
|
+
this.onPauseChange(true);
|
|
3152
|
+
}
|
|
3153
|
+
if (!previousSide || previousSide !== currentSide || fingerprint2 !== this.lastDirectiveFingerprint) {
|
|
3154
|
+
this.emitDirective(this.interventionPrefix(currentSide), this.interventionDirective(state, currentSide));
|
|
3155
|
+
}
|
|
3156
|
+
this.lastDirectiveFingerprint = fingerprint2;
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (previousSide) {
|
|
3160
|
+
this.pauseReason = null;
|
|
3161
|
+
this.pauseResumeAfterEpoch = null;
|
|
3162
|
+
this.lastDirectiveFingerprint = null;
|
|
3163
|
+
this.onPauseChange(false);
|
|
3164
|
+
this.emitDirective(this.recoveryPrefix(previousSide), this.recoveryDirective(state, previousSide));
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
if (!isDecisionGrade(state.perAgent.claude, state.now) || !isDecisionGrade(state.perAgent.codex, state.now)) {
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
if (!state.directiveToClaude) {
|
|
3171
|
+
this.lastDirectiveFingerprint = null;
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
const fingerprint = this.directiveFingerprint(state);
|
|
3175
|
+
if (fingerprint !== this.lastDirectiveFingerprint) {
|
|
3176
|
+
const prefix = state.phase === "balance" ? "system_budget_balance" : "system_budget_parallel";
|
|
3177
|
+
this.emitDirective(prefix, state.directiveToClaude);
|
|
3178
|
+
this.lastDirectiveFingerprint = fingerprint;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
updateActiveSides(state) {
|
|
3182
|
+
for (const agent of ["claude", "codex"]) {
|
|
3183
|
+
const usage = state.perAgent[agent];
|
|
3184
|
+
if (this.shouldEnter(usage, state.now)) {
|
|
3185
|
+
this.activeSides.add(agent);
|
|
3186
|
+
} else if (this.activeSides.has(agent) && this.canAgentResume(usage, state.now)) {
|
|
3187
|
+
this.activeSides.delete(agent);
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
shouldEnter(usage, now) {
|
|
3192
|
+
if (!isDecisionGrade(usage, now))
|
|
3193
|
+
return false;
|
|
3194
|
+
return usage.gateUtil >= this.config.pauseAt;
|
|
3195
|
+
}
|
|
3196
|
+
canAgentResume(usage, now) {
|
|
3197
|
+
if (!isDecisionGrade(usage, now))
|
|
3198
|
+
return false;
|
|
3199
|
+
if (usage.rateLimitedUntil > now)
|
|
3200
|
+
return false;
|
|
3201
|
+
return usage.gateUtil < this.config.resumeBelow;
|
|
3202
|
+
}
|
|
3203
|
+
resumeAfterEpoch(state) {
|
|
3204
|
+
const epochs = ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.resumeBlockingEpoch(state.perAgent[agent], state.now)).filter((epoch) => epoch > 0);
|
|
3205
|
+
if (epochs.length === 0)
|
|
3206
|
+
return null;
|
|
3207
|
+
return Math.max(...epochs);
|
|
3208
|
+
}
|
|
3209
|
+
resumeBlockingEpoch(usage, now) {
|
|
3210
|
+
if (!usage)
|
|
3211
|
+
return 0;
|
|
3212
|
+
if (usage.rateLimitedUntil > now)
|
|
3213
|
+
return usage.rateLimitedUntil;
|
|
3214
|
+
if (usage.gateUtil >= this.config.resumeBelow)
|
|
3215
|
+
return matchingGateReset2(usage);
|
|
3216
|
+
return 0;
|
|
3217
|
+
}
|
|
3218
|
+
tierControlEnabled() {
|
|
3219
|
+
if (!this.config.codexTierControl)
|
|
3220
|
+
return false;
|
|
3221
|
+
if (this.config.codexTiers.full)
|
|
3222
|
+
return true;
|
|
3223
|
+
if (!this.missingFullMappingLogged) {
|
|
3224
|
+
this.missingFullMappingLogged = true;
|
|
3225
|
+
this.log("Codex tier control disabled: budget.codexTiers.full restore mapping is missing");
|
|
3226
|
+
}
|
|
3227
|
+
return false;
|
|
3228
|
+
}
|
|
3229
|
+
updatePendingOverrides(tier) {
|
|
3230
|
+
if (!this.tierControlEnabled()) {
|
|
3231
|
+
this.pendingOverrideTier = null;
|
|
3232
|
+
this.pendingOverrides = null;
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
if (this.lastAppliedTier === tier) {
|
|
3236
|
+
this.pendingOverrideTier = null;
|
|
3237
|
+
this.pendingOverrides = null;
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
if (this.pendingOverrideTier === tier)
|
|
3241
|
+
return;
|
|
3242
|
+
const overrides = this.config.codexTiers[tier];
|
|
3243
|
+
if (!overrides) {
|
|
3244
|
+
this.pendingOverrideTier = null;
|
|
3245
|
+
this.pendingOverrides = null;
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
this.pendingOverrideTier = tier;
|
|
3249
|
+
this.pendingOverrides = { ...overrides };
|
|
3250
|
+
}
|
|
3251
|
+
directiveFingerprint(state, activeSide) {
|
|
3252
|
+
const side = activeSide ?? (state.phase === "balance" ? state.drift.lighter ?? "none" : state.pause.side ?? "none");
|
|
3253
|
+
let reset = 0;
|
|
3254
|
+
if (activeSide === "claude") {
|
|
3255
|
+
reset = state.pause.resetEpochs.claude;
|
|
3256
|
+
} else if (activeSide === "codex") {
|
|
3257
|
+
reset = state.pause.resetEpochs.codex;
|
|
3258
|
+
} else if (activeSide === "both") {
|
|
3259
|
+
reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
|
|
3260
|
+
} else if (state.phase === "balance" && state.drift.lighter) {
|
|
3261
|
+
reset = state.perAgent[state.drift.lighter]?.fiveHour?.resetEpoch ?? 0;
|
|
3262
|
+
} else if (side === "claude") {
|
|
3263
|
+
reset = state.pause.resetEpochs.claude;
|
|
3264
|
+
} else if (side === "codex") {
|
|
3265
|
+
reset = state.pause.resetEpochs.codex;
|
|
3266
|
+
} else if (side === "both") {
|
|
3267
|
+
reset = Math.max(state.pause.resetEpochs.claude, state.pause.resetEpochs.codex);
|
|
3268
|
+
}
|
|
3269
|
+
return [
|
|
3270
|
+
activeSide ? "paused" : state.phase,
|
|
3271
|
+
state.drift.heavier ?? "none",
|
|
3272
|
+
side,
|
|
3273
|
+
Math.round(reset / RESET_FINGERPRINT_BUCKET_SEC)
|
|
3274
|
+
].join("|");
|
|
3275
|
+
}
|
|
3276
|
+
emitDirective(prefix, content) {
|
|
3277
|
+
this.emit(`${prefix}_${this.sequence++}`, content);
|
|
3278
|
+
}
|
|
3279
|
+
pauseSide() {
|
|
3280
|
+
const claude = this.activeSides.has("claude");
|
|
3281
|
+
const codex = this.activeSides.has("codex");
|
|
3282
|
+
if (claude && codex)
|
|
3283
|
+
return "both";
|
|
3284
|
+
if (claude)
|
|
3285
|
+
return "claude";
|
|
3286
|
+
if (codex)
|
|
3287
|
+
return "codex";
|
|
3288
|
+
return null;
|
|
3289
|
+
}
|
|
3290
|
+
interventionPrefix(side) {
|
|
3291
|
+
return side === "claude" ? "system_budget_handoff" : "system_budget_pause";
|
|
3292
|
+
}
|
|
3293
|
+
recoveryPrefix(previousSide) {
|
|
3294
|
+
return previousSide === "claude" ? "system_budget_claude_recovered" : "system_budget_resume";
|
|
3295
|
+
}
|
|
3296
|
+
interventionDirective(state, side) {
|
|
3297
|
+
return renderBudgetInterventionDirective(state.perAgent.claude, state.perAgent.codex, side, this.pauseReason ?? "\u9884\u7B97\u63A5\u8FD1\u8017\u5C3D", this.pauseResumeAfterEpoch, this.config);
|
|
3298
|
+
}
|
|
3299
|
+
interventionReason(state) {
|
|
3300
|
+
return ["claude", "codex"].filter((agent) => this.activeSides.has(agent)).map((agent) => this.activeSideReason(agent, state.perAgent[agent], state.now)).join("\uFF1B");
|
|
3301
|
+
}
|
|
3302
|
+
activeSideProbeUncertain(state) {
|
|
3303
|
+
return ["claude", "codex"].some((agent) => {
|
|
3304
|
+
if (!this.activeSides.has(agent))
|
|
3305
|
+
return false;
|
|
3306
|
+
const usage = state.perAgent[agent];
|
|
3307
|
+
return usage === null || usage.rateLimitedUntil > state.now || !isDecisionGrade(usage, state.now);
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
activeSideReason(agent, usage, now) {
|
|
3311
|
+
if (!usage)
|
|
3312
|
+
return `${AGENT_LABEL2[agent]} \u63A2\u6D4B\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u4FDD\u6301\u4E0A\u4E00\u8F6E\u9884\u7B97\u5E72\u9884`;
|
|
3313
|
+
if (usage.rateLimitedUntil > now) {
|
|
3314
|
+
return `${AGENT_LABEL2[agent]} \u63A2\u9488\u88AB\u9650\u6D41\u81F3 ${this.formatEpoch(usage.rateLimitedUntil)}`;
|
|
3315
|
+
}
|
|
3316
|
+
if (usage.gateUtil >= this.config.pauseAt) {
|
|
3317
|
+
return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u2265 pauseAt ${pct2(this.config.pauseAt)}`;
|
|
3318
|
+
}
|
|
3319
|
+
return `${AGENT_LABEL2[agent]} gateUtil ${pct2(usage.gateUtil)} \u5C1A\u672A\u4F4E\u4E8E resumeBelow ${pct2(this.config.resumeBelow)}`;
|
|
3320
|
+
}
|
|
3321
|
+
recoveryDirective(state, previousSide) {
|
|
3322
|
+
if (previousSide === "claude") {
|
|
3323
|
+
return [
|
|
3324
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Claude \u4FA7\u9884\u7B97\u5DF2\u6062\u590D\u3002",
|
|
3325
|
+
`${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
|
|
3326
|
+
`Claude gateUtil \u5DF2\u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
|
|
3327
|
+
"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"
|
|
3328
|
+
].join(`
|
|
3329
|
+
`);
|
|
3330
|
+
}
|
|
3331
|
+
if (previousSide === "codex") {
|
|
3332
|
+
return [
|
|
3333
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011Codex \u4FA7\u9884\u7B97\u95F8\u95E8\u89E3\u9664\u3002",
|
|
3334
|
+
`${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
|
|
3335
|
+
`\u95F8\u95E8\u5DF2\u653E\u5F00\uFF1ACodex gateUtil \u4F4E\u4E8E ${pct2(this.config.resumeBelow)}\uFF0C\u4E14\u6CA1\u6709\u6709\u6548 rate_limit\u3002`,
|
|
3336
|
+
"\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"
|
|
3337
|
+
].join(`
|
|
3338
|
+
`);
|
|
3339
|
+
}
|
|
3340
|
+
return [
|
|
3341
|
+
"\u3010\u9884\u7B97\u534F\u8C03 \xB7 \u8D26\u53F7\u7EA7\u3011\u8054\u5408\u6682\u505C\u89E3\u9664\u3002",
|
|
3342
|
+
`${usageLine("claude", state.perAgent.claude)}\uFF1B${usageLine("codex", state.perAgent.codex)}\u3002`,
|
|
3343
|
+
`\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`,
|
|
3344
|
+
"\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"
|
|
3345
|
+
].join(`
|
|
3346
|
+
`);
|
|
3347
|
+
}
|
|
3348
|
+
formatEpoch(epoch) {
|
|
3349
|
+
return new Date(epoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
|
|
3350
|
+
}
|
|
3351
|
+
toSnapshot(state) {
|
|
3352
|
+
const paused = this.isPaused();
|
|
3353
|
+
return {
|
|
3354
|
+
phase: paused ? "paused" : state.phase,
|
|
3355
|
+
updatedAt: state.now,
|
|
3356
|
+
claude: state.perAgent.claude,
|
|
3357
|
+
codex: state.perAgent.codex,
|
|
3358
|
+
driftPct: state.drift.pct,
|
|
3359
|
+
paused,
|
|
3360
|
+
gateClosed: this.isGateClosed(),
|
|
3361
|
+
pauseSide: this.pauseSide(),
|
|
3362
|
+
pauseReason: paused ? this.pauseReason ?? state.pause.reason : null,
|
|
3363
|
+
resumeAfterEpoch: paused ? this.pauseResumeAfterEpoch ?? state.pause.resumeAfterEpoch : null,
|
|
3364
|
+
parallelRecommended: paused ? false : state.parallel.recommended,
|
|
3365
|
+
codexTier: state.effort.codexTier,
|
|
3366
|
+
claudeAdvice: state.effort.claudeAdvice
|
|
3367
|
+
};
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// src/budget/quota-source.ts
|
|
3372
|
+
import { execFile } from "child_process";
|
|
3373
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3374
|
+
import { homedir as homedir2 } from "os";
|
|
3375
|
+
import { basename, join as join4 } from "path";
|
|
3376
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
3377
|
+
var MAX_BUFFER = 1024 * 1024;
|
|
3378
|
+
function defaultRunner(command, args, options) {
|
|
3379
|
+
return new Promise((resolve, reject) => {
|
|
3380
|
+
execFile(command, args, {
|
|
3381
|
+
env: options.env,
|
|
3382
|
+
timeout: options.timeoutMs,
|
|
3383
|
+
maxBuffer: MAX_BUFFER
|
|
3384
|
+
}, (error, stdout) => {
|
|
3385
|
+
if (error && !stdout) {
|
|
3386
|
+
reject(error);
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
resolve({ stdout });
|
|
3390
|
+
});
|
|
3391
|
+
});
|
|
3392
|
+
}
|
|
3393
|
+
function commandKind(command) {
|
|
3394
|
+
return basename(command) === "probe.mjs" ? "probe-mjs" : "budget-probe";
|
|
3395
|
+
}
|
|
3396
|
+
function argsFor(candidate, agent) {
|
|
3397
|
+
if (candidate.kind === "probe-mjs")
|
|
3398
|
+
return [agent, "probe"];
|
|
3399
|
+
return ["--agent", agent];
|
|
3400
|
+
}
|
|
3401
|
+
function asFiniteNumber(value) {
|
|
3402
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
3403
|
+
return value;
|
|
3404
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
3405
|
+
const parsed = Number(value);
|
|
3406
|
+
if (Number.isFinite(parsed))
|
|
3407
|
+
return parsed;
|
|
3408
|
+
}
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
function numberOr(value, fallback) {
|
|
3412
|
+
return asFiniteNumber(value) ?? fallback;
|
|
3413
|
+
}
|
|
3414
|
+
function clamp(value, min, max) {
|
|
3415
|
+
return Math.min(max, Math.max(min, value));
|
|
3416
|
+
}
|
|
3417
|
+
function asRecord(value) {
|
|
3418
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
3419
|
+
}
|
|
3420
|
+
function normalizeBucket(value, fetchedAt) {
|
|
3421
|
+
const bucket = asRecord(value);
|
|
3422
|
+
if (!bucket)
|
|
3423
|
+
return null;
|
|
3424
|
+
const id = typeof bucket.id === "string" ? bucket.id : "";
|
|
3425
|
+
const util = asFiniteNumber(bucket.util);
|
|
3426
|
+
if (util === null)
|
|
3427
|
+
return null;
|
|
3428
|
+
const resetAfter = asFiniteNumber(bucket.reset_after_seconds ?? bucket.resetAfterSeconds);
|
|
3429
|
+
let resetEpoch = numberOr(bucket.reset_epoch ?? bucket.resetEpoch, 0);
|
|
3430
|
+
if (resetEpoch <= 0 && resetAfter !== null && fetchedAt > 0) {
|
|
3431
|
+
resetEpoch = fetchedAt + resetAfter;
|
|
3432
|
+
}
|
|
3433
|
+
return {
|
|
3434
|
+
id,
|
|
3435
|
+
util: clamp(util, 0, 100),
|
|
3436
|
+
resetEpoch: Math.max(0, resetEpoch),
|
|
3437
|
+
resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
function normalizeTopLevelBucket(record, util, fetchedAt) {
|
|
3441
|
+
const resetAfter = asFiniteNumber(record.reset_after_seconds ?? record.resetAfterSeconds);
|
|
3442
|
+
let resetEpoch = numberOr(record.reset_epoch ?? record.resetEpoch, 0);
|
|
3443
|
+
if (resetEpoch <= 0 && resetAfter !== null && fetchedAt > 0) {
|
|
3444
|
+
resetEpoch = fetchedAt + resetAfter;
|
|
3445
|
+
}
|
|
3446
|
+
return {
|
|
3447
|
+
id: "top_level",
|
|
3448
|
+
util: clamp(util, 0, 100),
|
|
3449
|
+
resetEpoch: Math.max(0, resetEpoch),
|
|
3450
|
+
resetAfterSeconds: resetAfter === null ? null : Math.max(0, resetAfter)
|
|
3451
|
+
};
|
|
3452
|
+
}
|
|
3453
|
+
function toWindow(bucket) {
|
|
3454
|
+
if (!bucket)
|
|
3455
|
+
return null;
|
|
3456
|
+
return { util: bucket.util, resetEpoch: bucket.resetEpoch };
|
|
3457
|
+
}
|
|
3458
|
+
function bucketSortKey(bucket) {
|
|
3459
|
+
if (bucket.resetAfterSeconds !== null)
|
|
3460
|
+
return bucket.resetAfterSeconds;
|
|
3461
|
+
if (bucket.resetEpoch > 0)
|
|
3462
|
+
return bucket.resetEpoch;
|
|
3463
|
+
return Number.POSITIVE_INFINITY;
|
|
3464
|
+
}
|
|
3465
|
+
function sameBucketWindow(bucket, window) {
|
|
3466
|
+
return !!window && bucket.util === window.util && bucket.resetEpoch === window.resetEpoch;
|
|
3467
|
+
}
|
|
3468
|
+
function pickHighestUtil(buckets) {
|
|
3469
|
+
if (buckets.length === 0)
|
|
3470
|
+
return null;
|
|
3471
|
+
return buckets.reduce((best, current) => {
|
|
3472
|
+
if (current.util > best.util)
|
|
3473
|
+
return current;
|
|
3474
|
+
if (current.util === best.util && bucketSortKey(current) < bucketSortKey(best))
|
|
3475
|
+
return current;
|
|
3476
|
+
return best;
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
function identifyWindows(buckets) {
|
|
3480
|
+
const fiveHourMatches = buckets.filter((bucket) => bucket.id.includes("five_hour") || bucket.id.includes("primary_window"));
|
|
3481
|
+
const weeklyMatches = buckets.filter((bucket) => bucket.id.includes("seven_day") || bucket.id.includes("secondary_window"));
|
|
3482
|
+
let fiveHour = toWindow(pickHighestUtil(fiveHourMatches));
|
|
3483
|
+
let weekly = toWindow(pickHighestUtil(weeklyMatches));
|
|
3484
|
+
const sorted = [...buckets].sort((a, b) => bucketSortKey(a) - bucketSortKey(b));
|
|
3485
|
+
if (!fiveHour && sorted.length > 0) {
|
|
3486
|
+
fiveHour = toWindow(sorted[0]);
|
|
3487
|
+
}
|
|
3488
|
+
if (!weekly && sorted.length > 1) {
|
|
3489
|
+
const latestDistinct = [...sorted].reverse().find((bucket) => !sameBucketWindow(bucket, fiveHour));
|
|
3490
|
+
weekly = toWindow(latestDistinct);
|
|
3491
|
+
}
|
|
3492
|
+
return { fiveHour, weekly };
|
|
3493
|
+
}
|
|
3494
|
+
function normalizeProbeResult(raw) {
|
|
3495
|
+
const record = asRecord(raw);
|
|
3496
|
+
if (!record)
|
|
3497
|
+
return null;
|
|
3498
|
+
const fetchedAt = numberOr(record.fetched_at ?? record.fetchedAt ?? record.now_epoch ?? record.nowEpoch, 0);
|
|
3499
|
+
const hasFiniteUtil = asFiniteNumber(record.util ?? record.hard_util ?? record.hardUtil) !== null || asFiniteNumber(record.warn_util ?? record.warnUtil) !== null;
|
|
3500
|
+
const gateUtil = clamp(numberOr(record.util ?? record.hard_util ?? record.hardUtil, 0), 0, 100);
|
|
3501
|
+
const warnUtil = clamp(numberOr(record.warn_util ?? record.warnUtil, gateUtil), 0, 100);
|
|
3502
|
+
const rawBuckets = Array.isArray(record.buckets) ? record.buckets : [];
|
|
3503
|
+
const buckets = rawBuckets.map((bucket) => normalizeBucket(bucket, fetchedAt)).filter((bucket) => bucket !== null);
|
|
3504
|
+
if (buckets.length === 0 && hasFiniteUtil) {
|
|
3505
|
+
const topLevelBucket = normalizeTopLevelBucket(record, gateUtil, fetchedAt);
|
|
3506
|
+
if (topLevelBucket)
|
|
3507
|
+
buckets.push(topLevelBucket);
|
|
3508
|
+
}
|
|
3509
|
+
const rateLimitedUntil = Math.max(0, numberOr(record.rate_limited_until ?? record.rateLimitedUntil, 0));
|
|
3510
|
+
const ok = record.ok === true;
|
|
3511
|
+
if (!ok && rateLimitedUntil <= 0 && buckets.length === 0)
|
|
3512
|
+
return null;
|
|
3513
|
+
const { fiveHour, weekly } = identifyWindows(buckets);
|
|
3514
|
+
if (!fiveHour && !weekly && rateLimitedUntil === 0 && !hasFiniteUtil)
|
|
3515
|
+
return null;
|
|
3516
|
+
return {
|
|
3517
|
+
ok,
|
|
3518
|
+
stale: record.stale === true,
|
|
3519
|
+
gateUtil,
|
|
3520
|
+
warnUtil,
|
|
3521
|
+
fiveHour,
|
|
3522
|
+
weekly,
|
|
3523
|
+
remaining: clamp(100 - gateUtil, 0, 100),
|
|
3524
|
+
rateLimitedUntil,
|
|
3525
|
+
fetchedAt
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
function withTimeout(promise, timeoutMs) {
|
|
3529
|
+
let timer = null;
|
|
3530
|
+
const timeout = new Promise((_, reject) => {
|
|
3531
|
+
timer = setTimeout(() => reject(new Error(`budget probe timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
3532
|
+
});
|
|
3533
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
3534
|
+
if (timer)
|
|
3535
|
+
clearTimeout(timer);
|
|
3536
|
+
});
|
|
3537
|
+
}
|
|
3538
|
+
function isDegradedUsage(usage, now = Math.floor(Date.now() / 1000)) {
|
|
3539
|
+
if (usage.stale || !usage.ok)
|
|
3540
|
+
return true;
|
|
3541
|
+
const hasFreshWindow = usage.fiveHour !== null && usage.fiveHour.resetEpoch > now || usage.weekly !== null && usage.weekly.resetEpoch > now;
|
|
3542
|
+
return !hasFreshWindow;
|
|
3543
|
+
}
|
|
1485
3544
|
|
|
1486
|
-
class
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
3545
|
+
class QuotaSource {
|
|
3546
|
+
env;
|
|
3547
|
+
homeDir;
|
|
3548
|
+
timeoutMs;
|
|
3549
|
+
runner;
|
|
3550
|
+
log;
|
|
3551
|
+
now;
|
|
3552
|
+
degradedLogged = new Map;
|
|
3553
|
+
constructor(options = {}) {
|
|
3554
|
+
this.env = options.env ?? process.env;
|
|
3555
|
+
this.homeDir = options.homeDir ?? homedir2();
|
|
3556
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3557
|
+
this.runner = options.runner ?? defaultRunner;
|
|
3558
|
+
this.log = options.log ?? (() => {});
|
|
3559
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
3560
|
+
}
|
|
3561
|
+
async fetchBoth() {
|
|
3562
|
+
const candidates = this.findProbeCandidates();
|
|
3563
|
+
if (candidates.length === 0)
|
|
3564
|
+
return null;
|
|
3565
|
+
const [claude, codex] = await Promise.all([
|
|
3566
|
+
this.fetchAgent(candidates, "claude"),
|
|
3567
|
+
this.fetchAgent(candidates, "codex")
|
|
3568
|
+
]);
|
|
3569
|
+
return { claude, codex };
|
|
3570
|
+
}
|
|
3571
|
+
findProbeCandidates() {
|
|
3572
|
+
const candidates = [];
|
|
3573
|
+
const seen = new Set;
|
|
3574
|
+
const add = (command, kind) => {
|
|
3575
|
+
const key = `${kind}:${command}`;
|
|
3576
|
+
if (seen.has(key))
|
|
3577
|
+
return;
|
|
3578
|
+
seen.add(key);
|
|
3579
|
+
candidates.push({ command, kind });
|
|
3580
|
+
};
|
|
3581
|
+
const explicit = this.env.AGENTBRIDGE_QUOTA_PROBE || this.env.BUDGET_PROBE;
|
|
3582
|
+
if (explicit && explicit.trim() !== "") {
|
|
3583
|
+
const command = explicit.trim();
|
|
3584
|
+
add(command, commandKind(command));
|
|
3585
|
+
return candidates;
|
|
1497
3586
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
if (
|
|
1501
|
-
|
|
3587
|
+
const binDir = join4(this.homeDir, ".budget-guard/bin");
|
|
3588
|
+
const installedBudgetProbe = join4(binDir, "budget-probe");
|
|
3589
|
+
if (existsSync5(installedBudgetProbe))
|
|
3590
|
+
add(installedBudgetProbe, "budget-probe");
|
|
3591
|
+
const installedProbeMjs = join4(binDir, "probe.mjs");
|
|
3592
|
+
if (existsSync5(installedProbeMjs))
|
|
3593
|
+
add(installedProbeMjs, "probe-mjs");
|
|
3594
|
+
return candidates;
|
|
3595
|
+
}
|
|
3596
|
+
async fetchAgent(candidates, agent) {
|
|
3597
|
+
for (const candidate of candidates) {
|
|
3598
|
+
try {
|
|
3599
|
+
const result = await withTimeout(this.runner(candidate.command, argsFor(candidate, agent), {
|
|
3600
|
+
env: this.env,
|
|
3601
|
+
timeoutMs: this.timeoutMs,
|
|
3602
|
+
agent
|
|
3603
|
+
}), this.timeoutMs);
|
|
3604
|
+
const text = String(result.stdout).trim();
|
|
3605
|
+
if (!text)
|
|
3606
|
+
continue;
|
|
3607
|
+
let parsed;
|
|
3608
|
+
try {
|
|
3609
|
+
parsed = JSON.parse(text);
|
|
3610
|
+
} catch {
|
|
3611
|
+
this.log(`budget probe output unparseable for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
|
|
3612
|
+
continue;
|
|
3613
|
+
}
|
|
3614
|
+
const usage = normalizeProbeResult(parsed);
|
|
3615
|
+
if (usage) {
|
|
3616
|
+
this.noteDegradation(agent, usage);
|
|
3617
|
+
return usage;
|
|
3618
|
+
}
|
|
3619
|
+
this.log(`budget probe returned no usable data for ${agent}: ${candidate.command} \u2014 raw: ${text.slice(0, 200)}`);
|
|
3620
|
+
} catch (error) {
|
|
3621
|
+
this.log(`budget probe failed for ${agent}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3622
|
+
}
|
|
1502
3623
|
}
|
|
3624
|
+
return null;
|
|
1503
3625
|
}
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
return join(this.stateDir, "daemon.lock");
|
|
1515
|
-
}
|
|
1516
|
-
get statusFile() {
|
|
1517
|
-
return join(this.stateDir, "status.json");
|
|
1518
|
-
}
|
|
1519
|
-
get portsFile() {
|
|
1520
|
-
return join(this.stateDir, "ports.json");
|
|
1521
|
-
}
|
|
1522
|
-
get logFile() {
|
|
1523
|
-
return join(this.stateDir, "agentbridge.log");
|
|
3626
|
+
noteDegradation(agent, usage) {
|
|
3627
|
+
const degraded = isDegradedUsage(usage, this.now());
|
|
3628
|
+
const wasDegraded = this.degradedLogged.get(agent) === true;
|
|
3629
|
+
if (degraded && !wasDegraded) {
|
|
3630
|
+
const gate = usage.rateLimitedUntil > 0 ? `, rate-limit gated until ${usage.rateLimitedUntil}` : "";
|
|
3631
|
+
this.log(`budget probe degraded data accepted for ${agent} (stale=${usage.stale}, ok=${usage.ok}${gate}) \u2014 display only, decisions hold`);
|
|
3632
|
+
} else if (!degraded && wasDegraded) {
|
|
3633
|
+
this.log(`budget probe recovered to fresh data for ${agent}`);
|
|
3634
|
+
}
|
|
3635
|
+
this.degradedLogged.set(agent, degraded);
|
|
1524
3636
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
3637
|
+
}
|
|
3638
|
+
function createQuotaSource(options) {
|
|
3639
|
+
return new QuotaSource(options);
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
// src/reply-required-tracker.ts
|
|
3643
|
+
class ReplyRequiredTracker {
|
|
3644
|
+
armed = false;
|
|
3645
|
+
forwardedDuringTurn = false;
|
|
3646
|
+
get isArmed() {
|
|
3647
|
+
return this.armed;
|
|
3648
|
+
}
|
|
3649
|
+
arm() {
|
|
3650
|
+
this.armed = true;
|
|
3651
|
+
this.forwardedDuringTurn = false;
|
|
3652
|
+
}
|
|
3653
|
+
noteForwarded() {
|
|
3654
|
+
if (this.armed)
|
|
3655
|
+
this.forwardedDuringTurn = true;
|
|
3656
|
+
}
|
|
3657
|
+
consumeOnTurnComplete() {
|
|
3658
|
+
const warnReplyMissing = this.armed && !this.forwardedDuringTurn;
|
|
3659
|
+
this.reset();
|
|
3660
|
+
return { warnReplyMissing };
|
|
3661
|
+
}
|
|
3662
|
+
reset() {
|
|
3663
|
+
this.armed = false;
|
|
3664
|
+
this.forwardedDuringTurn = false;
|
|
1527
3665
|
}
|
|
1528
3666
|
}
|
|
1529
3667
|
|
|
1530
|
-
// src/
|
|
1531
|
-
import {
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
3668
|
+
// src/thread-state.ts
|
|
3669
|
+
import {
|
|
3670
|
+
existsSync as existsSync6,
|
|
3671
|
+
mkdirSync as mkdirSync4,
|
|
3672
|
+
readdirSync,
|
|
3673
|
+
readFileSync as readFileSync3,
|
|
3674
|
+
renameSync as renameSync2,
|
|
3675
|
+
writeFileSync as writeFileSync3
|
|
3676
|
+
} from "fs";
|
|
3677
|
+
import { homedir as homedir3 } from "os";
|
|
3678
|
+
import { basename as basename2, dirname as dirname2, join as join5 } from "path";
|
|
3679
|
+
function nowIso() {
|
|
3680
|
+
return new Date().toISOString();
|
|
3681
|
+
}
|
|
3682
|
+
function threadTag(identity) {
|
|
3683
|
+
const name = identity.pairName ?? identity.pairId ?? "manual";
|
|
3684
|
+
return `abg:${name}:${identity.cwd}`;
|
|
3685
|
+
}
|
|
3686
|
+
function codexHome(env = process.env) {
|
|
3687
|
+
return env.CODEX_HOME && env.CODEX_HOME.length > 0 ? env.CODEX_HOME : join5(homedir3(), ".codex");
|
|
3688
|
+
}
|
|
3689
|
+
function atomicWriteJson(path, value) {
|
|
3690
|
+
mkdirSync4(dirname2(path), { recursive: true });
|
|
3691
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
3692
|
+
writeFileSync3(tmp, JSON.stringify(value, null, 2) + `
|
|
3693
|
+
`, "utf-8");
|
|
3694
|
+
renameSync2(tmp, path);
|
|
3695
|
+
}
|
|
3696
|
+
function readRawCurrentThread(stateDir) {
|
|
3697
|
+
try {
|
|
3698
|
+
const parsed = JSON.parse(readFileSync3(stateDir.currentThreadFile, "utf-8"));
|
|
3699
|
+
if (parsed?.version === 1 && typeof parsed.threadId === "string" && parsed.threadId.length > 0 && (parsed.status === "pending" || parsed.status === "current") && typeof parsed.cwd === "string") {
|
|
3700
|
+
return parsed;
|
|
1546
3701
|
}
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
## Thinking Patterns
|
|
1562
|
-
- Analytical/review tasks: Independent Analysis & Convergence
|
|
1563
|
-
- Implementation tasks: Architect -> Builder -> Critic
|
|
1564
|
-
- Debugging tasks: Hypothesis -> Experiment -> Interpretation
|
|
1565
|
-
|
|
1566
|
-
## Communication
|
|
1567
|
-
- Use explicit phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"
|
|
1568
|
-
- Tag messages with [IMPORTANT], [STATUS], or [FYI]
|
|
1569
|
-
|
|
1570
|
-
## Review Process
|
|
1571
|
-
- Cross-review: author never reviews their own code
|
|
1572
|
-
- All changes go through feature/fix branches + PR
|
|
1573
|
-
- Merge via squash merge
|
|
1574
|
-
|
|
1575
|
-
## Custom Rules
|
|
1576
|
-
<!-- Add your project-specific collaboration rules here -->
|
|
1577
|
-
`;
|
|
1578
|
-
var CONFIG_DIR = ".agentbridge";
|
|
1579
|
-
var CONFIG_FILE = "config.json";
|
|
1580
|
-
var COLLABORATION_FILE = "collaboration.md";
|
|
1581
|
-
|
|
1582
|
-
class ConfigService {
|
|
1583
|
-
configDir;
|
|
1584
|
-
configPath;
|
|
1585
|
-
collaborationPath;
|
|
1586
|
-
constructor(projectRoot) {
|
|
1587
|
-
const root = projectRoot ?? process.cwd();
|
|
1588
|
-
this.configDir = join2(root, CONFIG_DIR);
|
|
1589
|
-
this.configPath = join2(this.configDir, CONFIG_FILE);
|
|
1590
|
-
this.collaborationPath = join2(this.configDir, COLLABORATION_FILE);
|
|
1591
|
-
}
|
|
1592
|
-
hasConfig() {
|
|
1593
|
-
return existsSync3(this.configPath);
|
|
1594
|
-
}
|
|
1595
|
-
load() {
|
|
3702
|
+
} catch {}
|
|
3703
|
+
return null;
|
|
3704
|
+
}
|
|
3705
|
+
function findCodexRolloutFile(threadId, env = process.env, maxEntries = 20000) {
|
|
3706
|
+
const sessionsDir = join5(codexHome(env), "sessions");
|
|
3707
|
+
if (!threadId || !existsSync6(sessionsDir))
|
|
3708
|
+
return null;
|
|
3709
|
+
const exactName = `rollout-${threadId}.jsonl`;
|
|
3710
|
+
const stack = [sessionsDir];
|
|
3711
|
+
let visited = 0;
|
|
3712
|
+
while (stack.length > 0 && visited < maxEntries) {
|
|
3713
|
+
const dir = stack.pop();
|
|
3714
|
+
let entries;
|
|
1596
3715
|
try {
|
|
1597
|
-
|
|
1598
|
-
return JSON.parse(raw);
|
|
3716
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1599
3717
|
} catch {
|
|
1600
|
-
|
|
3718
|
+
continue;
|
|
3719
|
+
}
|
|
3720
|
+
for (const entry of entries) {
|
|
3721
|
+
visited++;
|
|
3722
|
+
const path = join5(dir, entry.name);
|
|
3723
|
+
if (entry.isDirectory()) {
|
|
3724
|
+
stack.push(path);
|
|
3725
|
+
continue;
|
|
3726
|
+
}
|
|
3727
|
+
if (!entry.isFile())
|
|
3728
|
+
continue;
|
|
3729
|
+
const name = basename2(entry.name);
|
|
3730
|
+
if (name === exactName || name.startsWith("rollout-") && name.endsWith(".jsonl") && name.includes(threadId)) {
|
|
3731
|
+
return path;
|
|
3732
|
+
}
|
|
1601
3733
|
}
|
|
1602
3734
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
3735
|
+
return null;
|
|
3736
|
+
}
|
|
3737
|
+
function writePendingCurrentThread(identity, threadId, reason) {
|
|
3738
|
+
const state = {
|
|
3739
|
+
version: 1,
|
|
3740
|
+
status: "pending",
|
|
3741
|
+
pairId: identity.pairId,
|
|
3742
|
+
pairName: identity.pairName,
|
|
3743
|
+
cwd: identity.cwd,
|
|
3744
|
+
threadId,
|
|
3745
|
+
updatedAt: nowIso(),
|
|
3746
|
+
reason,
|
|
3747
|
+
tag: threadTag(identity)
|
|
3748
|
+
};
|
|
3749
|
+
atomicWriteJson(identity.stateDir.currentThreadFile, state);
|
|
3750
|
+
return state;
|
|
3751
|
+
}
|
|
3752
|
+
function promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env = process.env) {
|
|
3753
|
+
const rolloutPath = findCodexRolloutFile(threadId, env);
|
|
3754
|
+
const state = {
|
|
3755
|
+
version: 1,
|
|
3756
|
+
status: rolloutPath ? "current" : "pending",
|
|
3757
|
+
pairId: identity.pairId,
|
|
3758
|
+
pairName: identity.pairName,
|
|
3759
|
+
cwd: identity.cwd,
|
|
3760
|
+
threadId,
|
|
3761
|
+
updatedAt: nowIso(),
|
|
3762
|
+
reason,
|
|
3763
|
+
tag: threadTag(identity),
|
|
3764
|
+
...rolloutPath ? { rolloutPath, rolloutVerifiedAt: nowIso() } : {}
|
|
3765
|
+
};
|
|
3766
|
+
atomicWriteJson(identity.stateDir.currentThreadFile, state);
|
|
3767
|
+
return state;
|
|
3768
|
+
}
|
|
3769
|
+
async function persistCurrentThreadWithRolloutRetry(identity, threadId, reason, options = {}) {
|
|
3770
|
+
const env = options.env ?? process.env;
|
|
3771
|
+
const attempts = options.attempts ?? 20;
|
|
3772
|
+
const delayMs = options.delayMs ?? 250;
|
|
3773
|
+
const shouldContinue = options.shouldContinue ?? (() => true);
|
|
3774
|
+
if (!shouldContinue())
|
|
3775
|
+
return null;
|
|
3776
|
+
writePendingCurrentThread(identity, threadId, reason);
|
|
3777
|
+
for (let attempt = 1;attempt <= attempts; attempt++) {
|
|
3778
|
+
if (!shouldContinue()) {
|
|
3779
|
+
options.log?.(`Abandoned current-thread persistence for ${threadId}: a newer thread became active`);
|
|
1615
3780
|
return null;
|
|
1616
3781
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
}
|
|
1622
|
-
initDefaults() {
|
|
1623
|
-
this.ensureConfigDir();
|
|
1624
|
-
const created = [];
|
|
1625
|
-
if (!existsSync3(this.configPath)) {
|
|
1626
|
-
this.save(DEFAULT_CONFIG);
|
|
1627
|
-
created.push(this.configPath);
|
|
3782
|
+
const state = promoteCurrentThreadIfRolloutExists(identity, threadId, reason, env);
|
|
3783
|
+
if (state.status === "current") {
|
|
3784
|
+
options.log?.(`Current Codex thread persisted: ${threadId} (${state.rolloutPath})`);
|
|
3785
|
+
return state;
|
|
1628
3786
|
}
|
|
1629
|
-
if (
|
|
1630
|
-
|
|
1631
|
-
created.push(this.collaborationPath);
|
|
3787
|
+
if (attempt < attempts) {
|
|
3788
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1632
3789
|
}
|
|
1633
|
-
return created;
|
|
1634
|
-
}
|
|
1635
|
-
get configFilePath() {
|
|
1636
|
-
return this.configPath;
|
|
1637
3790
|
}
|
|
1638
|
-
|
|
1639
|
-
return
|
|
3791
|
+
if (!shouldContinue())
|
|
3792
|
+
return null;
|
|
3793
|
+
options.log?.(`Current Codex thread left pending because no rollout file was found: ${threadId}`);
|
|
3794
|
+
return readRawCurrentThread(identity.stateDir) ?? writePendingCurrentThread(identity, threadId, reason);
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
// src/waiting-message.ts
|
|
3798
|
+
function formatWaitingForCodexTuiMessage(options) {
|
|
3799
|
+
const pairName = options.pairName ?? "unknown";
|
|
3800
|
+
const pairId = options.pairId ?? "manual";
|
|
3801
|
+
const slot = options.slot === null || options.slot === undefined ? "manual" : String(options.slot);
|
|
3802
|
+
return [
|
|
3803
|
+
"\u23F3 Waiting for Codex TUI to connect.",
|
|
3804
|
+
`Current pair: cwd=${options.cwd} pair=${pairName} pairId=${pairId} slot=${slot} proxy=${options.proxyUrl}`,
|
|
3805
|
+
"If Codex was started from a different cwd, it belongs to another pair and will not attach here.",
|
|
3806
|
+
"Run in another terminal:",
|
|
3807
|
+
options.attachCmd,
|
|
3808
|
+
"For diagnostics: abg doctor"
|
|
3809
|
+
].join(`
|
|
3810
|
+
`);
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
// src/pair-registry.ts
|
|
3814
|
+
var PAIR_BASE_PORT = 4500;
|
|
3815
|
+
var PAIR_SLOT_STRIDE = 10;
|
|
3816
|
+
var MAX_PAIR_SLOT = Math.floor((65535 - 2 - PAIR_BASE_PORT) / PAIR_SLOT_STRIDE);
|
|
3817
|
+
|
|
3818
|
+
// src/liveness-probe.ts
|
|
3819
|
+
var OPEN = 1;
|
|
3820
|
+
async function probeLiveness(target, options) {
|
|
3821
|
+
const {
|
|
3822
|
+
timeoutMs,
|
|
3823
|
+
pollMs = 50,
|
|
3824
|
+
now = Date.now,
|
|
3825
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
3826
|
+
} = options;
|
|
3827
|
+
if (target.readyState !== OPEN)
|
|
3828
|
+
return false;
|
|
3829
|
+
const baseline = target.pongCount;
|
|
3830
|
+
try {
|
|
3831
|
+
target.ping();
|
|
3832
|
+
} catch {
|
|
3833
|
+
return false;
|
|
1640
3834
|
}
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
3835
|
+
const deadline = now() + timeoutMs;
|
|
3836
|
+
while (now() < deadline) {
|
|
3837
|
+
if (target.pongCount > baseline)
|
|
3838
|
+
return true;
|
|
3839
|
+
if (target.readyState !== OPEN)
|
|
3840
|
+
return false;
|
|
3841
|
+
await sleep(pollMs);
|
|
1645
3842
|
}
|
|
3843
|
+
return target.pongCount > baseline;
|
|
1646
3844
|
}
|
|
1647
3845
|
|
|
1648
|
-
// src/control-protocol.ts
|
|
1649
|
-
var CLOSE_CODE_REPLACED = 4001;
|
|
1650
|
-
|
|
1651
3846
|
// src/daemon.ts
|
|
1652
3847
|
var stateDir = new StateDirResolver;
|
|
1653
3848
|
stateDir.ensure();
|
|
1654
3849
|
var configService = new ConfigService;
|
|
1655
3850
|
var config = configService.loadOrDefault();
|
|
1656
|
-
var
|
|
1657
|
-
var
|
|
3851
|
+
var processLogger = createProcessLogger({ component: "AgentBridgeDaemon", logFile: stateDir.logFile });
|
|
3852
|
+
var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
|
|
3853
|
+
var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
|
|
1658
3854
|
var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
|
|
1659
3855
|
var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
|
|
1660
3856
|
var CLAUDE_DISCONNECT_GRACE_MS = 5000;
|
|
@@ -1662,8 +3858,12 @@ var MAX_BUFFERED_MESSAGES = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAG
|
|
|
1662
3858
|
var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "filtered";
|
|
1663
3859
|
var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
|
|
1664
3860
|
var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
|
|
3861
|
+
var BOOTSTRAP_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_BOOTSTRAP_TIMEOUT_MS", 45000);
|
|
3862
|
+
var CODEX_BOOT_RETRIES = parsePositiveIntEnv("AGENTBRIDGE_CODEX_BOOT_RETRIES", 2);
|
|
3863
|
+
var ALLOW_IDENTITYLESS_CLIENT = process.env.AGENTBRIDGE_COMPAT_IDENTITYLESS === "1";
|
|
3864
|
+
var BUDGET_CONFIG = applyBudgetEnvOverrides(config.budget);
|
|
1665
3865
|
var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
|
|
1666
|
-
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT);
|
|
3866
|
+
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
|
|
1667
3867
|
var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
|
|
1668
3868
|
var controlServer = null;
|
|
1669
3869
|
var attachedClaude = null;
|
|
@@ -1672,16 +3872,58 @@ var nextSystemMessageId = 0;
|
|
|
1672
3872
|
var codexBootstrapped = false;
|
|
1673
3873
|
var attentionWindowTimer = null;
|
|
1674
3874
|
var inAttentionWindow = false;
|
|
1675
|
-
var
|
|
1676
|
-
var replyReceivedDuringTurn = false;
|
|
3875
|
+
var replyTracker = new ReplyRequiredTracker;
|
|
1677
3876
|
var shuttingDown = false;
|
|
3877
|
+
var bootDeadlineTimer = null;
|
|
1678
3878
|
var idleShutdownTimer = null;
|
|
1679
3879
|
var claudeDisconnectTimer = null;
|
|
1680
|
-
var claudeOnlineNoticeSent = false;
|
|
1681
|
-
var claudeOfflineNoticeShown = false;
|
|
1682
3880
|
var lastAttachStatusSentTs = 0;
|
|
1683
3881
|
var ATTACH_STATUS_COOLDOWN_MS = 30000;
|
|
3882
|
+
var LIVENESS_PROBE_TIMEOUT_MS = parsePositiveIntEnv("AGENTBRIDGE_LIVENESS_PROBE_TIMEOUT_MS", 3000, log);
|
|
3883
|
+
var LIVENESS_PROBE_POLL_MS = 50;
|
|
3884
|
+
var challengeInProgress = false;
|
|
1684
3885
|
var bufferedMessages = [];
|
|
3886
|
+
var budgetCoordinator = null;
|
|
3887
|
+
var budgetStatusTimer = null;
|
|
3888
|
+
function ensureBudgetCoordinatorStarted() {
|
|
3889
|
+
if (!BUDGET_CONFIG.enabled)
|
|
3890
|
+
return;
|
|
3891
|
+
if (!budgetCoordinator) {
|
|
3892
|
+
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"}`);
|
|
3893
|
+
budgetCoordinator = new BudgetCoordinator({
|
|
3894
|
+
source: createQuotaSource({ log }),
|
|
3895
|
+
config: BUDGET_CONFIG,
|
|
3896
|
+
emit: (id, content) => {
|
|
3897
|
+
emitToClaude(systemMessage(id, content));
|
|
3898
|
+
queueMicrotask(() => broadcastStatus());
|
|
3899
|
+
},
|
|
3900
|
+
onPauseChange: (paused) => {
|
|
3901
|
+
log(`Budget intervention ${paused ? "ACTIVE" : "CLEARED"} ` + `(gate ${budgetCoordinator?.isGateClosed() ? "CLOSED" : "OPEN"})`);
|
|
3902
|
+
queueMicrotask(() => broadcastStatus());
|
|
3903
|
+
},
|
|
3904
|
+
log
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
budgetCoordinator.start();
|
|
3908
|
+
if (!budgetStatusTimer) {
|
|
3909
|
+
budgetStatusTimer = setInterval(() => broadcastStatus(), BUDGET_CONFIG.pollSeconds * 1000);
|
|
3910
|
+
budgetStatusTimer.unref?.();
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
function stopBudgetCoordinator() {
|
|
3914
|
+
budgetCoordinator?.stop();
|
|
3915
|
+
if (budgetStatusTimer) {
|
|
3916
|
+
clearInterval(budgetStatusTimer);
|
|
3917
|
+
budgetStatusTimer = null;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
function budgetPauseGateError() {
|
|
3921
|
+
const snapshot = budgetCoordinator?.getSnapshot() ?? null;
|
|
3922
|
+
const reason = snapshot?.pauseReason ?? "Codex \u4FA7\u989D\u5EA6\u63A5\u8FD1\u8017\u5C3D";
|
|
3923
|
+
const resumeAt = snapshot?.resumeAfterEpoch ? new Date(snapshot.resumeAfterEpoch * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z") : null;
|
|
3924
|
+
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";
|
|
3925
|
+
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`;
|
|
3926
|
+
}
|
|
1685
3927
|
var tuiConnectionState = new TuiConnectionState({
|
|
1686
3928
|
disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
|
|
1687
3929
|
log,
|
|
@@ -1690,10 +3932,21 @@ var tuiConnectionState = new TuiConnectionState({
|
|
|
1690
3932
|
},
|
|
1691
3933
|
onReconnectAfterNotice: (connId) => {
|
|
1692
3934
|
emitToClaude(systemMessage("system_tui_reconnected", `\u2705 Codex TUI reconnected (conn #${connId}). Bridge restored, communication can continue.`));
|
|
1693
|
-
codex.injectMessage("\u2705 Claude Code is still online, bridge restored. Bidirectional communication can continue.");
|
|
1694
3935
|
}
|
|
1695
3936
|
});
|
|
1696
3937
|
var statusBuffer = new StatusBuffer((summary) => emitToClaude(summary));
|
|
3938
|
+
function tryWriteStatusFile(reason) {
|
|
3939
|
+
try {
|
|
3940
|
+
writeStatusFile();
|
|
3941
|
+
} catch (err) {
|
|
3942
|
+
log(`status file write failed (${reason}): ${err?.message ?? err}`);
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
codex.on("turnPhaseChanged", ({ phase, previous }) => {
|
|
3946
|
+
log(`Codex turn phase: ${previous} \u2192 ${phase}`);
|
|
3947
|
+
tryWriteStatusFile(`turnPhase:${phase}`);
|
|
3948
|
+
broadcastStatus();
|
|
3949
|
+
});
|
|
1697
3950
|
codex.on("turnStarted", () => {
|
|
1698
3951
|
log("Codex turn started");
|
|
1699
3952
|
emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
|
|
@@ -1702,9 +3955,9 @@ codex.on("agentMessage", (msg) => {
|
|
|
1702
3955
|
if (msg.source !== "codex")
|
|
1703
3956
|
return;
|
|
1704
3957
|
const result = classifyMessage(msg.content, FILTER_MODE);
|
|
1705
|
-
if (
|
|
3958
|
+
if (replyTracker.isArmed) {
|
|
1706
3959
|
log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
|
|
1707
|
-
|
|
3960
|
+
replyTracker.noteForwarded();
|
|
1708
3961
|
if (statusBuffer.size > 0) {
|
|
1709
3962
|
statusBuffer.flush("reply-required message arrived");
|
|
1710
3963
|
}
|
|
@@ -1737,23 +3990,49 @@ codex.on("agentMessage", (msg) => {
|
|
|
1737
3990
|
codex.on("turnCompleted", () => {
|
|
1738
3991
|
log("Codex turn completed");
|
|
1739
3992
|
statusBuffer.flush("turn completed");
|
|
1740
|
-
|
|
3993
|
+
const { warnReplyMissing } = replyTracker.consumeOnTurnComplete();
|
|
3994
|
+
if (warnReplyMissing) {
|
|
1741
3995
|
log("\u26A0\uFE0F Reply was required but Codex did not send any agentMessage");
|
|
1742
3996
|
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."));
|
|
1743
3997
|
}
|
|
1744
|
-
replyRequired = false;
|
|
1745
|
-
replyReceivedDuringTurn = false;
|
|
1746
3998
|
emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
|
|
1747
3999
|
startAttentionWindow();
|
|
1748
4000
|
});
|
|
4001
|
+
codex.on("turnAborted", (reason) => {
|
|
4002
|
+
log(`Codex turn aborted (${reason}) \u2014 clearing reply-required state`);
|
|
4003
|
+
const replyWasRequired = replyTracker.isArmed;
|
|
4004
|
+
replyTracker.reset();
|
|
4005
|
+
const notice = buildTurnAbortedNotice(reason, replyWasRequired);
|
|
4006
|
+
if (notice) {
|
|
4007
|
+
emitToClaude(systemMessage("system_turn_aborted", notice));
|
|
4008
|
+
}
|
|
4009
|
+
});
|
|
4010
|
+
codex.on("turnStalled", (event) => {
|
|
4011
|
+
log(`Codex turn stalled (${event.turnId}, inactivity ${event.inactivityMs}ms)`);
|
|
4012
|
+
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.`));
|
|
4013
|
+
});
|
|
1749
4014
|
codex.on("ready", (threadId) => {
|
|
1750
4015
|
tuiConnectionState.markBridgeReady();
|
|
1751
4016
|
log(`Codex ready \u2014 thread ${threadId}`);
|
|
1752
4017
|
log("Bridge fully operational");
|
|
1753
4018
|
emitToClaude(systemMessage("system_ready", currentReadyMessage()));
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
4019
|
+
budgetCoordinator?.resetAppliedTier();
|
|
4020
|
+
ensureBudgetCoordinatorStarted();
|
|
4021
|
+
});
|
|
4022
|
+
codex.on("threadChanged", (event) => {
|
|
4023
|
+
budgetCoordinator?.resetAppliedTier();
|
|
4024
|
+
broadcastStatus();
|
|
4025
|
+
persistCurrentThreadWithRolloutRetry({
|
|
4026
|
+
stateDir,
|
|
4027
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4028
|
+
pairName: process.env.AGENTBRIDGE_PAIR_NAME,
|
|
4029
|
+
cwd: process.cwd()
|
|
4030
|
+
}, event.threadId, event.reason, {
|
|
4031
|
+
log,
|
|
4032
|
+
shouldContinue: () => codex.activeThreadId === event.threadId
|
|
4033
|
+
}).catch((err) => {
|
|
4034
|
+
log(`Failed to persist current thread ${event.threadId}: ${err?.message ?? err}`);
|
|
4035
|
+
});
|
|
1757
4036
|
});
|
|
1758
4037
|
codex.on("tuiConnected", (connId) => {
|
|
1759
4038
|
tuiConnectionState.handleTuiConnected(connId);
|
|
@@ -1773,13 +4052,13 @@ codex.on("error", (err) => {
|
|
|
1773
4052
|
codex.on("exit", (code) => {
|
|
1774
4053
|
log(`Codex process exited (code ${code})`);
|
|
1775
4054
|
codexBootstrapped = false;
|
|
4055
|
+
replyTracker.reset();
|
|
1776
4056
|
statusBuffer.flush("codex exited");
|
|
1777
4057
|
tuiConnectionState.handleCodexExit();
|
|
1778
4058
|
clearPendingClaudeDisconnect("Codex process exited");
|
|
1779
|
-
|
|
1780
|
-
claudeOfflineNoticeShown = false;
|
|
1781
|
-
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.`));
|
|
4059
|
+
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.`));
|
|
1782
4060
|
broadcastStatus();
|
|
4061
|
+
armBootDeadline();
|
|
1783
4062
|
});
|
|
1784
4063
|
function startControlServer() {
|
|
1785
4064
|
controlServer = Bun.serve({
|
|
@@ -1793,7 +4072,7 @@ function startControlServer() {
|
|
|
1793
4072
|
if (url.pathname === "/readyz") {
|
|
1794
4073
|
return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
|
|
1795
4074
|
}
|
|
1796
|
-
if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false } })) {
|
|
4075
|
+
if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
|
|
1797
4076
|
return;
|
|
1798
4077
|
}
|
|
1799
4078
|
return new Response("AgentBridge daemon");
|
|
@@ -1803,6 +4082,8 @@ function startControlServer() {
|
|
|
1803
4082
|
sendPings: true,
|
|
1804
4083
|
open: (ws) => {
|
|
1805
4084
|
ws.data.clientId = ++nextControlClientId;
|
|
4085
|
+
ws.data.lastPongAt = Date.now();
|
|
4086
|
+
ws.data.pendingBackpressure = [];
|
|
1806
4087
|
log(`Frontend socket opened (#${ws.data.clientId})`);
|
|
1807
4088
|
},
|
|
1808
4089
|
close: (ws, code, reason) => {
|
|
@@ -1813,6 +4094,18 @@ function startControlServer() {
|
|
|
1813
4094
|
},
|
|
1814
4095
|
message: (ws, raw) => {
|
|
1815
4096
|
handleControlMessage(ws, raw);
|
|
4097
|
+
},
|
|
4098
|
+
pong: (ws) => {
|
|
4099
|
+
ws.data.lastPongAt = Date.now();
|
|
4100
|
+
ws.data.pongCount++;
|
|
4101
|
+
},
|
|
4102
|
+
drain: (ws) => {
|
|
4103
|
+
if (ws.data.pendingBackpressure.length > 0 && ws.getBufferedAmount() === 0) {
|
|
4104
|
+
ws.data.pendingBackpressure = [];
|
|
4105
|
+
}
|
|
4106
|
+
if (ws === attachedClaude && bufferedMessages.length > 0) {
|
|
4107
|
+
flushBufferedMessages(ws);
|
|
4108
|
+
}
|
|
1816
4109
|
}
|
|
1817
4110
|
}
|
|
1818
4111
|
});
|
|
@@ -1828,7 +4121,20 @@ function handleControlMessage(ws, raw) {
|
|
|
1828
4121
|
}
|
|
1829
4122
|
switch (message.type) {
|
|
1830
4123
|
case "claude_connect":
|
|
1831
|
-
|
|
4124
|
+
const admission = validateClaudeClientIdentity({
|
|
4125
|
+
expectedPairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4126
|
+
daemonCwd: process.cwd(),
|
|
4127
|
+
identity: message.identity,
|
|
4128
|
+
allowIdentityless: ALLOW_IDENTITYLESS_CLIENT
|
|
4129
|
+
});
|
|
4130
|
+
if (!admission.ok) {
|
|
4131
|
+
log(`Rejecting Claude frontend #${ws.data.clientId}: ${admission.reason}`);
|
|
4132
|
+
ws.close(admission.closeCode, admission.reason);
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
attachClaude(ws, message.identity).catch((err) => {
|
|
4136
|
+
log(`attachClaude threw for #${ws.data.clientId}: ${err?.message ?? err}`);
|
|
4137
|
+
});
|
|
1832
4138
|
return;
|
|
1833
4139
|
case "claude_disconnect":
|
|
1834
4140
|
detachClaude(ws, "frontend requested disconnect");
|
|
@@ -1836,6 +4142,11 @@ function handleControlMessage(ws, raw) {
|
|
|
1836
4142
|
case "status":
|
|
1837
4143
|
sendStatus(ws);
|
|
1838
4144
|
return;
|
|
4145
|
+
case "probe_incumbent":
|
|
4146
|
+
handleProbeIncumbent(ws).catch((err) => {
|
|
4147
|
+
log(`handleProbeIncumbent threw for #${ws.data.clientId}: ${err?.message ?? err}`);
|
|
4148
|
+
});
|
|
4149
|
+
return;
|
|
1839
4150
|
case "claude_to_codex": {
|
|
1840
4151
|
if (message.message.source !== "claude") {
|
|
1841
4152
|
sendProtocolMessage(ws, {
|
|
@@ -1855,18 +4166,25 @@ function handleControlMessage(ws, raw) {
|
|
|
1855
4166
|
});
|
|
1856
4167
|
return;
|
|
1857
4168
|
}
|
|
4169
|
+
if (budgetCoordinator?.isGateClosed()) {
|
|
4170
|
+
const reason = budgetPauseGateError();
|
|
4171
|
+
log(`Injection rejected by budget pause gate`);
|
|
4172
|
+
sendProtocolMessage(ws, {
|
|
4173
|
+
type: "claude_to_codex_result",
|
|
4174
|
+
requestId: message.requestId,
|
|
4175
|
+
success: false,
|
|
4176
|
+
error: reason
|
|
4177
|
+
});
|
|
4178
|
+
return;
|
|
4179
|
+
}
|
|
1858
4180
|
const requireReply = !!message.requireReply;
|
|
1859
|
-
let
|
|
1860
|
-
|
|
1861
|
-
` + BRIDGE_CONTRACT_REMINDER;
|
|
4181
|
+
let contentToSend = message.message.content;
|
|
1862
4182
|
if (requireReply) {
|
|
1863
|
-
|
|
1864
|
-
replyRequired = true;
|
|
1865
|
-
replyReceivedDuringTurn = false;
|
|
1866
|
-
log(`Reply required flag set for this message`);
|
|
4183
|
+
contentToSend += REPLY_REQUIRED_INSTRUCTION;
|
|
1867
4184
|
}
|
|
1868
4185
|
log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
|
|
1869
|
-
const
|
|
4186
|
+
const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
|
|
4187
|
+
const injected = codex.injectMessage(contentToSend, tierOverrides);
|
|
1870
4188
|
if (!injected) {
|
|
1871
4189
|
const reason = codex.turnInProgress ? "Codex is busy executing a turn. Wait for it to finish before sending another message." : "Injection failed: no active thread or WebSocket not connected.";
|
|
1872
4190
|
log(`Injection rejected: ${reason}`);
|
|
@@ -1878,6 +4196,13 @@ function handleControlMessage(ws, raw) {
|
|
|
1878
4196
|
});
|
|
1879
4197
|
return;
|
|
1880
4198
|
}
|
|
4199
|
+
if (tierOverrides) {
|
|
4200
|
+
budgetCoordinator?.notifyOverridesDelivered();
|
|
4201
|
+
}
|
|
4202
|
+
if (requireReply) {
|
|
4203
|
+
replyTracker.arm();
|
|
4204
|
+
log(`Reply required flag set for this message`);
|
|
4205
|
+
}
|
|
1881
4206
|
clearAttentionWindow();
|
|
1882
4207
|
sendProtocolMessage(ws, {
|
|
1883
4208
|
type: "claude_to_codex_result",
|
|
@@ -1888,24 +4213,57 @@ function handleControlMessage(ws, raw) {
|
|
|
1888
4213
|
}
|
|
1889
4214
|
}
|
|
1890
4215
|
}
|
|
1891
|
-
function attachClaude(ws) {
|
|
4216
|
+
async function attachClaude(ws, identity) {
|
|
4217
|
+
const occupant = attachedClaude;
|
|
4218
|
+
if (occupant && occupant !== ws && occupant.readyState !== WebSocket.CLOSED) {
|
|
4219
|
+
const msSincePong = Date.now() - occupant.data.lastPongAt;
|
|
4220
|
+
log(`Claude frontend contest: new=#${ws.data.clientId}, incumbent=#${occupant.data.clientId} ` + `(readyState=${occupant.readyState}, msSincePong=${msSincePong})`);
|
|
4221
|
+
if (challengeInProgress) {
|
|
4222
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 another liveness probe already in flight`);
|
|
4223
|
+
ws.close(CLOSE_CODE_PROBE_IN_PROGRESS, "liveness probe in progress, retry shortly");
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
challengeInProgress = true;
|
|
4227
|
+
let incumbentAlive = false;
|
|
4228
|
+
try {
|
|
4229
|
+
incumbentAlive = await probeLiveness2(occupant, LIVENESS_PROBE_TIMEOUT_MS);
|
|
4230
|
+
} finally {
|
|
4231
|
+
challengeInProgress = false;
|
|
4232
|
+
}
|
|
4233
|
+
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
|
4234
|
+
log(`Contestant #${ws.data.clientId} disappeared during probe \u2014 aborting`);
|
|
4235
|
+
if (!incumbentAlive) {
|
|
4236
|
+
evictStale(occupant, "contestant gone but probe still failed");
|
|
4237
|
+
}
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
if (incumbentAlive) {
|
|
4241
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 incumbent #${occupant.data.clientId} responded to liveness probe`);
|
|
4242
|
+
ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
|
|
4243
|
+
return;
|
|
4244
|
+
}
|
|
4245
|
+
evictStale(occupant, `liveness probe timed out after ${LIVENESS_PROBE_TIMEOUT_MS}ms`);
|
|
4246
|
+
}
|
|
1892
4247
|
if (attachedClaude && attachedClaude !== ws && attachedClaude.readyState !== WebSocket.CLOSED) {
|
|
1893
|
-
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014
|
|
4248
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 slot re-acquired by #${attachedClaude.data.clientId} after probe`);
|
|
1894
4249
|
ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
|
|
1895
4250
|
return;
|
|
1896
4251
|
}
|
|
1897
4252
|
clearPendingClaudeDisconnect("Claude frontend attached");
|
|
4253
|
+
ws.data.identity = identity;
|
|
1898
4254
|
attachedClaude = ws;
|
|
1899
4255
|
ws.data.attached = true;
|
|
1900
4256
|
cancelIdleShutdown();
|
|
1901
|
-
log(`Claude frontend attached (#${ws.data.clientId})`);
|
|
4257
|
+
log(`Claude frontend attached (#${ws.data.clientId}, pair=${identity?.pairId ?? "<none>"}, cwd=${identity?.cwd ?? "<unknown>"})`);
|
|
4258
|
+
const hadBacklog = bufferedMessages.length > 0;
|
|
4259
|
+
if (hadBacklog) {
|
|
4260
|
+
flushBufferedMessages(ws);
|
|
4261
|
+
}
|
|
1902
4262
|
statusBuffer.flush("claude reconnected");
|
|
1903
4263
|
sendStatus(ws);
|
|
1904
4264
|
const now = Date.now();
|
|
1905
4265
|
const isRapidReattach = now - lastAttachStatusSentTs < ATTACH_STATUS_COOLDOWN_MS;
|
|
1906
|
-
if (
|
|
1907
|
-
flushBufferedMessages(ws);
|
|
1908
|
-
} else if (!isRapidReattach) {
|
|
4266
|
+
if (!hadBacklog && !isRapidReattach) {
|
|
1909
4267
|
if (tuiConnectionState.canReply()) {
|
|
1910
4268
|
sendBridgeMessage(ws, systemMessage("system_ready", currentReadyMessage()));
|
|
1911
4269
|
} else if (codexBootstrapped) {
|
|
@@ -1913,9 +4271,6 @@ function attachClaude(ws) {
|
|
|
1913
4271
|
}
|
|
1914
4272
|
}
|
|
1915
4273
|
lastAttachStatusSentTs = now;
|
|
1916
|
-
if (tuiConnectionState.canReply() && shouldNotifyCodexClaudeOnline()) {
|
|
1917
|
-
notifyCodexClaudeOnline();
|
|
1918
|
-
}
|
|
1919
4274
|
}
|
|
1920
4275
|
function detachClaude(ws, reason) {
|
|
1921
4276
|
if (attachedClaude !== ws)
|
|
@@ -1923,19 +4278,75 @@ function detachClaude(ws, reason) {
|
|
|
1923
4278
|
attachedClaude = null;
|
|
1924
4279
|
ws.data.attached = false;
|
|
1925
4280
|
log(`Claude frontend detached (#${ws.data.clientId}, ${reason})`);
|
|
4281
|
+
if (ws.data.pendingBackpressure.length > 0) {
|
|
4282
|
+
bufferedMessages.unshift(...ws.data.pendingBackpressure);
|
|
4283
|
+
log(`Re-buffered ${ws.data.pendingBackpressure.length} backpressured message(s) for redelivery on reconnect`);
|
|
4284
|
+
ws.data.pendingBackpressure = [];
|
|
4285
|
+
if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
|
|
4286
|
+
const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
|
|
4287
|
+
bufferedMessages.splice(0, dropped);
|
|
4288
|
+
log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
1926
4291
|
scheduleClaudeDisconnectNotification(ws.data.clientId);
|
|
1927
4292
|
scheduleIdleShutdown();
|
|
1928
4293
|
}
|
|
4294
|
+
async function handleProbeIncumbent(ws) {
|
|
4295
|
+
const occupant = attachedClaude;
|
|
4296
|
+
log(`probe_incumbent from #${ws.data.clientId}: occupant=${occupant ? "#" + occupant.data.clientId : "none"} readyState=${occupant?.readyState}`);
|
|
4297
|
+
if (!occupant || occupant === ws || occupant.readyState !== WebSocket.OPEN) {
|
|
4298
|
+
sendProtocolMessage(ws, { type: "incumbent_status", connected: false, alive: false });
|
|
4299
|
+
return;
|
|
4300
|
+
}
|
|
4301
|
+
if (challengeInProgress) {
|
|
4302
|
+
sendProtocolMessage(ws, { type: "incumbent_status", connected: true, alive: true });
|
|
4303
|
+
return;
|
|
4304
|
+
}
|
|
4305
|
+
const alive = await probeLiveness2(occupant, LIVENESS_PROBE_TIMEOUT_MS);
|
|
4306
|
+
const stillConnected = attachedClaude === occupant && occupant.readyState === WebSocket.OPEN;
|
|
4307
|
+
log(`probe_incumbent reply to #${ws.data.clientId}: connected=${stillConnected} alive=${stillConnected && alive}`);
|
|
4308
|
+
sendProtocolMessage(ws, {
|
|
4309
|
+
type: "incumbent_status",
|
|
4310
|
+
connected: stillConnected,
|
|
4311
|
+
alive: stillConnected && alive
|
|
4312
|
+
});
|
|
4313
|
+
}
|
|
4314
|
+
async function probeLiveness2(ws, timeoutMs) {
|
|
4315
|
+
return probeLiveness({
|
|
4316
|
+
get readyState() {
|
|
4317
|
+
return ws.readyState;
|
|
4318
|
+
},
|
|
4319
|
+
get pongCount() {
|
|
4320
|
+
return ws.data.pongCount;
|
|
4321
|
+
},
|
|
4322
|
+
ping: () => {
|
|
4323
|
+
ws.ping();
|
|
4324
|
+
}
|
|
4325
|
+
}, { timeoutMs, pollMs: LIVENESS_PROBE_POLL_MS });
|
|
4326
|
+
}
|
|
4327
|
+
function evictStale(ws, reason) {
|
|
4328
|
+
log(`Evicting stale Claude frontend #${ws.data.clientId}: ${reason}`);
|
|
4329
|
+
if (attachedClaude === ws) {
|
|
4330
|
+
detachClaude(ws, `evicted: ${reason}`);
|
|
4331
|
+
}
|
|
4332
|
+
try {
|
|
4333
|
+
ws.close(CLOSE_CODE_EVICTED_STALE, "stale frontend evicted by newer session");
|
|
4334
|
+
} catch (err) {
|
|
4335
|
+
log(`Evict close threw on #${ws.data.clientId}: ${err.message}`);
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
1929
4338
|
function startAttentionWindow() {
|
|
1930
4339
|
clearAttentionWindow();
|
|
1931
4340
|
inAttentionWindow = true;
|
|
1932
4341
|
statusBuffer.pause();
|
|
1933
4342
|
log(`Attention window started (${ATTENTION_WINDOW_MS}ms)`);
|
|
4343
|
+
tryWriteStatusFile("attentionWindowStarted");
|
|
1934
4344
|
attentionWindowTimer = setTimeout(() => {
|
|
1935
4345
|
attentionWindowTimer = null;
|
|
1936
4346
|
inAttentionWindow = false;
|
|
1937
4347
|
statusBuffer.resume();
|
|
1938
4348
|
log("Attention window ended");
|
|
4349
|
+
tryWriteStatusFile("attentionWindowEnded");
|
|
1939
4350
|
}, ATTENTION_WINDOW_MS);
|
|
1940
4351
|
}
|
|
1941
4352
|
function clearAttentionWindow() {
|
|
@@ -1945,8 +4356,9 @@ function clearAttentionWindow() {
|
|
|
1945
4356
|
}
|
|
1946
4357
|
if (inAttentionWindow) {
|
|
1947
4358
|
statusBuffer.resume();
|
|
4359
|
+
inAttentionWindow = false;
|
|
4360
|
+
tryWriteStatusFile("attentionWindowCleared");
|
|
1948
4361
|
}
|
|
1949
|
-
inAttentionWindow = false;
|
|
1950
4362
|
}
|
|
1951
4363
|
function scheduleIdleShutdown() {
|
|
1952
4364
|
cancelIdleShutdown();
|
|
@@ -1987,17 +4399,6 @@ function scheduleClaudeDisconnectNotification(clientId) {
|
|
|
1987
4399
|
log(`Skipping Claude disconnect notification for client #${clientId} because Claude already reconnected`);
|
|
1988
4400
|
return;
|
|
1989
4401
|
}
|
|
1990
|
-
if (!tuiConnectionState.canReply()) {
|
|
1991
|
-
log(`Suppressing Claude disconnect notification for client #${clientId} because Codex cannot reply`);
|
|
1992
|
-
return;
|
|
1993
|
-
}
|
|
1994
|
-
if (!claudeOnlineNoticeSent) {
|
|
1995
|
-
log(`Suppressing Claude disconnect notification for client #${clientId} because Claude was never announced online`);
|
|
1996
|
-
return;
|
|
1997
|
-
}
|
|
1998
|
-
codex.injectMessage("\u26A0\uFE0F Claude Code went offline. AgentBridge is still running in the background; it will reconnect automatically when Claude reopens.");
|
|
1999
|
-
claudeOnlineNoticeSent = false;
|
|
2000
|
-
claudeOfflineNoticeShown = true;
|
|
2001
4402
|
log(`Claude disconnect persisted past grace window (client #${clientId})`);
|
|
2002
4403
|
}, CLAUDE_DISCONNECT_GRACE_MS);
|
|
2003
4404
|
}
|
|
@@ -2017,10 +4418,18 @@ function emitToClaude(message) {
|
|
|
2017
4418
|
function trySendBridgeMessage(ws, message) {
|
|
2018
4419
|
try {
|
|
2019
4420
|
const result = ws.send(JSON.stringify({ type: "codex_to_claude", message }));
|
|
2020
|
-
if (typeof result === "number" && result
|
|
2021
|
-
log(
|
|
4421
|
+
if (typeof result === "number" && result === 0) {
|
|
4422
|
+
log("Bridge message send returned 0 (dropped)");
|
|
2022
4423
|
return false;
|
|
2023
4424
|
}
|
|
4425
|
+
if (typeof result === "number" && result === -1) {
|
|
4426
|
+
ws.data.pendingBackpressure.push(message);
|
|
4427
|
+
if (ws.data.pendingBackpressure.length > MAX_BUFFERED_MESSAGES) {
|
|
4428
|
+
const dropped = ws.data.pendingBackpressure.length - MAX_BUFFERED_MESSAGES;
|
|
4429
|
+
ws.data.pendingBackpressure.splice(0, dropped);
|
|
4430
|
+
log(`Backpressure overflow: dropped ${dropped} oldest tracked message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
2024
4433
|
return true;
|
|
2025
4434
|
} catch (err) {
|
|
2026
4435
|
log(`Failed to send bridge message: ${err.message}`);
|
|
@@ -2029,10 +4438,9 @@ function trySendBridgeMessage(ws, message) {
|
|
|
2029
4438
|
}
|
|
2030
4439
|
function flushBufferedMessages(ws) {
|
|
2031
4440
|
const messages = bufferedMessages.splice(0, bufferedMessages.length);
|
|
2032
|
-
for (
|
|
2033
|
-
if (!trySendBridgeMessage(ws,
|
|
2034
|
-
const
|
|
2035
|
-
const remaining = messages.slice(failedIndex);
|
|
4441
|
+
for (let i = 0;i < messages.length; i++) {
|
|
4442
|
+
if (!trySendBridgeMessage(ws, messages[i])) {
|
|
4443
|
+
const remaining = messages.slice(i);
|
|
2036
4444
|
bufferedMessages.unshift(...remaining);
|
|
2037
4445
|
log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
|
|
2038
4446
|
return;
|
|
@@ -2052,7 +4460,10 @@ function broadcastStatus() {
|
|
|
2052
4460
|
}
|
|
2053
4461
|
function sendProtocolMessage(ws, message) {
|
|
2054
4462
|
try {
|
|
2055
|
-
ws.send(JSON.stringify(message));
|
|
4463
|
+
const result = ws.send(JSON.stringify(message));
|
|
4464
|
+
if (typeof result === "number" && result === 0) {
|
|
4465
|
+
log(`Control message dropped (socket closed): type=${message.type}`);
|
|
4466
|
+
}
|
|
2056
4467
|
} catch (err) {
|
|
2057
4468
|
log(`Failed to send control message: ${err.message}`);
|
|
2058
4469
|
}
|
|
@@ -2063,27 +4474,36 @@ function currentStatus() {
|
|
|
2063
4474
|
bridgeReady: tuiConnectionState.canReply(),
|
|
2064
4475
|
tuiConnected: snapshot.tuiConnected,
|
|
2065
4476
|
threadId: codex.activeThreadId,
|
|
2066
|
-
queuedMessageCount: bufferedMessages.length + statusBuffer.size,
|
|
4477
|
+
queuedMessageCount: bufferedMessages.length + statusBuffer.size + (attachedClaude?.data.pendingBackpressure.length ?? 0),
|
|
2067
4478
|
proxyUrl: codex.proxyUrl,
|
|
2068
4479
|
appServerUrl: codex.appServerUrl,
|
|
2069
|
-
pid: process.pid
|
|
4480
|
+
pid: process.pid,
|
|
4481
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4482
|
+
cwd: process.cwd(),
|
|
4483
|
+
stateDir: stateDir.dir,
|
|
4484
|
+
build: daemonStatusBuildInfo(),
|
|
4485
|
+
budget: budgetCoordinator?.getSnapshot() ?? undefined,
|
|
4486
|
+
turnInProgress: codex.turnInProgress,
|
|
4487
|
+
turnPhase: codex.turnPhase,
|
|
4488
|
+
attentionWindowActive: inAttentionWindow
|
|
2070
4489
|
};
|
|
2071
4490
|
}
|
|
2072
4491
|
function currentWaitingMessage() {
|
|
2073
|
-
|
|
2074
|
-
|
|
4492
|
+
const pairId = process.env.AGENTBRIDGE_PAIR_ID ?? null;
|
|
4493
|
+
const offset = CODEX_PROXY_PORT - PAIR_BASE_PORT - 1;
|
|
4494
|
+
const slot = pairId !== null && offset >= 0 && offset % PAIR_SLOT_STRIDE === 0 ? offset / PAIR_SLOT_STRIDE : null;
|
|
4495
|
+
return formatWaitingForCodexTuiMessage({
|
|
4496
|
+
attachCmd,
|
|
4497
|
+
cwd: process.cwd(),
|
|
4498
|
+
pairId,
|
|
4499
|
+
pairName: process.env.AGENTBRIDGE_PAIR_NAME ?? null,
|
|
4500
|
+
slot,
|
|
4501
|
+
proxyUrl: codex.proxyUrl
|
|
4502
|
+
});
|
|
2075
4503
|
}
|
|
2076
4504
|
function currentReadyMessage() {
|
|
2077
4505
|
return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
|
|
2078
4506
|
}
|
|
2079
|
-
function notifyCodexClaudeOnline() {
|
|
2080
|
-
claudeOnlineNoticeSent = true;
|
|
2081
|
-
claudeOfflineNoticeShown = false;
|
|
2082
|
-
codex.injectMessage("\u2705 AgentBridge connected to Claude Code.");
|
|
2083
|
-
}
|
|
2084
|
-
function shouldNotifyCodexClaudeOnline() {
|
|
2085
|
-
return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;
|
|
2086
|
-
}
|
|
2087
4507
|
function systemMessage(idPrefix, content) {
|
|
2088
4508
|
return {
|
|
2089
4509
|
id: `${idPrefix}_${++nextSystemMessageId}`,
|
|
@@ -2103,34 +4523,82 @@ function writeStatusFile() {
|
|
|
2103
4523
|
proxyUrl: codex.proxyUrl,
|
|
2104
4524
|
appServerUrl: codex.appServerUrl,
|
|
2105
4525
|
controlPort: CONTROL_PORT,
|
|
2106
|
-
pid: process.pid
|
|
4526
|
+
pid: process.pid,
|
|
4527
|
+
pairId: process.env.AGENTBRIDGE_PAIR_ID ?? null,
|
|
4528
|
+
cwd: process.cwd(),
|
|
4529
|
+
stateDir: stateDir.dir,
|
|
4530
|
+
build: daemonStatusBuildInfo(),
|
|
4531
|
+
turnInProgress: codex.turnInProgress,
|
|
4532
|
+
turnPhase: codex.turnPhase,
|
|
4533
|
+
attentionWindowActive: inAttentionWindow
|
|
2107
4534
|
});
|
|
2108
4535
|
}
|
|
2109
4536
|
function removeStatusFile() {
|
|
2110
4537
|
daemonLifecycle.removeStatusFile();
|
|
2111
4538
|
}
|
|
4539
|
+
function armBootDeadline() {
|
|
4540
|
+
if (bootDeadlineTimer)
|
|
4541
|
+
return;
|
|
4542
|
+
bootDeadlineTimer = setTimeout(() => {
|
|
4543
|
+
bootDeadlineTimer = null;
|
|
4544
|
+
if (codexBootstrapped)
|
|
4545
|
+
return;
|
|
4546
|
+
if (tuiConnectionState.snapshot().tuiConnected)
|
|
4547
|
+
return;
|
|
4548
|
+
log(`Codex not ready within bootstrap deadline (${BOOTSTRAP_TIMEOUT_MS}ms) \u2014 self-exiting to release control port`);
|
|
4549
|
+
if (attachedClaude) {
|
|
4550
|
+
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."));
|
|
4551
|
+
}
|
|
4552
|
+
shutdown("codex not ready within bootstrap deadline", 1);
|
|
4553
|
+
}, BOOTSTRAP_TIMEOUT_MS);
|
|
4554
|
+
bootDeadlineTimer.unref?.();
|
|
4555
|
+
}
|
|
4556
|
+
function clearBootDeadline() {
|
|
4557
|
+
if (bootDeadlineTimer) {
|
|
4558
|
+
clearTimeout(bootDeadlineTimer);
|
|
4559
|
+
bootDeadlineTimer = null;
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
2112
4562
|
async function bootCodex() {
|
|
2113
4563
|
log("Starting AgentBridge daemon...");
|
|
2114
4564
|
log(`Codex app-server: ${codex.appServerUrl}`);
|
|
2115
4565
|
log(`Codex proxy: ${codex.proxyUrl}`);
|
|
2116
4566
|
log(`Control server: ws://127.0.0.1:${CONTROL_PORT}/ws`);
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
4567
|
+
for (let attempt = 0;attempt <= CODEX_BOOT_RETRIES; attempt++) {
|
|
4568
|
+
try {
|
|
4569
|
+
await codex.start();
|
|
4570
|
+
codexBootstrapped = true;
|
|
4571
|
+
clearBootDeadline();
|
|
4572
|
+
writeStatusFile();
|
|
4573
|
+
emitToClaude(systemMessage("system_waiting", currentWaitingMessage()));
|
|
4574
|
+
broadcastStatus();
|
|
4575
|
+
scheduleIdleShutdown();
|
|
4576
|
+
return;
|
|
4577
|
+
} catch (err) {
|
|
4578
|
+
const attemptsLeft = CODEX_BOOT_RETRIES - attempt;
|
|
4579
|
+
log(`Failed to start Codex (attempt ${attempt + 1}/${CODEX_BOOT_RETRIES + 1}): ${err.message}`);
|
|
4580
|
+
if (attemptsLeft > 0) {
|
|
4581
|
+
const backoffMs = 1000 * (attempt + 1);
|
|
4582
|
+
log(`Retrying Codex bootstrap in ${backoffMs}ms (${attemptsLeft} attempt(s) left)...`);
|
|
4583
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
4584
|
+
if (shuttingDown)
|
|
4585
|
+
return;
|
|
4586
|
+
continue;
|
|
4587
|
+
}
|
|
4588
|
+
emitToClaude(systemMessage("system_codex_start_failed", `\u274C AgentBridge failed to start Codex app-server after ${CODEX_BOOT_RETRIES + 1} attempts: ${err.message}`));
|
|
4589
|
+
broadcastStatus();
|
|
4590
|
+
shutdown("codex bootstrap failed", 1);
|
|
4591
|
+
return;
|
|
4592
|
+
}
|
|
2127
4593
|
}
|
|
2128
4594
|
}
|
|
2129
|
-
function shutdown(reason) {
|
|
4595
|
+
function shutdown(reason, exitCode = 0) {
|
|
2130
4596
|
if (shuttingDown)
|
|
2131
4597
|
return;
|
|
2132
4598
|
shuttingDown = true;
|
|
2133
4599
|
log(`Shutting down daemon (${reason})...`);
|
|
4600
|
+
clearBootDeadline();
|
|
4601
|
+
stopBudgetCoordinator();
|
|
2134
4602
|
tuiConnectionState.dispose(`daemon shutdown (${reason})`);
|
|
2135
4603
|
clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
|
|
2136
4604
|
controlServer?.stop();
|
|
@@ -2138,27 +4606,23 @@ function shutdown(reason) {
|
|
|
2138
4606
|
codex.stop();
|
|
2139
4607
|
removePidFile();
|
|
2140
4608
|
removeStatusFile();
|
|
2141
|
-
process.exit(
|
|
4609
|
+
process.exit(exitCode);
|
|
2142
4610
|
}
|
|
2143
4611
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
2144
4612
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
2145
4613
|
process.on("exit", () => {
|
|
4614
|
+
codex.forceKillAppServerSync();
|
|
2146
4615
|
removePidFile();
|
|
2147
4616
|
removeStatusFile();
|
|
2148
4617
|
});
|
|
2149
4618
|
process.on("uncaughtException", (err) => {
|
|
2150
|
-
|
|
4619
|
+
processLogger.fatal("UNCAUGHT EXCEPTION", err);
|
|
2151
4620
|
});
|
|
2152
4621
|
process.on("unhandledRejection", (reason) => {
|
|
2153
|
-
|
|
4622
|
+
processLogger.fatal("UNHANDLED REJECTION", reason);
|
|
2154
4623
|
});
|
|
2155
4624
|
function log(msg) {
|
|
2156
|
-
|
|
2157
|
-
`;
|
|
2158
|
-
process.stderr.write(line);
|
|
2159
|
-
try {
|
|
2160
|
-
appendFileSync2(stateDir.logFile, line);
|
|
2161
|
-
} catch {}
|
|
4625
|
+
processLogger.log(msg);
|
|
2162
4626
|
}
|
|
2163
4627
|
if (daemonLifecycle.wasKilled()) {
|
|
2164
4628
|
log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
|
|
@@ -2166,4 +4630,5 @@ if (daemonLifecycle.wasKilled()) {
|
|
|
2166
4630
|
}
|
|
2167
4631
|
writePidFile();
|
|
2168
4632
|
startControlServer();
|
|
4633
|
+
armBootDeadline();
|
|
2169
4634
|
bootCodex();
|