@raysonmeng/agentbridge 0.1.6 → 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 +53 -6
- package/README.zh-CN.md +37 -1
- package/dist/cli.js +3982 -440
- 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 +1144 -136
- package/plugins/agentbridge/server/daemon.js +2620 -367
- package/scripts/install-safety.cjs +209 -0
- package/scripts/postinstall.cjs +114 -34
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,4634 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
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("dist"),
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/codex-adapter.ts
|
|
45
|
+
import { spawn, execFileSync } from "child_process";
|
|
46
|
+
import { createInterface } from "readline";
|
|
47
|
+
import { EventEmitter } from "events";
|
|
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
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/app-server-protocol.ts
|
|
323
|
+
var APP_SERVER_TRACKED_REQUEST_METHODS = [
|
|
324
|
+
"thread/start",
|
|
325
|
+
"thread/resume",
|
|
326
|
+
"turn/start"
|
|
327
|
+
];
|
|
328
|
+
var APP_SERVER_SERVER_REQUEST_METHODS = [
|
|
329
|
+
"item/permissions/requestApproval",
|
|
330
|
+
"item/fileChange/requestApproval",
|
|
331
|
+
"item/commandExecution/requestApproval"
|
|
332
|
+
];
|
|
333
|
+
var APP_SERVER_NOTIFICATION_METHODS = [
|
|
334
|
+
"turn/started",
|
|
335
|
+
"turn/completed",
|
|
336
|
+
"item/started",
|
|
337
|
+
"item/agentMessage/delta",
|
|
338
|
+
"item/completed"
|
|
339
|
+
];
|
|
340
|
+
var TRACKED_REQUEST_METHOD_SET = new Set(APP_SERVER_TRACKED_REQUEST_METHODS);
|
|
341
|
+
var SERVER_REQUEST_METHOD_SET = new Set(APP_SERVER_SERVER_REQUEST_METHODS);
|
|
342
|
+
var NOTIFICATION_METHOD_SET = new Set(APP_SERVER_NOTIFICATION_METHODS);
|
|
343
|
+
function isObjectRecord(value) {
|
|
344
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
345
|
+
}
|
|
346
|
+
function isTrackedAppServerRequestMethod(method) {
|
|
347
|
+
return typeof method === "string" && TRACKED_REQUEST_METHOD_SET.has(method);
|
|
348
|
+
}
|
|
349
|
+
function isAppServerRequestMessage(value) {
|
|
350
|
+
if (!isObjectRecord(value))
|
|
351
|
+
return false;
|
|
352
|
+
return (typeof value.id === "number" || typeof value.id === "string") && typeof value.method === "string";
|
|
353
|
+
}
|
|
354
|
+
function isAppServerNotification(value) {
|
|
355
|
+
if (!isObjectRecord(value))
|
|
356
|
+
return false;
|
|
357
|
+
return value.id === undefined && typeof value.method === "string" && NOTIFICATION_METHOD_SET.has(value.method);
|
|
358
|
+
}
|
|
359
|
+
function isAppServerResponseMessage(value) {
|
|
360
|
+
if (!isObjectRecord(value))
|
|
361
|
+
return false;
|
|
362
|
+
return (typeof value.id === "number" || typeof value.id === "string") && value.method === undefined && (("result" in value) || ("error" in value));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/codex-transport.ts
|
|
366
|
+
import { createServer, connect } from "net";
|
|
367
|
+
import { spawnSync } from "child_process";
|
|
368
|
+
import { mkdirSync as mkdirSync2, rmSync, chmodSync } from "fs";
|
|
369
|
+
import { join as join2 } from "path";
|
|
370
|
+
import { tmpdir } from "os";
|
|
371
|
+
var CODEX_TRANSPORT_ENV = "AGENTBRIDGE_CODEX_TRANSPORT";
|
|
372
|
+
var HEADER_SEP = `\r
|
|
373
|
+
\r
|
|
374
|
+
`;
|
|
375
|
+
var EXTENSIONS_HEADER_RE = /^sec-websocket-extensions:/i;
|
|
376
|
+
var MAX_UPGRADE_HEADER_BYTES = 64 * 1024;
|
|
377
|
+
function parseTransportMode(raw) {
|
|
378
|
+
switch ((raw ?? "").trim().toLowerCase()) {
|
|
379
|
+
case "ws":
|
|
380
|
+
return "ws";
|
|
381
|
+
case "unix":
|
|
382
|
+
return "unix";
|
|
383
|
+
case "auto":
|
|
384
|
+
case "":
|
|
385
|
+
return "auto";
|
|
386
|
+
default:
|
|
387
|
+
return "auto";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function probeCodexWsSupport(runHelp = defaultRunCodexAppServerHelp) {
|
|
391
|
+
const help = runHelp();
|
|
392
|
+
if (help === null)
|
|
393
|
+
return true;
|
|
394
|
+
return help.includes("ws://");
|
|
395
|
+
}
|
|
396
|
+
function defaultRunCodexAppServerHelp() {
|
|
397
|
+
try {
|
|
398
|
+
const res = spawnSync("codex", ["app-server", "--help"], {
|
|
399
|
+
encoding: "utf-8",
|
|
400
|
+
timeout: 5000
|
|
401
|
+
});
|
|
402
|
+
if (res.error || typeof res.stdout !== "string")
|
|
403
|
+
return null;
|
|
404
|
+
return res.stdout + (res.stderr ?? "");
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function resolveCodexTransport(mode, runHelp = defaultRunCodexAppServerHelp) {
|
|
410
|
+
if (mode === "ws")
|
|
411
|
+
return "ws";
|
|
412
|
+
if (mode === "unix")
|
|
413
|
+
return "unix";
|
|
414
|
+
return probeCodexWsSupport(runHelp) ? "ws" : "unix";
|
|
415
|
+
}
|
|
416
|
+
function codexSocketPath(appPort, baseTmpDir = tmpdir()) {
|
|
417
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
418
|
+
const dir = join2(baseTmpDir, `agentbridge-${uid}`);
|
|
419
|
+
const path = join2(dir, `codex-${appPort}.sock`);
|
|
420
|
+
if (path.length >= 104) {
|
|
421
|
+
throw new Error(`Codex unix socket path is too long for the platform (${path.length} >= 104): ${path}. ` + `Set a shorter TMPDIR or use ${CODEX_TRANSPORT_ENV}=ws.`);
|
|
422
|
+
}
|
|
423
|
+
return path;
|
|
424
|
+
}
|
|
425
|
+
function ensureSocketDir(socketPath) {
|
|
426
|
+
const dir = socketPath.slice(0, socketPath.lastIndexOf("/"));
|
|
427
|
+
if (!dir)
|
|
428
|
+
return;
|
|
429
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
430
|
+
try {
|
|
431
|
+
chmodSync(dir, 448);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
throw new Error(`Refusing to use Codex socket dir ${dir}: cannot enforce 0700 perms ` + `(${err.message}). Remove it or set a private TMPDIR.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function removeSocketFile(socketPath) {
|
|
437
|
+
try {
|
|
438
|
+
rmSync(socketPath, { force: true });
|
|
439
|
+
} catch {}
|
|
440
|
+
}
|
|
441
|
+
function codexListenArg(transport, appPort, socketPath) {
|
|
442
|
+
return transport === "unix" ? `unix://${socketPath}` : `ws://127.0.0.1:${appPort}`;
|
|
443
|
+
}
|
|
444
|
+
function stripWebSocketExtensions(headerBlock) {
|
|
445
|
+
return headerBlock.split(`\r
|
|
446
|
+
`).filter((line) => !EXTENSIONS_HEADER_RE.test(line)).join(`\r
|
|
447
|
+
`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
class TcpToUnixRelay {
|
|
451
|
+
tcpHost;
|
|
452
|
+
tcpPort;
|
|
453
|
+
unixPath;
|
|
454
|
+
log;
|
|
455
|
+
server = null;
|
|
456
|
+
pairs = new Set;
|
|
457
|
+
constructor(tcpHost, tcpPort, unixPath, log = () => {}) {
|
|
458
|
+
this.tcpHost = tcpHost;
|
|
459
|
+
this.tcpPort = tcpPort;
|
|
460
|
+
this.unixPath = unixPath;
|
|
461
|
+
this.log = log;
|
|
462
|
+
}
|
|
463
|
+
start() {
|
|
464
|
+
return new Promise((resolve, reject) => {
|
|
465
|
+
const server = createServer((tcp) => this.handleConnection(tcp));
|
|
466
|
+
const onListenError = (err) => {
|
|
467
|
+
server.removeListener("listening", onListening);
|
|
468
|
+
reject(err);
|
|
469
|
+
};
|
|
470
|
+
const onListening = () => {
|
|
471
|
+
server.removeListener("error", onListenError);
|
|
472
|
+
server.on("error", (err) => this.log(`relay server error: ${err.message}`));
|
|
473
|
+
this.server = server;
|
|
474
|
+
resolve();
|
|
475
|
+
};
|
|
476
|
+
server.once("error", onListenError);
|
|
477
|
+
server.once("listening", onListening);
|
|
478
|
+
server.listen(this.tcpPort, this.tcpHost);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
handleConnection(tcp) {
|
|
482
|
+
const unix = connect(this.unixPath);
|
|
483
|
+
const pair = { tcp, unix };
|
|
484
|
+
this.pairs.add(pair);
|
|
485
|
+
let closed = false;
|
|
486
|
+
const teardown = () => {
|
|
487
|
+
if (closed)
|
|
488
|
+
return;
|
|
489
|
+
closed = true;
|
|
490
|
+
this.pairs.delete(pair);
|
|
491
|
+
tcp.destroy();
|
|
492
|
+
unix.destroy();
|
|
493
|
+
};
|
|
494
|
+
let head = Buffer.alloc(0);
|
|
495
|
+
const onData = (chunk) => {
|
|
496
|
+
head = Buffer.concat([head, chunk]);
|
|
497
|
+
const sep = head.indexOf(HEADER_SEP);
|
|
498
|
+
if (sep === -1) {
|
|
499
|
+
if (head.length > MAX_UPGRADE_HEADER_BYTES) {
|
|
500
|
+
tcp.removeListener("data", onData);
|
|
501
|
+
unix.write(head);
|
|
502
|
+
head = Buffer.alloc(0);
|
|
503
|
+
tcp.pipe(unix);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
tcp.removeListener("data", onData);
|
|
508
|
+
const headers = head.subarray(0, sep).toString("utf8");
|
|
509
|
+
const rest = head.subarray(sep + HEADER_SEP.length);
|
|
510
|
+
unix.write(stripWebSocketExtensions(headers) + HEADER_SEP);
|
|
511
|
+
head = Buffer.alloc(0);
|
|
512
|
+
if (rest.length)
|
|
513
|
+
tcp.unshift(rest);
|
|
514
|
+
tcp.pipe(unix);
|
|
515
|
+
};
|
|
516
|
+
tcp.on("data", onData);
|
|
517
|
+
unix.pipe(tcp);
|
|
518
|
+
tcp.on("error", (e) => {
|
|
519
|
+
this.log(`relay tcp error: ${e.message}`);
|
|
520
|
+
teardown();
|
|
521
|
+
});
|
|
522
|
+
unix.on("error", (e) => {
|
|
523
|
+
this.log(`relay unix error: ${e.message}`);
|
|
524
|
+
teardown();
|
|
525
|
+
});
|
|
526
|
+
tcp.on("close", teardown);
|
|
527
|
+
unix.on("close", teardown);
|
|
528
|
+
}
|
|
529
|
+
get connectionCount() {
|
|
530
|
+
return this.pairs.size;
|
|
531
|
+
}
|
|
532
|
+
get port() {
|
|
533
|
+
const addr = this.server?.address();
|
|
534
|
+
return addr && typeof addr === "object" ? addr.port : this.tcpPort;
|
|
535
|
+
}
|
|
536
|
+
stop() {
|
|
537
|
+
if (this.server) {
|
|
538
|
+
this.server.close();
|
|
539
|
+
this.server = null;
|
|
540
|
+
}
|
|
541
|
+
for (const { tcp, unix } of this.pairs) {
|
|
542
|
+
tcp.destroy();
|
|
543
|
+
unix.destroy();
|
|
544
|
+
}
|
|
545
|
+
this.pairs.clear();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function waitForUnixWsReady(socketPath, maxRetries = 40, delayMs = 250) {
|
|
549
|
+
for (let i = 0;i < maxRetries; i++) {
|
|
550
|
+
if (await attemptUnixWsUpgrade(socketPath))
|
|
551
|
+
return;
|
|
552
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
553
|
+
}
|
|
554
|
+
throw new Error(`Codex unix app-server at ${socketPath} did not become ready`);
|
|
555
|
+
}
|
|
556
|
+
function attemptUnixWsUpgrade(socketPath) {
|
|
557
|
+
return new Promise((resolve) => {
|
|
558
|
+
let settled = false;
|
|
559
|
+
const done = (ok) => {
|
|
560
|
+
if (settled)
|
|
561
|
+
return;
|
|
562
|
+
settled = true;
|
|
563
|
+
try {
|
|
564
|
+
socket.destroy();
|
|
565
|
+
} catch {}
|
|
566
|
+
resolve(ok);
|
|
567
|
+
};
|
|
568
|
+
const socket = connect(socketPath, () => {
|
|
569
|
+
socket.write(`GET / HTTP/1.1\r
|
|
570
|
+
Host: localhost\r
|
|
571
|
+
Upgrade: websocket\r
|
|
572
|
+
Connection: Upgrade\r
|
|
573
|
+
` + `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r
|
|
574
|
+
Sec-WebSocket-Version: 13\r
|
|
575
|
+
\r
|
|
576
|
+
`);
|
|
577
|
+
});
|
|
578
|
+
let buf = "";
|
|
579
|
+
socket.on("data", (d) => {
|
|
580
|
+
buf += d.toString("utf8");
|
|
581
|
+
if (buf.includes(`\r
|
|
582
|
+
`))
|
|
583
|
+
done(buf.startsWith("HTTP/1.1 101"));
|
|
584
|
+
});
|
|
585
|
+
socket.on("error", () => done(false));
|
|
586
|
+
socket.on("close", () => done(false));
|
|
587
|
+
setTimeout(() => done(false), 1500);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/turn-notices.ts
|
|
592
|
+
var ADAPTER_DISCONNECT_REASON = "adapter disconnect";
|
|
593
|
+
var APP_SERVER_RECONNECT_NEW_TUI_REASON = "app-server reconnect for new TUI session";
|
|
594
|
+
var SILENT_ABORT_REASONS = new Set([
|
|
595
|
+
ADAPTER_DISCONNECT_REASON,
|
|
596
|
+
APP_SERVER_RECONNECT_NEW_TUI_REASON
|
|
597
|
+
]);
|
|
598
|
+
function buildTurnAbortedNotice(reason, replyWasRequired) {
|
|
599
|
+
if (SILENT_ABORT_REASONS.has(reason))
|
|
600
|
+
return null;
|
|
601
|
+
const tail = replyWasRequired ? " A reply you were waiting on will NOT arrive \u2014 retry your last message, or wait for the Codex TUI to reconnect." : " If you were waiting on a reply it will not arrive; retry, or wait for the Codex TUI to reconnect.";
|
|
602
|
+
return `\u26A0\uFE0F Codex's current turn ended without completing (${reason}). ` + "This usually means Codex hit an error (e.g. a rate limit / 429), the app-server connection dropped, or the turn was interrupted." + tail;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/codex-adapter.ts
|
|
606
|
+
class CodexAdapter extends EventEmitter {
|
|
607
|
+
static RESPONSE_TRACKING_TTL_MS = 30000;
|
|
608
|
+
proc = null;
|
|
609
|
+
appServerPid = null;
|
|
610
|
+
appServerWs = null;
|
|
611
|
+
tuiWs = null;
|
|
612
|
+
proxyServer = null;
|
|
613
|
+
transport = "ws";
|
|
614
|
+
socketPath = null;
|
|
615
|
+
relay = null;
|
|
616
|
+
threadId = null;
|
|
617
|
+
nextInjectionId = -1;
|
|
618
|
+
appPort;
|
|
619
|
+
proxyPort;
|
|
620
|
+
logFile;
|
|
621
|
+
logger;
|
|
622
|
+
tuiConnId = 0;
|
|
623
|
+
connIdCounter = 0;
|
|
624
|
+
secondaryConnections = new Map;
|
|
625
|
+
agentMessageBuffers = new Map;
|
|
626
|
+
pendingRequests = new Map;
|
|
627
|
+
activeTurnIds = new Set;
|
|
628
|
+
turnInProgress = false;
|
|
629
|
+
turnWatchdogs = new Map;
|
|
630
|
+
stalledTurnIds = new Set;
|
|
631
|
+
currentlyStalledTurnIds = new Set;
|
|
632
|
+
lastTurnEndedAbnormally = false;
|
|
633
|
+
lastEmittedPhase = "idle";
|
|
634
|
+
threadSwitchSeq = 0;
|
|
635
|
+
nextProxyId = 1e5;
|
|
636
|
+
upstreamToClient = new Map;
|
|
637
|
+
serverRequestToProxy = new Map;
|
|
638
|
+
pendingServerRequests = [];
|
|
639
|
+
pendingServerResponses = new Map;
|
|
640
|
+
staleProxyIds = new Map;
|
|
641
|
+
bridgeRequestIds = new Map;
|
|
642
|
+
intentionalDisconnect = false;
|
|
643
|
+
pendingTuiMessages = [];
|
|
644
|
+
reconnectingForNewSession = false;
|
|
645
|
+
replayingBufferedMessages = false;
|
|
646
|
+
appServerGeneration = 0;
|
|
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) {
|
|
657
|
+
super();
|
|
658
|
+
this.appPort = appPort;
|
|
659
|
+
this.proxyPort = proxyPort;
|
|
660
|
+
this.logFile = logFile;
|
|
661
|
+
this.logger = createProcessLogger({ component: "CodexAdapter", logFile: this.logFile });
|
|
662
|
+
}
|
|
663
|
+
get appServerUrl() {
|
|
664
|
+
return `ws://127.0.0.1:${this.appPort}`;
|
|
665
|
+
}
|
|
666
|
+
get proxyUrl() {
|
|
667
|
+
return `ws://127.0.0.1:${this.proxyPort}`;
|
|
668
|
+
}
|
|
669
|
+
get activeThreadId() {
|
|
670
|
+
return this.threadId;
|
|
671
|
+
}
|
|
672
|
+
async start() {
|
|
673
|
+
this.intentionalDisconnect = false;
|
|
674
|
+
await this.checkPorts();
|
|
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], {
|
|
683
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
684
|
+
});
|
|
685
|
+
this.appServerPid = this.proc.pid ?? null;
|
|
686
|
+
this.proc.on("error", (err) => this.emit("error", err));
|
|
687
|
+
this.proc.on("exit", (code) => {
|
|
688
|
+
this.appServerPid = null;
|
|
689
|
+
this.emit("exit", code);
|
|
690
|
+
});
|
|
691
|
+
const stderrRl = createInterface({ input: this.proc.stderr });
|
|
692
|
+
stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
|
|
693
|
+
const stdoutRl = createInterface({ input: this.proc.stdout });
|
|
694
|
+
stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
|
|
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
|
+
}
|
|
703
|
+
await this.connectToAppServer();
|
|
704
|
+
this.startProxy();
|
|
705
|
+
this.log(`Proxy ready on ${this.proxyUrl}`);
|
|
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
|
+
}
|
|
713
|
+
disconnect() {
|
|
714
|
+
this.intentionalDisconnect = true;
|
|
715
|
+
if (this.reconnectTimer) {
|
|
716
|
+
clearTimeout(this.reconnectTimer);
|
|
717
|
+
this.reconnectTimer = null;
|
|
718
|
+
}
|
|
719
|
+
this.outageQueue = [];
|
|
720
|
+
this.clearOutageTimer();
|
|
721
|
+
this.appServerWs?.close();
|
|
722
|
+
this.appServerWs = null;
|
|
723
|
+
for (const [id, sec] of this.secondaryConnections) {
|
|
724
|
+
try {
|
|
725
|
+
sec.appServerWs?.close();
|
|
726
|
+
} catch {}
|
|
727
|
+
this.secondaryConnections.delete(id);
|
|
728
|
+
}
|
|
729
|
+
this.proxyServer?.stop();
|
|
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);
|
|
737
|
+
this.clearResponseTrackingState();
|
|
738
|
+
this.resetTurnState(ADAPTER_DISCONNECT_REASON);
|
|
739
|
+
}
|
|
740
|
+
stop() {
|
|
741
|
+
this.intentionalDisconnect = true;
|
|
742
|
+
this.disconnect();
|
|
743
|
+
if (this.proc) {
|
|
744
|
+
const proc = this.proc;
|
|
745
|
+
this.proc = null;
|
|
746
|
+
proc.kill("SIGTERM");
|
|
747
|
+
const killTimer = setTimeout(() => {
|
|
748
|
+
try {
|
|
749
|
+
proc.kill("SIGKILL");
|
|
750
|
+
} catch {}
|
|
751
|
+
}, 2000);
|
|
752
|
+
proc.on("exit", () => clearTimeout(killTimer));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
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) {
|
|
764
|
+
if (!this.threadId) {
|
|
765
|
+
this.log("Cannot inject: no active thread");
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
769
|
+
this.log("Cannot inject: app-server WebSocket not connected");
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
if (this.turnInProgress) {
|
|
773
|
+
this.log(`Rejected injection: Codex turn is in progress (thread ${this.threadId})`);
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
this.log(`Injecting message into Codex (${text.length} chars)`);
|
|
777
|
+
const requestId = this.nextInjectionId--;
|
|
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
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
this.appServerWs.send(JSON.stringify({
|
|
789
|
+
method: "turn/start",
|
|
790
|
+
id: requestId,
|
|
791
|
+
params
|
|
792
|
+
}));
|
|
793
|
+
return true;
|
|
794
|
+
} catch (err) {
|
|
795
|
+
this.untrackBridgeRequestId(requestId);
|
|
796
|
+
this.log(`Injection send failed: ${err.message}`);
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async waitForHealthy(maxRetries = 20, delayMs = 500) {
|
|
801
|
+
for (let i = 0;i < maxRetries; i++) {
|
|
802
|
+
try {
|
|
803
|
+
const res = await fetch(`http://127.0.0.1:${this.appPort}/healthz`);
|
|
804
|
+
if (res.ok)
|
|
805
|
+
return;
|
|
806
|
+
} catch {}
|
|
807
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
808
|
+
}
|
|
809
|
+
throw new Error("Codex app-server failed to become healthy");
|
|
810
|
+
}
|
|
811
|
+
connectToAppServer(isReconnect = false) {
|
|
812
|
+
const generation = ++this.appServerGeneration;
|
|
813
|
+
return new Promise((resolve, reject) => {
|
|
814
|
+
const appWs = new WebSocket(this.appServerUrl);
|
|
815
|
+
appWs.onopen = () => {
|
|
816
|
+
if (this.appServerGeneration !== generation) {
|
|
817
|
+
appWs.close();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.appServerWs = appWs;
|
|
821
|
+
this.intentionalDisconnect = false;
|
|
822
|
+
this.reconnectAttempts = 0;
|
|
823
|
+
this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server");
|
|
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
|
+
}
|
|
833
|
+
resolve();
|
|
834
|
+
};
|
|
835
|
+
appWs.onmessage = (event) => {
|
|
836
|
+
if (this.appServerGeneration !== generation)
|
|
837
|
+
return;
|
|
838
|
+
const data = typeof event.data === "string" ? event.data : event.data.toString();
|
|
839
|
+
const forwarded = this.handleAppServerPayload(data);
|
|
840
|
+
if (forwarded === null)
|
|
841
|
+
return;
|
|
842
|
+
if (this.tuiWs) {
|
|
843
|
+
try {
|
|
844
|
+
this.tuiWs.send(forwarded);
|
|
845
|
+
} catch (e) {
|
|
846
|
+
this.log(`Failed to forward message to TUI: ${e.message}`);
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
this.log("WARNING: response from app-server but no TUI connected, message dropped");
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
appWs.onerror = () => {
|
|
853
|
+
if (this.appServerGeneration !== generation)
|
|
854
|
+
return;
|
|
855
|
+
this.log("App-server connection error");
|
|
856
|
+
if (!isReconnect)
|
|
857
|
+
reject(new Error("Failed to connect to app-server"));
|
|
858
|
+
};
|
|
859
|
+
appWs.onclose = () => {
|
|
860
|
+
if (this.appServerGeneration !== generation)
|
|
861
|
+
return;
|
|
862
|
+
this.handleAppServerClose();
|
|
863
|
+
};
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async reconnectAppServerForNewSession(tuiWs) {
|
|
867
|
+
this.appServerGeneration++;
|
|
868
|
+
this.intentionalDisconnect = true;
|
|
869
|
+
if (this.reconnectTimer) {
|
|
870
|
+
clearTimeout(this.reconnectTimer);
|
|
871
|
+
this.reconnectTimer = null;
|
|
872
|
+
}
|
|
873
|
+
const oldWs = this.appServerWs;
|
|
874
|
+
this.appServerWs = null;
|
|
875
|
+
if (oldWs) {
|
|
876
|
+
try {
|
|
877
|
+
oldWs.close();
|
|
878
|
+
} catch {}
|
|
879
|
+
}
|
|
880
|
+
this.clearResponseTrackingStateForAppServerReconnect();
|
|
881
|
+
this.resetTurnState(APP_SERVER_RECONNECT_NEW_TUI_REASON);
|
|
882
|
+
try {
|
|
883
|
+
await this.connectToAppServer(false);
|
|
884
|
+
this.log("App-server reconnected for new TUI session \u2014 replaying buffered messages");
|
|
885
|
+
const messages = this.pendingTuiMessages;
|
|
886
|
+
this.pendingTuiMessages = [];
|
|
887
|
+
this.reconnectingForNewSession = false;
|
|
888
|
+
this.replayingBufferedMessages = true;
|
|
889
|
+
try {
|
|
890
|
+
for (const msg of messages) {
|
|
891
|
+
this.onTuiMessage(tuiWs, msg);
|
|
892
|
+
}
|
|
893
|
+
} finally {
|
|
894
|
+
this.replayingBufferedMessages = false;
|
|
895
|
+
}
|
|
896
|
+
} catch (err) {
|
|
897
|
+
this.log(`Failed to reconnect app-server for new session: ${err.message}`);
|
|
898
|
+
this.pendingTuiMessages = [];
|
|
899
|
+
this.reconnectingForNewSession = false;
|
|
900
|
+
this.intentionalDisconnect = false;
|
|
901
|
+
this.scheduleReconnect();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
reconnectAttempts = 0;
|
|
905
|
+
reconnectTimer = null;
|
|
906
|
+
static MAX_RECONNECT_ATTEMPTS = 10;
|
|
907
|
+
static RECONNECT_BASE_DELAY_MS = 1000;
|
|
908
|
+
scheduleReconnect() {
|
|
909
|
+
if (!this.proc)
|
|
910
|
+
return;
|
|
911
|
+
if (this.reconnectAttempts >= CodexAdapter.MAX_RECONNECT_ATTEMPTS) {
|
|
912
|
+
this.log(`App-server reconnect failed after ${this.reconnectAttempts} attempts. Giving up.`);
|
|
913
|
+
this.emit("error", new Error("App-server connection lost and reconnect failed"));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const delay = Math.min(CodexAdapter.RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts), 30000);
|
|
917
|
+
this.reconnectAttempts++;
|
|
918
|
+
this.log(`Scheduling app-server reconnect attempt ${this.reconnectAttempts}/${CodexAdapter.MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`);
|
|
919
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
920
|
+
try {
|
|
921
|
+
await this.connectToAppServer(true);
|
|
922
|
+
this.log("App-server reconnect successful");
|
|
923
|
+
} catch {
|
|
924
|
+
this.log("App-server reconnect attempt failed");
|
|
925
|
+
this.scheduleReconnect();
|
|
926
|
+
}
|
|
927
|
+
}, delay);
|
|
928
|
+
}
|
|
929
|
+
handleAppServerClose() {
|
|
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})`);
|
|
933
|
+
this.appServerWs = null;
|
|
934
|
+
this.clearResponseTrackingState();
|
|
935
|
+
this.resetTurnState("app-server connection closed");
|
|
936
|
+
if (!intentional) {
|
|
937
|
+
this.scheduleReconnect();
|
|
938
|
+
}
|
|
939
|
+
}
|
|
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}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
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();
|
|
957
|
+
}
|
|
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);
|
|
1157
|
+
appWs.onopen = () => {
|
|
1158
|
+
if (!this.secondaryConnections.has(connId)) {
|
|
1159
|
+
appWs.close();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
this.log(`Secondary conn #${connId}: app-server WS connected, flushing ${entry.buffer.length} buffered messages`);
|
|
1163
|
+
for (const msg of entry.buffer) {
|
|
1164
|
+
try {
|
|
1165
|
+
appWs.send(msg);
|
|
1166
|
+
} catch {}
|
|
1167
|
+
}
|
|
1168
|
+
entry.buffer = [];
|
|
1169
|
+
};
|
|
1170
|
+
appWs.onmessage = (event) => {
|
|
1171
|
+
if (!this.secondaryConnections.has(connId))
|
|
1172
|
+
return;
|
|
1173
|
+
const data = typeof event.data === "string" ? event.data : event.data.toString();
|
|
1174
|
+
try {
|
|
1175
|
+
ws.send(data);
|
|
1176
|
+
} catch {}
|
|
1177
|
+
};
|
|
1178
|
+
appWs.onerror = () => {
|
|
1179
|
+
this.log(`Secondary conn #${connId}: app-server WS error`);
|
|
1180
|
+
};
|
|
1181
|
+
appWs.onclose = () => {
|
|
1182
|
+
this.log(`Secondary conn #${connId}: app-server WS closed`);
|
|
1183
|
+
const sec = this.secondaryConnections.get(connId);
|
|
1184
|
+
if (sec) {
|
|
1185
|
+
this.secondaryConnections.delete(connId);
|
|
1186
|
+
try {
|
|
1187
|
+
sec.tuiWs.close();
|
|
1188
|
+
} catch {}
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
replayPendingForThread(resumedThreadId, ws) {
|
|
1193
|
+
const remaining = [];
|
|
1194
|
+
for (const buffered of this.pendingServerRequests) {
|
|
1195
|
+
const belongsToThread = buffered.threadId === null || buffered.threadId === resumedThreadId;
|
|
1196
|
+
if (!belongsToThread) {
|
|
1197
|
+
remaining.push(buffered);
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const proxyId = this.nextProxyId++;
|
|
1201
|
+
try {
|
|
1202
|
+
const parsed = JSON.parse(buffered.raw);
|
|
1203
|
+
parsed.id = proxyId;
|
|
1204
|
+
ws.send(JSON.stringify(parsed));
|
|
1205
|
+
this.serverRequestToProxy.set(proxyId, {
|
|
1206
|
+
raw: buffered.raw,
|
|
1207
|
+
serverId: buffered.serverId,
|
|
1208
|
+
connId: this.tuiConnId,
|
|
1209
|
+
method: buffered.method,
|
|
1210
|
+
timestamp: Date.now(),
|
|
1211
|
+
threadId: buffered.threadId
|
|
1212
|
+
});
|
|
1213
|
+
if (buffered.threadId === null) {
|
|
1214
|
+
this.log(`WARNING: Replaying pending server request with unknown threadId (experimental fallback, may surface orphan UI on wrong thread): ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId})`);
|
|
1215
|
+
} else {
|
|
1216
|
+
this.log(`Replayed buffered server request on thread/resume: ${buffered.method} (server id=${buffered.serverId} \u2192 proxy id=${proxyId}, threadId=${buffered.threadId})`);
|
|
1217
|
+
}
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
this.log(`Failed to replay buffered server request: ${buffered.method} (server id=${buffered.serverId}): ${e.message}`);
|
|
1220
|
+
remaining.push(buffered);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
this.pendingServerRequests = remaining;
|
|
1224
|
+
}
|
|
1225
|
+
dropOrphanPendingRequests(reason, matchThreadId = null) {
|
|
1226
|
+
if (this.pendingServerRequests.length === 0)
|
|
1227
|
+
return;
|
|
1228
|
+
const remaining = [];
|
|
1229
|
+
for (const buffered of this.pendingServerRequests) {
|
|
1230
|
+
const shouldDrop = matchThreadId === null ? true : buffered.threadId !== null && buffered.threadId !== matchThreadId;
|
|
1231
|
+
if (shouldDrop) {
|
|
1232
|
+
this.log(`Dropped orphan pending server request: ${buffered.method} (server id=${buffered.serverId}, threadId=${buffered.threadId ?? "unknown"}, reason=${reason})`);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
remaining.push(buffered);
|
|
1236
|
+
}
|
|
1237
|
+
this.pendingServerRequests = remaining;
|
|
1238
|
+
}
|
|
1239
|
+
onTuiDisconnect(ws) {
|
|
1240
|
+
const connId = ws.data.connId;
|
|
1241
|
+
const secondary = this.secondaryConnections.get(connId);
|
|
1242
|
+
if (secondary) {
|
|
1243
|
+
this.log(`Secondary TUI disconnected (conn #${connId})`);
|
|
1244
|
+
this.secondaryConnections.delete(connId);
|
|
1245
|
+
if (secondary.appServerWs) {
|
|
1246
|
+
try {
|
|
1247
|
+
secondary.appServerWs.close();
|
|
1248
|
+
} catch {}
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (this.tuiWs === ws) {
|
|
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})`);
|
|
1255
|
+
this.tuiWs = null;
|
|
1256
|
+
if (this.reconnectingForNewSession) {
|
|
1257
|
+
this.log("Clearing pending TUI message buffer (TUI disconnected during app-server reconnect)");
|
|
1258
|
+
this.pendingTuiMessages = [];
|
|
1259
|
+
this.reconnectingForNewSession = false;
|
|
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
|
+
}
|
|
1266
|
+
this.emit("tuiDisconnected", connId);
|
|
1267
|
+
} else {
|
|
1268
|
+
this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
|
|
1269
|
+
}
|
|
1270
|
+
this.retireConnectionState(connId);
|
|
1271
|
+
}
|
|
1272
|
+
onTuiMessage(ws, msg) {
|
|
1273
|
+
const data = typeof msg === "string" ? msg : msg.toString();
|
|
1274
|
+
const connId = ws.data.connId;
|
|
1275
|
+
const secondary = this.secondaryConnections.get(connId);
|
|
1276
|
+
if (secondary) {
|
|
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);
|
|
1282
|
+
}
|
|
1283
|
+
this.sendOrBufferSecondary(secondary, data);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (connId !== this.tuiConnId) {
|
|
1287
|
+
this.log(`Dropping message from stale TUI conn #${connId} (current is #${this.tuiConnId})`);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
try {
|
|
1291
|
+
const parsed = JSON.parse(data);
|
|
1292
|
+
if (parsed.id !== undefined && !parsed.method) {
|
|
1293
|
+
const normalizedId = this.normalizeNumericId(parsed.id);
|
|
1294
|
+
if (!isNaN(normalizedId) && this.pendingServerResponses.has(normalizedId)) {
|
|
1295
|
+
this.log(`Ignoring duplicate approval response while app-server reconnect is pending (proxy id=${normalizedId})`);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const pending = !isNaN(normalizedId) ? this.serverRequestToProxy.get(normalizedId) : undefined;
|
|
1299
|
+
if (pending !== undefined) {
|
|
1300
|
+
if (pending.connId !== connId) {
|
|
1301
|
+
this.log(`Dropping stale server request response (proxy id=${normalizedId}, expected conn #${pending.connId}, got #${connId})`);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
parsed.id = pending.serverId;
|
|
1305
|
+
const forwardedResponse = JSON.stringify(parsed);
|
|
1306
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
1307
|
+
this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, "app-server disconnected");
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
this.appServerWs.send(forwardedResponse);
|
|
1312
|
+
this.serverRequestToProxy.delete(normalizedId);
|
|
1313
|
+
this.log(`TUI \u2192 app-server: ${pending.method} response (proxy id=${normalizedId} \u2192 server id=${pending.serverId})`);
|
|
1314
|
+
} catch (e) {
|
|
1315
|
+
this.bufferPendingServerResponse(normalizedId, pending, forwardedResponse, `send failed: ${e.message}`);
|
|
1316
|
+
}
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
} catch {}
|
|
1321
|
+
let detectedMethod;
|
|
1322
|
+
try {
|
|
1323
|
+
const parsed = JSON.parse(data);
|
|
1324
|
+
detectedMethod = typeof parsed.method === "string" ? parsed.method : undefined;
|
|
1325
|
+
} catch {}
|
|
1326
|
+
if (!this.replayingBufferedMessages) {
|
|
1327
|
+
if (detectedMethod === "initialize") {
|
|
1328
|
+
this.lastInitializeRaw = data;
|
|
1329
|
+
this.log("Detected initialize \u2014 reconnecting app-server for fresh session");
|
|
1330
|
+
this.reconnectingForNewSession = true;
|
|
1331
|
+
this.pendingTuiMessages = [data];
|
|
1332
|
+
this.reconnectAppServerForNewSession(ws);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (this.reconnectingForNewSession) {
|
|
1336
|
+
this.pendingTuiMessages.push(data);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
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
|
+
}
|
|
1351
|
+
let forwarded = data;
|
|
1352
|
+
try {
|
|
1353
|
+
const parsed = JSON.parse(data);
|
|
1354
|
+
const method = parsed.method ?? `response:${parsed.id}`;
|
|
1355
|
+
this.log(`TUI \u2192 app-server: ${method}`);
|
|
1356
|
+
if (parsed.id !== undefined && parsed.method) {
|
|
1357
|
+
const proxyId = this.nextProxyId++;
|
|
1358
|
+
this.upstreamToClient.set(proxyId, { connId, clientId: parsed.id });
|
|
1359
|
+
this.trackPendingRequest(parsed, connId, proxyId);
|
|
1360
|
+
parsed.id = proxyId;
|
|
1361
|
+
forwarded = JSON.stringify(parsed);
|
|
1362
|
+
} else {
|
|
1363
|
+
this.trackPendingRequest(parsed, connId);
|
|
1364
|
+
}
|
|
1365
|
+
} catch {
|
|
1366
|
+
this.log(`TUI \u2192 app-server: (unparseable)`);
|
|
1367
|
+
}
|
|
1368
|
+
if (this.appServerWs?.readyState === WebSocket.OPEN) {
|
|
1369
|
+
this.appServerWs.send(forwarded);
|
|
1370
|
+
} else {
|
|
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);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
handleAppServerPayload(raw) {
|
|
1407
|
+
try {
|
|
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
|
+
}
|
|
1417
|
+
if (isAppServerNotification(parsed) || typeof parsed === "object" && parsed !== null && !("id" in parsed)) {
|
|
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
|
+
}
|
|
1424
|
+
const forwarded = this.patchResponse(notificationLike, raw);
|
|
1425
|
+
this.interceptServerMessage(notificationLike);
|
|
1426
|
+
return forwarded;
|
|
1427
|
+
}
|
|
1428
|
+
if (isAppServerRequestMessage(parsed)) {
|
|
1429
|
+
this.handleServerRequest(parsed, raw);
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
if (isAppServerResponseMessage(parsed)) {
|
|
1433
|
+
return this.handleAppServerResponse(parsed, raw);
|
|
1434
|
+
}
|
|
1435
|
+
this.log(`Dropping unclassifiable app-server message: ${raw.slice(0, 100)}`);
|
|
1436
|
+
return null;
|
|
1437
|
+
} catch {
|
|
1438
|
+
return raw;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
handleServerRequest(parsed, raw) {
|
|
1442
|
+
const serverId = parsed.id;
|
|
1443
|
+
const method = parsed.method;
|
|
1444
|
+
const threadId = this.extractThreadIdFromParams(parsed.params);
|
|
1445
|
+
if (!this.tuiWs) {
|
|
1446
|
+
this.pendingServerRequests.push({ raw, serverId, method, threadId });
|
|
1447
|
+
this.log(`Server request buffered (no TUI): ${method} (server id=${serverId}, threadId=${threadId ?? "unknown"})`);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const proxyId = this.nextProxyId++;
|
|
1451
|
+
parsed.id = proxyId;
|
|
1452
|
+
try {
|
|
1453
|
+
this.tuiWs.send(JSON.stringify(parsed));
|
|
1454
|
+
} catch (e) {
|
|
1455
|
+
this.log(`Server request send failed, buffering: ${method} (server id=${serverId}): ${e.message}`);
|
|
1456
|
+
this.pendingServerRequests.push({ raw, serverId, method, threadId });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
this.serverRequestToProxy.set(proxyId, {
|
|
1460
|
+
raw,
|
|
1461
|
+
serverId,
|
|
1462
|
+
connId: this.tuiConnId,
|
|
1463
|
+
method,
|
|
1464
|
+
timestamp: Date.now(),
|
|
1465
|
+
threadId
|
|
1466
|
+
});
|
|
1467
|
+
this.log(`Server request: ${method} (server id=${serverId} \u2192 proxy id=${proxyId}, conn #${this.tuiConnId}, threadId=${threadId ?? "unknown"})`);
|
|
1468
|
+
}
|
|
1469
|
+
extractThreadIdFromParams(params) {
|
|
1470
|
+
if (typeof params !== "object" || params === null)
|
|
1471
|
+
return null;
|
|
1472
|
+
const tid = params.threadId;
|
|
1473
|
+
return typeof tid === "string" && tid.length > 0 ? tid : null;
|
|
1474
|
+
}
|
|
1475
|
+
normalizeNumericId(id) {
|
|
1476
|
+
if (typeof id === "number")
|
|
1477
|
+
return id;
|
|
1478
|
+
if (typeof id === "string" && /^-?\d+$/.test(id))
|
|
1479
|
+
return Number(id);
|
|
1480
|
+
return NaN;
|
|
1481
|
+
}
|
|
1482
|
+
bufferPendingServerResponse(proxyId, pending, forwardedResponse, reason) {
|
|
1483
|
+
this.pendingServerResponses.set(proxyId, {
|
|
1484
|
+
raw: forwardedResponse,
|
|
1485
|
+
serverId: pending.serverId,
|
|
1486
|
+
method: pending.method,
|
|
1487
|
+
timestamp: Date.now()
|
|
1488
|
+
});
|
|
1489
|
+
this.serverRequestToProxy.delete(proxyId);
|
|
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})`);
|
|
1491
|
+
}
|
|
1492
|
+
flushPendingServerResponses() {
|
|
1493
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
|
|
1494
|
+
return;
|
|
1495
|
+
for (const [proxyId, pending] of this.pendingServerResponses.entries()) {
|
|
1496
|
+
try {
|
|
1497
|
+
this.appServerWs.send(pending.raw);
|
|
1498
|
+
this.pendingServerResponses.delete(proxyId);
|
|
1499
|
+
this.log(`Flushed buffered approval response after app-server reconnect (proxy id=${proxyId} \u2192 server id=${pending.serverId})`);
|
|
1500
|
+
} catch (e) {
|
|
1501
|
+
this.log(`Failed to flush buffered approval response (proxy id=${proxyId}): ${e.message}`);
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
handleAppServerResponse(parsed, raw) {
|
|
1507
|
+
const responseId = parsed.id;
|
|
1508
|
+
const numericId = this.normalizeNumericId(responseId);
|
|
1509
|
+
const mapping = !isNaN(numericId) ? this.upstreamToClient.get(numericId) : undefined;
|
|
1510
|
+
if (mapping) {
|
|
1511
|
+
this.upstreamToClient.delete(numericId);
|
|
1512
|
+
if (mapping.connId !== this.tuiConnId) {
|
|
1513
|
+
this.log(`Dropping stale response (upstream id ${responseId}, from conn #${mapping.connId}, current #${this.tuiConnId})`);
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
parsed.id = mapping.clientId;
|
|
1517
|
+
this.log(`app-server \u2192 TUI: response (proxy id=${numericId} \u2192 client id=${String(mapping.clientId)}, conn #${mapping.connId})`);
|
|
1518
|
+
const forwarded = this.patchResponse(parsed, JSON.stringify(parsed));
|
|
1519
|
+
this.interceptServerMessage(parsed, mapping.connId);
|
|
1520
|
+
return forwarded;
|
|
1521
|
+
}
|
|
1522
|
+
if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
|
|
1523
|
+
if (parsed.error) {
|
|
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();
|
|
1528
|
+
} else {
|
|
1529
|
+
this.log(`Bridge-originated request completed (id ${responseId})`);
|
|
1530
|
+
}
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
if (!isNaN(numericId) && this.consumeStaleProxyId(numericId)) {
|
|
1534
|
+
this.log(`Dropping stale response for retired upstream id ${responseId}`);
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
this.log(`Dropping unmatched app-server response id ${String(responseId)}`);
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
patchResponse(parsed, raw) {
|
|
1541
|
+
if (isAppServerResponseMessage(parsed) && parsed.error && parsed.id !== undefined) {
|
|
1542
|
+
const errMsg = parsed.error.message ?? "";
|
|
1543
|
+
if (errMsg.includes("rate limits") || errMsg.includes("rateLimits")) {
|
|
1544
|
+
this.log(`Patching rateLimits error \u2192 mock success (id: ${parsed.id})`);
|
|
1545
|
+
return JSON.stringify({
|
|
1546
|
+
id: parsed.id,
|
|
1547
|
+
result: {
|
|
1548
|
+
rateLimits: {
|
|
1549
|
+
limitId: null,
|
|
1550
|
+
limitName: null,
|
|
1551
|
+
primary: { usedPercent: 0, windowDurationMins: 60, resetsAt: null },
|
|
1552
|
+
secondary: null,
|
|
1553
|
+
credits: null,
|
|
1554
|
+
planType: null
|
|
1555
|
+
},
|
|
1556
|
+
rateLimitsByLimitId: null
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return raw;
|
|
1562
|
+
}
|
|
1563
|
+
interceptServerMessage(msg, connId) {
|
|
1564
|
+
this.handleTrackedResponse(msg, connId);
|
|
1565
|
+
if ("method" in msg && typeof msg.method === "string" && isAppServerNotification(msg)) {
|
|
1566
|
+
this.handleServerNotification(msg);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
handleServerNotification(msg) {
|
|
1570
|
+
const { method, params } = msg;
|
|
1571
|
+
switch (method) {
|
|
1572
|
+
case "turn/started":
|
|
1573
|
+
this.markTurnStarted(params?.turn?.id);
|
|
1574
|
+
break;
|
|
1575
|
+
case "item/started": {
|
|
1576
|
+
const item = params?.item;
|
|
1577
|
+
if (item?.type === "agentMessage")
|
|
1578
|
+
this.agentMessageBuffers.set(item.id, []);
|
|
1579
|
+
break;
|
|
1580
|
+
}
|
|
1581
|
+
case "item/agentMessage/delta": {
|
|
1582
|
+
const itemId = params?.itemId;
|
|
1583
|
+
if (typeof itemId !== "string")
|
|
1584
|
+
break;
|
|
1585
|
+
const buf = this.agentMessageBuffers.get(itemId);
|
|
1586
|
+
if (buf && params?.delta)
|
|
1587
|
+
buf.push(params.delta);
|
|
1588
|
+
break;
|
|
1589
|
+
}
|
|
1590
|
+
case "item/completed": {
|
|
1591
|
+
const item = params?.item;
|
|
1592
|
+
if (item?.type === "agentMessage") {
|
|
1593
|
+
const content = this.extractContent(item);
|
|
1594
|
+
this.agentMessageBuffers.delete(item.id);
|
|
1595
|
+
if (content) {
|
|
1596
|
+
this.log(`Agent message completed (${content.length} chars)`);
|
|
1597
|
+
this.emit("agentMessage", {
|
|
1598
|
+
id: item.id,
|
|
1599
|
+
source: "codex",
|
|
1600
|
+
content,
|
|
1601
|
+
timestamp: Date.now()
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
case "turn/completed": {
|
|
1608
|
+
const wasInProgress = this.turnInProgress;
|
|
1609
|
+
this.markTurnCompleted(params?.turn?.id);
|
|
1610
|
+
if (wasInProgress && !this.turnInProgress) {
|
|
1611
|
+
this.emit("turnCompleted");
|
|
1612
|
+
}
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
extractContent(item) {
|
|
1618
|
+
if (item.content?.length) {
|
|
1619
|
+
return item.content.filter((c) => c.type === "text" && c.text).map((c) => c.text).join("");
|
|
1620
|
+
}
|
|
1621
|
+
return this.agentMessageBuffers.get(item.id)?.join("") ?? "";
|
|
1622
|
+
}
|
|
1623
|
+
pendingKey(rpcId, connId) {
|
|
1624
|
+
const base = this.requestKey(rpcId);
|
|
1625
|
+
if (!base)
|
|
1626
|
+
return null;
|
|
1627
|
+
return `${connId ?? this.tuiConnId}:${base}`;
|
|
1628
|
+
}
|
|
1629
|
+
trackPendingRequest(message, connId, _proxyId) {
|
|
1630
|
+
const rpcId = "id" in message ? message.id : undefined;
|
|
1631
|
+
const method = "method" in message && typeof message.method === "string" ? message.method : undefined;
|
|
1632
|
+
const key = this.pendingKey(rpcId, connId);
|
|
1633
|
+
if (!key || !isTrackedAppServerRequestMethod(method))
|
|
1634
|
+
return;
|
|
1635
|
+
const pending = { method };
|
|
1636
|
+
if (method === "turn/start") {
|
|
1637
|
+
const params = "params" in message && typeof message.params === "object" && message.params !== null ? message.params : undefined;
|
|
1638
|
+
const threadId = params?.threadId;
|
|
1639
|
+
if (typeof threadId === "string" && threadId.length > 0) {
|
|
1640
|
+
pending.threadId = threadId;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (method === "thread/start" || method === "thread/resume") {
|
|
1644
|
+
pending.threadSwitchSeq = ++this.threadSwitchSeq;
|
|
1645
|
+
}
|
|
1646
|
+
if (this.pendingRequests.has(key)) {
|
|
1647
|
+
this.log(`WARNING: overwriting pending request for key ${key}`);
|
|
1648
|
+
}
|
|
1649
|
+
this.pendingRequests.set(key, pending);
|
|
1650
|
+
}
|
|
1651
|
+
handleTrackedResponse(message, connId) {
|
|
1652
|
+
const key = this.pendingKey(message?.id, connId);
|
|
1653
|
+
if (!key)
|
|
1654
|
+
return;
|
|
1655
|
+
const pending = this.pendingRequests.get(key);
|
|
1656
|
+
if (!pending) {
|
|
1657
|
+
if (message?.result?.thread?.id) {
|
|
1658
|
+
this.log(`[track-resp] Unmatched response with thread.id=${message.result.thread.id}, key=${key}, pending keys=[${[...this.pendingRequests.keys()].join(",")}]`);
|
|
1659
|
+
}
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
this.pendingRequests.delete(key);
|
|
1663
|
+
if (message?.error) {
|
|
1664
|
+
this.log(`Tracked request failed (${pending.method}, id ${key}): ${message.error.message ?? "unknown error"}`);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
switch (pending.method) {
|
|
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
|
+
}
|
|
1673
|
+
const threadId = message?.result?.thread?.id;
|
|
1674
|
+
if (typeof threadId === "string" && threadId.length > 0) {
|
|
1675
|
+
this.setActiveThreadId(threadId, `thread/start response ${key}`);
|
|
1676
|
+
}
|
|
1677
|
+
this.dropOrphanPendingRequests(`thread/start (new session)`);
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
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
|
+
}
|
|
1685
|
+
const threadId = message?.result?.thread?.id;
|
|
1686
|
+
if (typeof threadId === "string" && threadId.length > 0) {
|
|
1687
|
+
this.setActiveThreadId(threadId, `thread/resume response ${key}`);
|
|
1688
|
+
if (this.tuiWs) {
|
|
1689
|
+
this.replayPendingForThread(threadId, this.tuiWs);
|
|
1690
|
+
}
|
|
1691
|
+
this.dropOrphanPendingRequests(`thread/resume to ${threadId}`, threadId);
|
|
1692
|
+
}
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
case "turn/start":
|
|
1696
|
+
if (pending.threadId) {
|
|
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
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
isLatestThreadSwitch(pending) {
|
|
1707
|
+
return pending.threadSwitchSeq === this.threadSwitchSeq;
|
|
1708
|
+
}
|
|
1709
|
+
setActiveThreadId(threadId, reason) {
|
|
1710
|
+
if (this.threadId === threadId)
|
|
1711
|
+
return;
|
|
1712
|
+
const previousThreadId = this.threadId;
|
|
1713
|
+
this.threadId = threadId;
|
|
1714
|
+
this.emit("threadChanged", { threadId, previousThreadId, reason });
|
|
1715
|
+
if (previousThreadId) {
|
|
1716
|
+
this.log(`Active thread changed: ${previousThreadId} \u2192 ${threadId} (${reason})`);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
this.log(`Thread detected: ${threadId} (${reason})`);
|
|
1720
|
+
this.emit("ready", threadId);
|
|
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
|
+
}
|
|
1737
|
+
markTurnStarted(turnId) {
|
|
1738
|
+
const wasInProgress = this.turnInProgress;
|
|
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);
|
|
1745
|
+
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
1746
|
+
if (!wasInProgress && this.turnInProgress) {
|
|
1747
|
+
this.emit("turnStarted");
|
|
1748
|
+
}
|
|
1749
|
+
this.notifyPhaseIfChanged();
|
|
1750
|
+
}
|
|
1751
|
+
markTurnCompleted(turnId) {
|
|
1752
|
+
if (typeof turnId === "string" && turnId.length > 0) {
|
|
1753
|
+
this.activeTurnIds.delete(turnId);
|
|
1754
|
+
this.clearTurnWatchdog(turnId);
|
|
1755
|
+
this.stalledTurnIds.delete(turnId);
|
|
1756
|
+
this.currentlyStalledTurnIds.delete(turnId);
|
|
1757
|
+
} else {
|
|
1758
|
+
this.activeTurnIds.clear();
|
|
1759
|
+
this.clearAllTurnWatchdogs();
|
|
1760
|
+
this.stalledTurnIds.clear();
|
|
1761
|
+
this.currentlyStalledTurnIds.clear();
|
|
1762
|
+
}
|
|
1763
|
+
this.lastTurnEndedAbnormally = false;
|
|
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();
|
|
1834
|
+
}
|
|
1835
|
+
requestKey(id) {
|
|
1836
|
+
if (typeof id === "number" || typeof id === "string")
|
|
1837
|
+
return String(id);
|
|
1838
|
+
return null;
|
|
1839
|
+
}
|
|
1840
|
+
retireConnectionState(connId) {
|
|
1841
|
+
const prefix = `${connId}:`;
|
|
1842
|
+
for (const key of this.pendingRequests.keys()) {
|
|
1843
|
+
if (key.startsWith(prefix))
|
|
1844
|
+
this.pendingRequests.delete(key);
|
|
1845
|
+
}
|
|
1846
|
+
for (const [upId, mapping] of this.upstreamToClient.entries()) {
|
|
1847
|
+
if (mapping.connId !== connId)
|
|
1848
|
+
continue;
|
|
1849
|
+
this.upstreamToClient.delete(upId);
|
|
1850
|
+
this.trackStaleProxyId(upId);
|
|
1851
|
+
}
|
|
1852
|
+
const requeuedServerRequests = [];
|
|
1853
|
+
for (const [proxyId, pending] of this.serverRequestToProxy.entries()) {
|
|
1854
|
+
if (pending.connId === connId) {
|
|
1855
|
+
this.serverRequestToProxy.delete(proxyId);
|
|
1856
|
+
requeuedServerRequests.push({
|
|
1857
|
+
raw: pending.raw,
|
|
1858
|
+
serverId: pending.serverId,
|
|
1859
|
+
method: pending.method,
|
|
1860
|
+
threadId: pending.threadId
|
|
1861
|
+
});
|
|
1862
|
+
this.log(`Requeued in-flight server request after TUI disconnect (proxy id=${proxyId}, server id=${pending.serverId}, method=${pending.method}, threadId=${pending.threadId ?? "unknown"})`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
if (requeuedServerRequests.length === 0)
|
|
1866
|
+
return;
|
|
1867
|
+
this.pendingServerRequests.push(...requeuedServerRequests);
|
|
1868
|
+
}
|
|
1869
|
+
trackStaleProxyId(proxyId) {
|
|
1870
|
+
this.clearTrackedId(this.staleProxyIds, proxyId);
|
|
1871
|
+
const timer = setTimeout(() => {
|
|
1872
|
+
this.staleProxyIds.delete(proxyId);
|
|
1873
|
+
}, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
|
|
1874
|
+
timer.unref?.();
|
|
1875
|
+
this.staleProxyIds.set(proxyId, timer);
|
|
1876
|
+
}
|
|
1877
|
+
consumeStaleProxyId(proxyId) {
|
|
1878
|
+
return this.clearTrackedId(this.staleProxyIds, proxyId);
|
|
1879
|
+
}
|
|
1880
|
+
trackBridgeRequestId(requestId) {
|
|
1881
|
+
this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
1882
|
+
const timer = setTimeout(() => {
|
|
1883
|
+
this.bridgeRequestIds.delete(requestId);
|
|
1884
|
+
}, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
|
|
1885
|
+
timer.unref?.();
|
|
1886
|
+
this.bridgeRequestIds.set(requestId, timer);
|
|
1887
|
+
}
|
|
1888
|
+
consumeBridgeRequestId(requestId) {
|
|
1889
|
+
return this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
1890
|
+
}
|
|
1891
|
+
untrackBridgeRequestId(requestId) {
|
|
1892
|
+
this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
1893
|
+
}
|
|
1894
|
+
clearTrackedId(store, id) {
|
|
1895
|
+
const timer = store.get(id);
|
|
1896
|
+
if (!timer)
|
|
1897
|
+
return false;
|
|
1898
|
+
clearTimeout(timer);
|
|
1899
|
+
store.delete(id);
|
|
1900
|
+
return true;
|
|
1901
|
+
}
|
|
1902
|
+
clearTransientResponseTrackingState() {
|
|
1903
|
+
this.pendingRequests.clear();
|
|
1904
|
+
this.upstreamToClient.clear();
|
|
1905
|
+
for (const timer of this.staleProxyIds.values()) {
|
|
1906
|
+
clearTimeout(timer);
|
|
1907
|
+
}
|
|
1908
|
+
this.staleProxyIds.clear();
|
|
1909
|
+
for (const timer of this.bridgeRequestIds.values()) {
|
|
1910
|
+
clearTimeout(timer);
|
|
1911
|
+
}
|
|
1912
|
+
this.bridgeRequestIds.clear();
|
|
1913
|
+
}
|
|
1914
|
+
clearResponseTrackingState() {
|
|
1915
|
+
this.clearTransientResponseTrackingState();
|
|
1916
|
+
this.serverRequestToProxy.clear();
|
|
1917
|
+
this.pendingServerRequests = [];
|
|
1918
|
+
this.pendingServerResponses.clear();
|
|
1919
|
+
}
|
|
1920
|
+
clearResponseTrackingStateForAppServerReconnect() {
|
|
1921
|
+
this.clearTransientResponseTrackingState();
|
|
1922
|
+
for (const pending of this.serverRequestToProxy.values()) {
|
|
1923
|
+
this.pendingServerRequests.push({
|
|
1924
|
+
raw: pending.raw,
|
|
1925
|
+
serverId: pending.serverId,
|
|
1926
|
+
method: pending.method,
|
|
1927
|
+
threadId: pending.threadId
|
|
1928
|
+
});
|
|
1929
|
+
this.log(`Requeued in-flight server request on app-server reconnect (server id=${pending.serverId}, method=${pending.method}, threadId=${pending.threadId ?? "unknown"})`);
|
|
1930
|
+
}
|
|
1931
|
+
this.serverRequestToProxy.clear();
|
|
1932
|
+
this.pendingServerResponses.clear();
|
|
1933
|
+
}
|
|
1934
|
+
static buildPortListenLsofCommand(port) {
|
|
1935
|
+
const { cmd, args } = portPidsCommand(port, "linux");
|
|
1936
|
+
return [cmd, ...args].join(" ");
|
|
1937
|
+
}
|
|
1938
|
+
async checkPorts() {
|
|
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
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
log(msg) {
|
|
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
|
+
};
|
|
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 };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// src/message-filter.ts
|
|
1985
|
+
var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
|
|
1986
|
+
function parseMarker(content) {
|
|
1987
|
+
const match = content.match(MARKER_REGEX);
|
|
1988
|
+
if (!match)
|
|
1989
|
+
return { marker: "untagged", body: content };
|
|
1990
|
+
return {
|
|
1991
|
+
marker: match[1].toLowerCase(),
|
|
1992
|
+
body: content.slice(match[0].length)
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
function classifyMessage(content, mode) {
|
|
1996
|
+
if (mode === "full")
|
|
1997
|
+
return { action: "forward", marker: "untagged" };
|
|
1998
|
+
const { marker } = parseMarker(content);
|
|
1999
|
+
switch (marker) {
|
|
2000
|
+
case "important":
|
|
2001
|
+
return { action: "forward", marker };
|
|
2002
|
+
case "status":
|
|
2003
|
+
return { action: "buffer", marker };
|
|
2004
|
+
case "fyi":
|
|
2005
|
+
return { action: "drop", marker };
|
|
2006
|
+
case "untagged":
|
|
2007
|
+
return { action: "forward", marker };
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
var REPLY_REQUIRED_INSTRUCTION = `
|
|
2011
|
+
|
|
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.`;
|
|
2013
|
+
class StatusBuffer {
|
|
2014
|
+
onFlush;
|
|
2015
|
+
buffer = [];
|
|
2016
|
+
flushTimer = null;
|
|
2017
|
+
flushThreshold;
|
|
2018
|
+
flushTimeoutMs;
|
|
2019
|
+
paused = false;
|
|
2020
|
+
constructor(onFlush, options) {
|
|
2021
|
+
this.onFlush = onFlush;
|
|
2022
|
+
this.flushThreshold = options?.flushThreshold ?? 3;
|
|
2023
|
+
this.flushTimeoutMs = options?.flushTimeoutMs ?? 15000;
|
|
2024
|
+
}
|
|
2025
|
+
get size() {
|
|
2026
|
+
return this.buffer.length;
|
|
2027
|
+
}
|
|
2028
|
+
pause() {
|
|
2029
|
+
this.paused = true;
|
|
2030
|
+
this.clearTimer();
|
|
2031
|
+
}
|
|
2032
|
+
resume() {
|
|
2033
|
+
this.paused = false;
|
|
2034
|
+
if (this.buffer.length > 0) {
|
|
2035
|
+
this.resetTimer();
|
|
2036
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
2037
|
+
this.flush("threshold reached after resume");
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
add(message) {
|
|
2042
|
+
this.buffer.push(message);
|
|
2043
|
+
if (this.paused)
|
|
2044
|
+
return;
|
|
2045
|
+
this.resetTimer();
|
|
2046
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
2047
|
+
this.flush("threshold reached");
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
flush(reason) {
|
|
2051
|
+
if (this.buffer.length === 0)
|
|
2052
|
+
return;
|
|
2053
|
+
this.clearTimer();
|
|
2054
|
+
const combined = this.buffer.map((m) => parseMarker(m.content).body).join(`
|
|
2055
|
+
---
|
|
2056
|
+
`);
|
|
2057
|
+
const summary = {
|
|
2058
|
+
id: `status_summary_${Date.now()}`,
|
|
2059
|
+
source: "codex",
|
|
2060
|
+
content: `[STATUS summary \u2014 ${this.buffer.length} update(s), flushed: ${reason}]
|
|
2061
|
+
${combined}`,
|
|
2062
|
+
timestamp: Date.now()
|
|
2063
|
+
};
|
|
2064
|
+
this.onFlush(summary);
|
|
2065
|
+
this.buffer = [];
|
|
2066
|
+
}
|
|
2067
|
+
dispose() {
|
|
2068
|
+
this.clearTimer();
|
|
2069
|
+
this.buffer = [];
|
|
2070
|
+
}
|
|
2071
|
+
clearTimer() {
|
|
2072
|
+
if (this.flushTimer) {
|
|
2073
|
+
clearTimeout(this.flushTimer);
|
|
2074
|
+
this.flushTimer = null;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
resetTimer() {
|
|
2078
|
+
this.clearTimer();
|
|
2079
|
+
this.flushTimer = setTimeout(() => {
|
|
2080
|
+
this.flushTimer = null;
|
|
2081
|
+
this.flush("timeout");
|
|
2082
|
+
}, this.flushTimeoutMs);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// src/tui-connection-state.ts
|
|
2087
|
+
class TuiConnectionState {
|
|
2088
|
+
options;
|
|
2089
|
+
bridgeReady = false;
|
|
2090
|
+
tuiConnected = false;
|
|
2091
|
+
disconnectNotificationShown = false;
|
|
2092
|
+
disconnectNotificationTimer = null;
|
|
2093
|
+
constructor(options) {
|
|
2094
|
+
this.options = options;
|
|
2095
|
+
}
|
|
2096
|
+
canReply() {
|
|
2097
|
+
if (!this.bridgeReady)
|
|
2098
|
+
return false;
|
|
2099
|
+
return this.tuiConnected || this.disconnectNotificationTimer !== null;
|
|
2100
|
+
}
|
|
2101
|
+
snapshot() {
|
|
2102
|
+
return {
|
|
2103
|
+
bridgeReady: this.bridgeReady,
|
|
2104
|
+
tuiConnected: this.tuiConnected,
|
|
2105
|
+
disconnectNotificationShown: this.disconnectNotificationShown,
|
|
2106
|
+
hasPendingDisconnectNotification: this.disconnectNotificationTimer !== null
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
markBridgeReady() {
|
|
2110
|
+
this.bridgeReady = true;
|
|
2111
|
+
this.disconnectNotificationShown = false;
|
|
2112
|
+
this.clearPendingDisconnectNotification("thread became ready");
|
|
2113
|
+
}
|
|
2114
|
+
handleTuiConnected(connId) {
|
|
2115
|
+
const reconnectingAfterNotice = this.disconnectNotificationShown && this.bridgeReady;
|
|
2116
|
+
this.tuiConnected = true;
|
|
2117
|
+
this.clearPendingDisconnectNotification(`TUI reconnected as conn #${connId}`);
|
|
2118
|
+
if (reconnectingAfterNotice) {
|
|
2119
|
+
this.disconnectNotificationShown = false;
|
|
2120
|
+
this.options.onReconnectAfterNotice(connId);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
handleTuiDisconnected(connId) {
|
|
2124
|
+
this.tuiConnected = false;
|
|
2125
|
+
if (!this.bridgeReady) {
|
|
2126
|
+
this.options.log?.(`Suppressing pre-ready TUI disconnect notification (conn #${connId})`);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
this.scheduleDisconnectNotification(connId);
|
|
2130
|
+
}
|
|
2131
|
+
handleCodexExit() {
|
|
2132
|
+
this.bridgeReady = false;
|
|
2133
|
+
this.tuiConnected = false;
|
|
2134
|
+
this.disconnectNotificationShown = false;
|
|
2135
|
+
this.clearPendingDisconnectNotification("Codex process exited");
|
|
2136
|
+
}
|
|
2137
|
+
dispose(reason = "disposed") {
|
|
2138
|
+
this.clearPendingDisconnectNotification(reason);
|
|
2139
|
+
}
|
|
2140
|
+
clearPendingDisconnectNotification(reason) {
|
|
2141
|
+
if (!this.disconnectNotificationTimer)
|
|
2142
|
+
return;
|
|
2143
|
+
clearTimeout(this.disconnectNotificationTimer);
|
|
2144
|
+
this.disconnectNotificationTimer = null;
|
|
2145
|
+
if (reason) {
|
|
2146
|
+
this.options.log?.(`Cleared pending TUI disconnect notification (${reason})`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
scheduleDisconnectNotification(connId) {
|
|
2150
|
+
this.clearPendingDisconnectNotification("rescheduled");
|
|
2151
|
+
this.disconnectNotificationTimer = setTimeout(() => {
|
|
2152
|
+
this.disconnectNotificationTimer = null;
|
|
2153
|
+
if (this.tuiConnected) {
|
|
2154
|
+
this.options.log?.(`Skipping TUI disconnect notification for conn #${connId} because TUI already reconnected`);
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
this.disconnectNotificationShown = true;
|
|
2158
|
+
this.options.log?.(`Codex TUI disconnect persisted past grace window (conn #${connId})`);
|
|
2159
|
+
this.options.onDisconnectPersisted(connId);
|
|
2160
|
+
}, this.options.disconnectGraceMs);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// src/daemon-lifecycle.ts
|
|
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";
|
|
2167
|
+
import { fileURLToPath } from "url";
|
|
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;
|
|
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);
|
|
2190
|
+
|
|
2191
|
+
class DaemonLifecycle {
|
|
2192
|
+
stateDir;
|
|
2193
|
+
controlPort;
|
|
2194
|
+
log;
|
|
2195
|
+
constructor(opts) {
|
|
2196
|
+
this.stateDir = opts.stateDir;
|
|
2197
|
+
this.controlPort = opts.controlPort;
|
|
2198
|
+
this.log = opts.log;
|
|
2199
|
+
}
|
|
2200
|
+
get healthUrl() {
|
|
2201
|
+
return `http://127.0.0.1:${this.controlPort}/healthz`;
|
|
2202
|
+
}
|
|
2203
|
+
get readyUrl() {
|
|
2204
|
+
return `http://127.0.0.1:${this.controlPort}/readyz`;
|
|
2205
|
+
}
|
|
2206
|
+
get controlWsUrl() {
|
|
2207
|
+
return `ws://127.0.0.1:${this.controlPort}/ws`;
|
|
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
|
+
}
|
|
2249
|
+
async ensureRunning() {
|
|
2250
|
+
if (await this.isHealthy()) {
|
|
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
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
const existingPid = this.readPid();
|
|
2279
|
+
if (existingPid) {
|
|
2280
|
+
if (isProcessAlive(existingPid)) {
|
|
2281
|
+
if (this.isDaemonProcess(existingPid)) {
|
|
2282
|
+
try {
|
|
2283
|
+
await this.waitForReady(REUSE_READY_RETRIES, REUSE_READY_DELAY_MS);
|
|
2284
|
+
return;
|
|
2285
|
+
} catch {
|
|
2286
|
+
this.log(`Existing daemon process ${existingPid} never became ready \u2014 replacing`);
|
|
2287
|
+
await this.replaceUnhealthyDaemon(existingPid);
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
|
|
2292
|
+
}
|
|
2293
|
+
this.removeStalePidFile();
|
|
2294
|
+
}
|
|
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
|
+
}
|
|
2316
|
+
this.launch();
|
|
2317
|
+
await this.waitForReady();
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
async isHealthy() {
|
|
2321
|
+
try {
|
|
2322
|
+
const response = await fetchWithTimeout(this.healthUrl);
|
|
2323
|
+
return response.ok;
|
|
2324
|
+
} catch {
|
|
2325
|
+
return false;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
async waitForHealthy(maxRetries = 40, delayMs = 250) {
|
|
2329
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
2330
|
+
if (await this.isHealthy())
|
|
2331
|
+
return;
|
|
2332
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2333
|
+
}
|
|
2334
|
+
throw new Error(`Timed out waiting for AgentBridge daemon health on ${this.healthUrl}`);
|
|
2335
|
+
}
|
|
2336
|
+
async isReady() {
|
|
2337
|
+
try {
|
|
2338
|
+
const response = await fetchWithTimeout(this.readyUrl);
|
|
2339
|
+
return response.ok;
|
|
2340
|
+
} catch {
|
|
2341
|
+
return false;
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
async waitForReady(maxRetries = 40, delayMs = 250) {
|
|
2345
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
2346
|
+
if (await this.isReady())
|
|
2347
|
+
return;
|
|
2348
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
2349
|
+
}
|
|
2350
|
+
throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
|
|
2351
|
+
}
|
|
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");
|
|
2367
|
+
return JSON.parse(raw);
|
|
2368
|
+
} catch {
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
writeStatus(status) {
|
|
2373
|
+
this.stateDir.ensure();
|
|
2374
|
+
writeFileSync(this.stateDir.statusFile, JSON.stringify(status, null, 2) + `
|
|
2375
|
+
`, "utf-8");
|
|
2376
|
+
}
|
|
2377
|
+
readPid() {
|
|
2378
|
+
try {
|
|
2379
|
+
const raw = readFileSync(this.stateDir.pidFile, "utf-8").trim();
|
|
2380
|
+
if (!raw)
|
|
2381
|
+
return null;
|
|
2382
|
+
const pid = Number.parseInt(raw, 10);
|
|
2383
|
+
return Number.isFinite(pid) ? pid : null;
|
|
2384
|
+
} catch {
|
|
2385
|
+
return null;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
writePid(pid) {
|
|
2389
|
+
this.stateDir.ensure();
|
|
2390
|
+
writeFileSync(this.stateDir.pidFile, `${pid ?? process.pid}
|
|
2391
|
+
`, "utf-8");
|
|
2392
|
+
}
|
|
2393
|
+
removePidFile() {
|
|
2394
|
+
try {
|
|
2395
|
+
unlinkSync2(this.stateDir.pidFile);
|
|
2396
|
+
} catch {}
|
|
2397
|
+
}
|
|
2398
|
+
removeStatusFile() {
|
|
2399
|
+
try {
|
|
2400
|
+
unlinkSync2(this.stateDir.statusFile);
|
|
2401
|
+
} catch {}
|
|
2402
|
+
}
|
|
2403
|
+
markKilled() {
|
|
2404
|
+
this.stateDir.ensure();
|
|
2405
|
+
writeFileSync(this.stateDir.killedFile, `${Date.now()}
|
|
2406
|
+
`, "utf-8");
|
|
2407
|
+
}
|
|
2408
|
+
clearKilled() {
|
|
2409
|
+
try {
|
|
2410
|
+
unlinkSync2(this.stateDir.killedFile);
|
|
2411
|
+
} catch {}
|
|
2412
|
+
}
|
|
2413
|
+
wasKilled() {
|
|
2414
|
+
return existsSync3(this.stateDir.killedFile);
|
|
2415
|
+
}
|
|
2416
|
+
launch() {
|
|
2417
|
+
this.stateDir.ensure();
|
|
2418
|
+
this.log(`Launching detached daemon on control port ${this.controlPort}`);
|
|
2419
|
+
const daemonProc = spawn2(process.execPath, ["run", DAEMON_PATH], {
|
|
2420
|
+
cwd: process.cwd(),
|
|
2421
|
+
env: {
|
|
2422
|
+
...process.env,
|
|
2423
|
+
AGENTBRIDGE_CONTROL_PORT: String(this.controlPort),
|
|
2424
|
+
AGENTBRIDGE_STATE_DIR: this.stateDir.dir
|
|
2425
|
+
},
|
|
2426
|
+
detached: true,
|
|
2427
|
+
stdio: "ignore"
|
|
2428
|
+
});
|
|
2429
|
+
daemonProc.unref();
|
|
2430
|
+
}
|
|
2431
|
+
removeStalePidFile() {
|
|
2432
|
+
this.log("Removing stale pid file");
|
|
2433
|
+
this.removePidFile();
|
|
2434
|
+
}
|
|
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();
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
acquireLockStrict(reclaimed = false) {
|
|
2467
|
+
this.stateDir.ensure();
|
|
2468
|
+
let fd = null;
|
|
2469
|
+
try {
|
|
2470
|
+
fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
2471
|
+
writeFileSync(fd, `${process.pid}
|
|
2472
|
+
`);
|
|
2473
|
+
closeSync(fd);
|
|
2474
|
+
return true;
|
|
2475
|
+
} catch (err) {
|
|
2476
|
+
if (fd !== null && err.code !== "EEXIST") {
|
|
2477
|
+
try {
|
|
2478
|
+
closeSync(fd);
|
|
2479
|
+
} catch {}
|
|
2480
|
+
this.releaseLock();
|
|
2481
|
+
}
|
|
2482
|
+
if (err.code === "EEXIST") {
|
|
2483
|
+
if (reclaimed)
|
|
2484
|
+
return false;
|
|
2485
|
+
try {
|
|
2486
|
+
const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
|
|
2487
|
+
if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
|
|
2488
|
+
this.log(`Stale startup lock from dead process ${holderPid}, reclaiming`);
|
|
2489
|
+
this.releaseLock();
|
|
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);
|
|
2496
|
+
}
|
|
2497
|
+
} catch {
|
|
2498
|
+
return false;
|
|
2499
|
+
}
|
|
2500
|
+
return false;
|
|
2501
|
+
}
|
|
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;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
releaseLock() {
|
|
2522
|
+
try {
|
|
2523
|
+
unlinkSync2(this.stateDir.lockFile);
|
|
2524
|
+
} catch {}
|
|
2525
|
+
}
|
|
2526
|
+
async kill(gracefulTimeoutMs = 3000, pidOverride) {
|
|
2527
|
+
const pid = pidOverride ?? this.readPid();
|
|
2528
|
+
if (!pid) {
|
|
2529
|
+
this.log("No daemon pid file found");
|
|
2530
|
+
this.cleanup();
|
|
2531
|
+
return false;
|
|
2532
|
+
}
|
|
2533
|
+
if (!isProcessAlive(pid)) {
|
|
2534
|
+
this.log(`Daemon pid ${pid} is not alive, cleaning up stale files`);
|
|
2535
|
+
this.cleanup();
|
|
2536
|
+
return false;
|
|
2537
|
+
}
|
|
2538
|
+
if (!this.isDaemonProcess(pid)) {
|
|
2539
|
+
this.log(`Pid ${pid} is alive but is NOT an AgentBridge daemon \u2014 refusing to kill. Cleaning up stale pid file.`);
|
|
2540
|
+
this.cleanup();
|
|
2541
|
+
return false;
|
|
2542
|
+
}
|
|
2543
|
+
this.log(`Sending SIGTERM to daemon pid ${pid}`);
|
|
2544
|
+
try {
|
|
2545
|
+
process.kill(pid, "SIGTERM");
|
|
2546
|
+
} catch {
|
|
2547
|
+
this.cleanup();
|
|
2548
|
+
return false;
|
|
2549
|
+
}
|
|
2550
|
+
const deadline = Date.now() + gracefulTimeoutMs;
|
|
2551
|
+
while (Date.now() < deadline) {
|
|
2552
|
+
if (!isProcessAlive(pid)) {
|
|
2553
|
+
this.log(`Daemon pid ${pid} stopped gracefully`);
|
|
2554
|
+
this.cleanup();
|
|
2555
|
+
return true;
|
|
2556
|
+
}
|
|
2557
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2558
|
+
}
|
|
2559
|
+
this.log(`Daemon pid ${pid} did not stop gracefully, sending SIGKILL`);
|
|
2560
|
+
try {
|
|
2561
|
+
process.kill(pid, "SIGKILL");
|
|
2562
|
+
} catch {}
|
|
2563
|
+
this.cleanup();
|
|
2564
|
+
return true;
|
|
2565
|
+
}
|
|
2566
|
+
isDaemonProcess(pid) {
|
|
2567
|
+
try {
|
|
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;
|
|
2572
|
+
} catch {
|
|
2573
|
+
return false;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
cleanup() {
|
|
2577
|
+
this.removePidFile();
|
|
2578
|
+
this.removeStatusFile();
|
|
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);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
function isProcessAlive(pid) {
|
|
2591
|
+
try {
|
|
2592
|
+
process.kill(pid, 0);
|
|
2593
|
+
return true;
|
|
2594
|
+
} catch {
|
|
2595
|
+
return false;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
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
|
+
}
|
|
3544
|
+
|
|
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;
|
|
3586
|
+
}
|
|
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
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
return null;
|
|
3625
|
+
}
|
|
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);
|
|
3636
|
+
}
|
|
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;
|
|
3665
|
+
}
|
|
3666
|
+
}
|
|
3667
|
+
|
|
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;
|
|
3701
|
+
}
|
|
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;
|
|
3715
|
+
try {
|
|
3716
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
3717
|
+
} catch {
|
|
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
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
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`);
|
|
3780
|
+
return null;
|
|
3781
|
+
}
|
|
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;
|
|
3786
|
+
}
|
|
3787
|
+
if (attempt < attempts) {
|
|
3788
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
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;
|
|
3834
|
+
}
|
|
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);
|
|
3842
|
+
}
|
|
3843
|
+
return target.pongCount > baseline;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
// src/daemon.ts
|
|
3847
|
+
var stateDir = new StateDirResolver;
|
|
3848
|
+
stateDir.ensure();
|
|
3849
|
+
var configService = new ConfigService;
|
|
3850
|
+
var config = configService.loadOrDefault();
|
|
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);
|
|
3854
|
+
var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
|
|
3855
|
+
var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
|
|
3856
|
+
var CLAUDE_DISCONNECT_GRACE_MS = 5000;
|
|
3857
|
+
var MAX_BUFFERED_MESSAGES = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
|
|
3858
|
+
var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "filtered";
|
|
3859
|
+
var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
|
|
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);
|
|
3865
|
+
var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
|
|
3866
|
+
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
|
|
3867
|
+
var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
|
|
3868
|
+
var controlServer = null;
|
|
3869
|
+
var attachedClaude = null;
|
|
3870
|
+
var nextControlClientId = 0;
|
|
3871
|
+
var nextSystemMessageId = 0;
|
|
3872
|
+
var codexBootstrapped = false;
|
|
3873
|
+
var attentionWindowTimer = null;
|
|
3874
|
+
var inAttentionWindow = false;
|
|
3875
|
+
var replyTracker = new ReplyRequiredTracker;
|
|
3876
|
+
var shuttingDown = false;
|
|
3877
|
+
var bootDeadlineTimer = null;
|
|
3878
|
+
var idleShutdownTimer = null;
|
|
3879
|
+
var claudeDisconnectTimer = null;
|
|
3880
|
+
var lastAttachStatusSentTs = 0;
|
|
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;
|
|
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
|
+
}
|
|
3927
|
+
var tuiConnectionState = new TuiConnectionState({
|
|
3928
|
+
disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
|
|
3929
|
+
log,
|
|
3930
|
+
onDisconnectPersisted: (connId) => {
|
|
3931
|
+
emitToClaude(systemMessage("system_tui_disconnected", `\u26A0\uFE0F Codex TUI disconnected (conn #${connId}). Codex is still running in the background \u2014 reconnect the TUI to resume.`));
|
|
3932
|
+
},
|
|
3933
|
+
onReconnectAfterNotice: (connId) => {
|
|
3934
|
+
emitToClaude(systemMessage("system_tui_reconnected", `\u2705 Codex TUI reconnected (conn #${connId}). Bridge restored, communication can continue.`));
|
|
3935
|
+
}
|
|
3936
|
+
});
|
|
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
|
+
});
|
|
3950
|
+
codex.on("turnStarted", () => {
|
|
3951
|
+
log("Codex turn started");
|
|
3952
|
+
emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
|
|
3953
|
+
});
|
|
3954
|
+
codex.on("agentMessage", (msg) => {
|
|
3955
|
+
if (msg.source !== "codex")
|
|
3956
|
+
return;
|
|
3957
|
+
const result = classifyMessage(msg.content, FILTER_MODE);
|
|
3958
|
+
if (replyTracker.isArmed) {
|
|
3959
|
+
log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
|
|
3960
|
+
replyTracker.noteForwarded();
|
|
3961
|
+
if (statusBuffer.size > 0) {
|
|
3962
|
+
statusBuffer.flush("reply-required message arrived");
|
|
3963
|
+
}
|
|
3964
|
+
emitToClaude(msg);
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
if (inAttentionWindow && result.marker === "status") {
|
|
3968
|
+
log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
|
|
3969
|
+
statusBuffer.add(msg);
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3972
|
+
log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
|
|
3973
|
+
switch (result.action) {
|
|
3974
|
+
case "forward":
|
|
3975
|
+
if (result.marker === "important" && statusBuffer.size > 0) {
|
|
3976
|
+
statusBuffer.flush("important message arrived");
|
|
3977
|
+
}
|
|
3978
|
+
emitToClaude(msg);
|
|
3979
|
+
if (result.marker === "important") {
|
|
3980
|
+
startAttentionWindow();
|
|
3981
|
+
}
|
|
3982
|
+
break;
|
|
3983
|
+
case "buffer":
|
|
3984
|
+
statusBuffer.add(msg);
|
|
3985
|
+
break;
|
|
3986
|
+
case "drop":
|
|
3987
|
+
break;
|
|
3988
|
+
}
|
|
3989
|
+
});
|
|
3990
|
+
codex.on("turnCompleted", () => {
|
|
3991
|
+
log("Codex turn completed");
|
|
3992
|
+
statusBuffer.flush("turn completed");
|
|
3993
|
+
const { warnReplyMissing } = replyTracker.consumeOnTurnComplete();
|
|
3994
|
+
if (warnReplyMissing) {
|
|
3995
|
+
log("\u26A0\uFE0F Reply was required but Codex did not send any agentMessage");
|
|
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."));
|
|
3997
|
+
}
|
|
3998
|
+
emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
|
|
3999
|
+
startAttentionWindow();
|
|
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
|
+
});
|
|
4014
|
+
codex.on("ready", (threadId) => {
|
|
4015
|
+
tuiConnectionState.markBridgeReady();
|
|
4016
|
+
log(`Codex ready \u2014 thread ${threadId}`);
|
|
4017
|
+
log("Bridge fully operational");
|
|
4018
|
+
emitToClaude(systemMessage("system_ready", currentReadyMessage()));
|
|
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
|
+
});
|
|
4036
|
+
});
|
|
4037
|
+
codex.on("tuiConnected", (connId) => {
|
|
4038
|
+
tuiConnectionState.handleTuiConnected(connId);
|
|
4039
|
+
cancelIdleShutdown();
|
|
4040
|
+
log(`Codex TUI connected (conn #${connId})`);
|
|
4041
|
+
broadcastStatus();
|
|
4042
|
+
});
|
|
4043
|
+
codex.on("tuiDisconnected", (connId) => {
|
|
4044
|
+
tuiConnectionState.handleTuiDisconnected(connId);
|
|
4045
|
+
log(`Codex TUI disconnected (conn #${connId})`);
|
|
4046
|
+
broadcastStatus();
|
|
4047
|
+
scheduleIdleShutdown();
|
|
4048
|
+
});
|
|
4049
|
+
codex.on("error", (err) => {
|
|
4050
|
+
log(`Codex error: ${err.message}`);
|
|
4051
|
+
});
|
|
4052
|
+
codex.on("exit", (code) => {
|
|
4053
|
+
log(`Codex process exited (code ${code})`);
|
|
4054
|
+
codexBootstrapped = false;
|
|
4055
|
+
replyTracker.reset();
|
|
4056
|
+
statusBuffer.flush("codex exited");
|
|
4057
|
+
tuiConnectionState.handleCodexExit();
|
|
4058
|
+
clearPendingClaudeDisconnect("Codex process exited");
|
|
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.`));
|
|
4060
|
+
broadcastStatus();
|
|
4061
|
+
armBootDeadline();
|
|
4062
|
+
});
|
|
4063
|
+
function startControlServer() {
|
|
4064
|
+
controlServer = Bun.serve({
|
|
4065
|
+
port: CONTROL_PORT,
|
|
4066
|
+
hostname: "127.0.0.1",
|
|
4067
|
+
fetch(req, server) {
|
|
4068
|
+
const url = new URL(req.url);
|
|
4069
|
+
if (url.pathname === "/healthz") {
|
|
4070
|
+
return Response.json(currentStatus());
|
|
4071
|
+
}
|
|
4072
|
+
if (url.pathname === "/readyz") {
|
|
4073
|
+
return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
|
|
4074
|
+
}
|
|
4075
|
+
if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false, lastPongAt: Date.now(), pongCount: 0, pendingBackpressure: [] } })) {
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
return new Response("AgentBridge daemon");
|
|
4079
|
+
},
|
|
4080
|
+
websocket: {
|
|
4081
|
+
idleTimeout: 960,
|
|
4082
|
+
sendPings: true,
|
|
4083
|
+
open: (ws) => {
|
|
4084
|
+
ws.data.clientId = ++nextControlClientId;
|
|
4085
|
+
ws.data.lastPongAt = Date.now();
|
|
4086
|
+
ws.data.pendingBackpressure = [];
|
|
4087
|
+
log(`Frontend socket opened (#${ws.data.clientId})`);
|
|
4088
|
+
},
|
|
4089
|
+
close: (ws, code, reason) => {
|
|
4090
|
+
log(`Frontend socket closed (#${ws.data.clientId}, code=${code}, reason=${reason || "none"}, wasAttached=${attachedClaude === ws})`);
|
|
4091
|
+
if (attachedClaude === ws) {
|
|
4092
|
+
detachClaude(ws, "frontend socket closed");
|
|
4093
|
+
}
|
|
4094
|
+
},
|
|
4095
|
+
message: (ws, raw) => {
|
|
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
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
4113
|
+
function handleControlMessage(ws, raw) {
|
|
4114
|
+
let message;
|
|
4115
|
+
try {
|
|
4116
|
+
const text = typeof raw === "string" ? raw : raw.toString();
|
|
4117
|
+
message = JSON.parse(text);
|
|
4118
|
+
} catch (e) {
|
|
4119
|
+
log(`Failed to parse control message: ${e.message}`);
|
|
4120
|
+
return;
|
|
4121
|
+
}
|
|
4122
|
+
switch (message.type) {
|
|
4123
|
+
case "claude_connect":
|
|
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
|
+
});
|
|
4138
|
+
return;
|
|
4139
|
+
case "claude_disconnect":
|
|
4140
|
+
detachClaude(ws, "frontend requested disconnect");
|
|
4141
|
+
return;
|
|
4142
|
+
case "status":
|
|
4143
|
+
sendStatus(ws);
|
|
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;
|
|
4150
|
+
case "claude_to_codex": {
|
|
4151
|
+
if (message.message.source !== "claude") {
|
|
4152
|
+
sendProtocolMessage(ws, {
|
|
4153
|
+
type: "claude_to_codex_result",
|
|
4154
|
+
requestId: message.requestId,
|
|
4155
|
+
success: false,
|
|
4156
|
+
error: "Invalid message source"
|
|
4157
|
+
});
|
|
4158
|
+
return;
|
|
4159
|
+
}
|
|
4160
|
+
if (!tuiConnectionState.canReply()) {
|
|
4161
|
+
sendProtocolMessage(ws, {
|
|
4162
|
+
type: "claude_to_codex_result",
|
|
4163
|
+
requestId: message.requestId,
|
|
4164
|
+
success: false,
|
|
4165
|
+
error: "Codex is not ready. Wait for TUI to connect and create a thread."
|
|
4166
|
+
});
|
|
4167
|
+
return;
|
|
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
|
+
}
|
|
4180
|
+
const requireReply = !!message.requireReply;
|
|
4181
|
+
let contentToSend = message.message.content;
|
|
4182
|
+
if (requireReply) {
|
|
4183
|
+
contentToSend += REPLY_REQUIRED_INSTRUCTION;
|
|
4184
|
+
}
|
|
4185
|
+
log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
|
|
4186
|
+
const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
|
|
4187
|
+
const injected = codex.injectMessage(contentToSend, tierOverrides);
|
|
4188
|
+
if (!injected) {
|
|
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.";
|
|
4190
|
+
log(`Injection rejected: ${reason}`);
|
|
4191
|
+
sendProtocolMessage(ws, {
|
|
4192
|
+
type: "claude_to_codex_result",
|
|
4193
|
+
requestId: message.requestId,
|
|
4194
|
+
success: false,
|
|
4195
|
+
error: reason
|
|
4196
|
+
});
|
|
4197
|
+
return;
|
|
4198
|
+
}
|
|
4199
|
+
if (tierOverrides) {
|
|
4200
|
+
budgetCoordinator?.notifyOverridesDelivered();
|
|
4201
|
+
}
|
|
4202
|
+
if (requireReply) {
|
|
4203
|
+
replyTracker.arm();
|
|
4204
|
+
log(`Reply required flag set for this message`);
|
|
4205
|
+
}
|
|
4206
|
+
clearAttentionWindow();
|
|
4207
|
+
sendProtocolMessage(ws, {
|
|
4208
|
+
type: "claude_to_codex_result",
|
|
4209
|
+
requestId: message.requestId,
|
|
4210
|
+
success: true
|
|
4211
|
+
});
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
}
|
|
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
|
+
}
|
|
4247
|
+
if (attachedClaude && attachedClaude !== ws && attachedClaude.readyState !== WebSocket.CLOSED) {
|
|
4248
|
+
log(`Rejecting Claude frontend #${ws.data.clientId} \u2014 slot re-acquired by #${attachedClaude.data.clientId} after probe`);
|
|
4249
|
+
ws.close(CLOSE_CODE_REPLACED, "another Claude session is already connected");
|
|
4250
|
+
return;
|
|
4251
|
+
}
|
|
4252
|
+
clearPendingClaudeDisconnect("Claude frontend attached");
|
|
4253
|
+
ws.data.identity = identity;
|
|
4254
|
+
attachedClaude = ws;
|
|
4255
|
+
ws.data.attached = true;
|
|
4256
|
+
cancelIdleShutdown();
|
|
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
|
+
}
|
|
4262
|
+
statusBuffer.flush("claude reconnected");
|
|
4263
|
+
sendStatus(ws);
|
|
4264
|
+
const now = Date.now();
|
|
4265
|
+
const isRapidReattach = now - lastAttachStatusSentTs < ATTACH_STATUS_COOLDOWN_MS;
|
|
4266
|
+
if (!hadBacklog && !isRapidReattach) {
|
|
4267
|
+
if (tuiConnectionState.canReply()) {
|
|
4268
|
+
sendBridgeMessage(ws, systemMessage("system_ready", currentReadyMessage()));
|
|
4269
|
+
} else if (codexBootstrapped) {
|
|
4270
|
+
sendBridgeMessage(ws, systemMessage("system_waiting", currentWaitingMessage()));
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
lastAttachStatusSentTs = now;
|
|
4274
|
+
}
|
|
4275
|
+
function detachClaude(ws, reason) {
|
|
4276
|
+
if (attachedClaude !== ws)
|
|
4277
|
+
return;
|
|
4278
|
+
attachedClaude = null;
|
|
4279
|
+
ws.data.attached = false;
|
|
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
|
+
}
|
|
4291
|
+
scheduleClaudeDisconnectNotification(ws.data.clientId);
|
|
4292
|
+
scheduleIdleShutdown();
|
|
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
|
+
}
|
|
4338
|
+
function startAttentionWindow() {
|
|
4339
|
+
clearAttentionWindow();
|
|
4340
|
+
inAttentionWindow = true;
|
|
4341
|
+
statusBuffer.pause();
|
|
4342
|
+
log(`Attention window started (${ATTENTION_WINDOW_MS}ms)`);
|
|
4343
|
+
tryWriteStatusFile("attentionWindowStarted");
|
|
4344
|
+
attentionWindowTimer = setTimeout(() => {
|
|
4345
|
+
attentionWindowTimer = null;
|
|
4346
|
+
inAttentionWindow = false;
|
|
4347
|
+
statusBuffer.resume();
|
|
4348
|
+
log("Attention window ended");
|
|
4349
|
+
tryWriteStatusFile("attentionWindowEnded");
|
|
4350
|
+
}, ATTENTION_WINDOW_MS);
|
|
4351
|
+
}
|
|
4352
|
+
function clearAttentionWindow() {
|
|
4353
|
+
if (attentionWindowTimer) {
|
|
4354
|
+
clearTimeout(attentionWindowTimer);
|
|
4355
|
+
attentionWindowTimer = null;
|
|
4356
|
+
}
|
|
4357
|
+
if (inAttentionWindow) {
|
|
4358
|
+
statusBuffer.resume();
|
|
4359
|
+
inAttentionWindow = false;
|
|
4360
|
+
tryWriteStatusFile("attentionWindowCleared");
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
function scheduleIdleShutdown() {
|
|
4364
|
+
cancelIdleShutdown();
|
|
4365
|
+
if (attachedClaude)
|
|
4366
|
+
return;
|
|
4367
|
+
const snapshot = tuiConnectionState.snapshot();
|
|
4368
|
+
if (snapshot.tuiConnected)
|
|
4369
|
+
return;
|
|
4370
|
+
log(`No clients connected. Daemon will shut down in ${IDLE_SHUTDOWN_MS}ms if no one reconnects.`);
|
|
4371
|
+
idleShutdownTimer = setTimeout(() => {
|
|
4372
|
+
if (attachedClaude || tuiConnectionState.snapshot().tuiConnected) {
|
|
4373
|
+
log("Idle shutdown cancelled: client reconnected during grace period");
|
|
4374
|
+
return;
|
|
4375
|
+
}
|
|
4376
|
+
shutdown("idle \u2014 no clients connected");
|
|
4377
|
+
}, IDLE_SHUTDOWN_MS);
|
|
4378
|
+
}
|
|
4379
|
+
function cancelIdleShutdown() {
|
|
4380
|
+
if (idleShutdownTimer) {
|
|
4381
|
+
clearTimeout(idleShutdownTimer);
|
|
4382
|
+
idleShutdownTimer = null;
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
function clearPendingClaudeDisconnect(reason) {
|
|
4386
|
+
if (!claudeDisconnectTimer)
|
|
4387
|
+
return;
|
|
4388
|
+
clearTimeout(claudeDisconnectTimer);
|
|
4389
|
+
claudeDisconnectTimer = null;
|
|
4390
|
+
if (reason) {
|
|
4391
|
+
log(`Cleared pending Claude disconnect notification (${reason})`);
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
function scheduleClaudeDisconnectNotification(clientId) {
|
|
4395
|
+
clearPendingClaudeDisconnect("rescheduled");
|
|
4396
|
+
claudeDisconnectTimer = setTimeout(() => {
|
|
4397
|
+
claudeDisconnectTimer = null;
|
|
4398
|
+
if (attachedClaude) {
|
|
4399
|
+
log(`Skipping Claude disconnect notification for client #${clientId} because Claude already reconnected`);
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
log(`Claude disconnect persisted past grace window (client #${clientId})`);
|
|
4403
|
+
}, CLAUDE_DISCONNECT_GRACE_MS);
|
|
4404
|
+
}
|
|
4405
|
+
function emitToClaude(message) {
|
|
4406
|
+
if (attachedClaude && attachedClaude.readyState === WebSocket.OPEN) {
|
|
4407
|
+
if (trySendBridgeMessage(attachedClaude, message))
|
|
4408
|
+
return;
|
|
4409
|
+
log("Send to Claude failed, buffering message for retry on reconnect");
|
|
4410
|
+
}
|
|
4411
|
+
bufferedMessages.push(message);
|
|
4412
|
+
if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
|
|
4413
|
+
const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
|
|
4414
|
+
bufferedMessages.splice(0, dropped);
|
|
4415
|
+
log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
function trySendBridgeMessage(ws, message) {
|
|
4419
|
+
try {
|
|
4420
|
+
const result = ws.send(JSON.stringify({ type: "codex_to_claude", message }));
|
|
4421
|
+
if (typeof result === "number" && result === 0) {
|
|
4422
|
+
log("Bridge message send returned 0 (dropped)");
|
|
4423
|
+
return false;
|
|
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
|
+
}
|
|
4433
|
+
return true;
|
|
4434
|
+
} catch (err) {
|
|
4435
|
+
log(`Failed to send bridge message: ${err.message}`);
|
|
4436
|
+
return false;
|
|
4437
|
+
}
|
|
4438
|
+
}
|
|
4439
|
+
function flushBufferedMessages(ws) {
|
|
4440
|
+
const messages = bufferedMessages.splice(0, bufferedMessages.length);
|
|
4441
|
+
for (let i = 0;i < messages.length; i++) {
|
|
4442
|
+
if (!trySendBridgeMessage(ws, messages[i])) {
|
|
4443
|
+
const remaining = messages.slice(i);
|
|
4444
|
+
bufferedMessages.unshift(...remaining);
|
|
4445
|
+
log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
|
|
4446
|
+
return;
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
function sendBridgeMessage(ws, message) {
|
|
4451
|
+
trySendBridgeMessage(ws, message);
|
|
4452
|
+
}
|
|
4453
|
+
function sendStatus(ws) {
|
|
4454
|
+
sendProtocolMessage(ws, { type: "status", status: currentStatus() });
|
|
4455
|
+
}
|
|
4456
|
+
function broadcastStatus() {
|
|
4457
|
+
if (!attachedClaude)
|
|
4458
|
+
return;
|
|
4459
|
+
sendStatus(attachedClaude);
|
|
4460
|
+
}
|
|
4461
|
+
function sendProtocolMessage(ws, message) {
|
|
4462
|
+
try {
|
|
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
|
+
}
|
|
4467
|
+
} catch (err) {
|
|
4468
|
+
log(`Failed to send control message: ${err.message}`);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
function currentStatus() {
|
|
4472
|
+
const snapshot = tuiConnectionState.snapshot();
|
|
4473
|
+
return {
|
|
4474
|
+
bridgeReady: tuiConnectionState.canReply(),
|
|
4475
|
+
tuiConnected: snapshot.tuiConnected,
|
|
4476
|
+
threadId: codex.activeThreadId,
|
|
4477
|
+
queuedMessageCount: bufferedMessages.length + statusBuffer.size + (attachedClaude?.data.pendingBackpressure.length ?? 0),
|
|
4478
|
+
proxyUrl: codex.proxyUrl,
|
|
4479
|
+
appServerUrl: codex.appServerUrl,
|
|
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
|
|
4489
|
+
};
|
|
4490
|
+
}
|
|
4491
|
+
function currentWaitingMessage() {
|
|
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
|
+
});
|
|
4503
|
+
}
|
|
4504
|
+
function currentReadyMessage() {
|
|
4505
|
+
return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
|
|
4506
|
+
}
|
|
4507
|
+
function systemMessage(idPrefix, content) {
|
|
4508
|
+
return {
|
|
4509
|
+
id: `${idPrefix}_${++nextSystemMessageId}`,
|
|
4510
|
+
source: "codex",
|
|
4511
|
+
content,
|
|
4512
|
+
timestamp: Date.now()
|
|
4513
|
+
};
|
|
4514
|
+
}
|
|
4515
|
+
function writePidFile() {
|
|
4516
|
+
daemonLifecycle.writePid();
|
|
4517
|
+
}
|
|
4518
|
+
function removePidFile() {
|
|
4519
|
+
daemonLifecycle.removePidFile();
|
|
4520
|
+
}
|
|
4521
|
+
function writeStatusFile() {
|
|
4522
|
+
daemonLifecycle.writeStatus({
|
|
4523
|
+
proxyUrl: codex.proxyUrl,
|
|
4524
|
+
appServerUrl: codex.appServerUrl,
|
|
4525
|
+
controlPort: CONTROL_PORT,
|
|
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
|
|
4534
|
+
});
|
|
4535
|
+
}
|
|
4536
|
+
function removeStatusFile() {
|
|
4537
|
+
daemonLifecycle.removeStatusFile();
|
|
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
|
+
}
|
|
4562
|
+
async function bootCodex() {
|
|
4563
|
+
log("Starting AgentBridge daemon...");
|
|
4564
|
+
log(`Codex app-server: ${codex.appServerUrl}`);
|
|
4565
|
+
log(`Codex proxy: ${codex.proxyUrl}`);
|
|
4566
|
+
log(`Control server: ws://127.0.0.1:${CONTROL_PORT}/ws`);
|
|
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
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
function shutdown(reason, exitCode = 0) {
|
|
4596
|
+
if (shuttingDown)
|
|
4597
|
+
return;
|
|
4598
|
+
shuttingDown = true;
|
|
4599
|
+
log(`Shutting down daemon (${reason})...`);
|
|
4600
|
+
clearBootDeadline();
|
|
4601
|
+
stopBudgetCoordinator();
|
|
4602
|
+
tuiConnectionState.dispose(`daemon shutdown (${reason})`);
|
|
4603
|
+
clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
|
|
4604
|
+
controlServer?.stop();
|
|
4605
|
+
controlServer = null;
|
|
4606
|
+
codex.stop();
|
|
4607
|
+
removePidFile();
|
|
4608
|
+
removeStatusFile();
|
|
4609
|
+
process.exit(exitCode);
|
|
4610
|
+
}
|
|
4611
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
4612
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
4613
|
+
process.on("exit", () => {
|
|
4614
|
+
codex.forceKillAppServerSync();
|
|
4615
|
+
removePidFile();
|
|
4616
|
+
removeStatusFile();
|
|
4617
|
+
});
|
|
4618
|
+
process.on("uncaughtException", (err) => {
|
|
4619
|
+
processLogger.fatal("UNCAUGHT EXCEPTION", err);
|
|
4620
|
+
});
|
|
4621
|
+
process.on("unhandledRejection", (reason) => {
|
|
4622
|
+
processLogger.fatal("UNHANDLED REJECTION", reason);
|
|
4623
|
+
});
|
|
4624
|
+
function log(msg) {
|
|
4625
|
+
processLogger.log(msg);
|
|
4626
|
+
}
|
|
4627
|
+
if (daemonLifecycle.wasKilled()) {
|
|
4628
|
+
log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
|
|
4629
|
+
process.exit(0);
|
|
4630
|
+
}
|
|
4631
|
+
writePidFile();
|
|
4632
|
+
startControlServer();
|
|
4633
|
+
armBootDeadline();
|
|
4634
|
+
bootCodex();
|