@raysonmeng/agentbridge 0.1.0
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 +24 -0
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/README.zh-CN.md +291 -0
- package/dist/cli.js +1158 -0
- package/package.json +54 -0
- package/plugins/agentbridge/.claude-plugin/plugin.json +12 -0
- package/plugins/agentbridge/.mcp.json +11 -0
- package/plugins/agentbridge/README.md +43 -0
- package/plugins/agentbridge/commands/init.md +70 -0
- package/plugins/agentbridge/hooks/hooks.json +16 -0
- package/plugins/agentbridge/scripts/health-check.sh +51 -0
- package/plugins/agentbridge/server/bridge-server.js +14734 -0
- package/plugins/agentbridge/server/daemon.js +1762 -0
- package/scripts/postinstall.cjs +27 -0
|
@@ -0,0 +1,1762 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/daemon.ts
|
|
5
|
+
import { appendFileSync as appendFileSync2 } from "fs";
|
|
6
|
+
|
|
7
|
+
// src/codex-adapter.ts
|
|
8
|
+
import { spawn, execSync } from "child_process";
|
|
9
|
+
import { createInterface } from "readline";
|
|
10
|
+
import { EventEmitter } from "events";
|
|
11
|
+
import { appendFileSync } from "fs";
|
|
12
|
+
var LOG_FILE = "/tmp/agentbridge.log";
|
|
13
|
+
var TRACKED_REQUEST_METHODS = new Set(["thread/start", "thread/resume", "turn/start"]);
|
|
14
|
+
|
|
15
|
+
class CodexAdapter extends EventEmitter {
|
|
16
|
+
static RESPONSE_TRACKING_TTL_MS = 30000;
|
|
17
|
+
proc = null;
|
|
18
|
+
appServerWs = null;
|
|
19
|
+
tuiWs = null;
|
|
20
|
+
proxyServer = null;
|
|
21
|
+
threadId = null;
|
|
22
|
+
nextInjectionId = -1;
|
|
23
|
+
appPort;
|
|
24
|
+
proxyPort;
|
|
25
|
+
tuiConnId = 0;
|
|
26
|
+
agentMessageBuffers = new Map;
|
|
27
|
+
pendingRequests = new Map;
|
|
28
|
+
activeTurnIds = new Set;
|
|
29
|
+
turnInProgress = false;
|
|
30
|
+
nextProxyId = 1e5;
|
|
31
|
+
upstreamToClient = new Map;
|
|
32
|
+
staleProxyIds = new Map;
|
|
33
|
+
bridgeRequestIds = new Map;
|
|
34
|
+
intentionalDisconnect = false;
|
|
35
|
+
constructor(appPort = 4500, proxyPort = 4501) {
|
|
36
|
+
super();
|
|
37
|
+
this.appPort = appPort;
|
|
38
|
+
this.proxyPort = proxyPort;
|
|
39
|
+
}
|
|
40
|
+
get appServerUrl() {
|
|
41
|
+
return `ws://127.0.0.1:${this.appPort}`;
|
|
42
|
+
}
|
|
43
|
+
get proxyUrl() {
|
|
44
|
+
return `ws://127.0.0.1:${this.proxyPort}`;
|
|
45
|
+
}
|
|
46
|
+
get activeThreadId() {
|
|
47
|
+
return this.threadId;
|
|
48
|
+
}
|
|
49
|
+
async start() {
|
|
50
|
+
this.intentionalDisconnect = false;
|
|
51
|
+
await this.checkPorts();
|
|
52
|
+
this.log(`Spawning codex app-server on ${this.appServerUrl}`);
|
|
53
|
+
this.proc = spawn("codex", ["app-server", "--listen", this.appServerUrl], {
|
|
54
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
55
|
+
});
|
|
56
|
+
this.proc.on("error", (err) => this.emit("error", err));
|
|
57
|
+
this.proc.on("exit", (code) => this.emit("exit", code));
|
|
58
|
+
const stderrRl = createInterface({ input: this.proc.stderr });
|
|
59
|
+
stderrRl.on("line", (l) => this.log(`[codex-server] ${l}`));
|
|
60
|
+
const stdoutRl = createInterface({ input: this.proc.stdout });
|
|
61
|
+
stdoutRl.on("line", (l) => this.log(`[codex-stdout] ${l}`));
|
|
62
|
+
await this.waitForHealthy();
|
|
63
|
+
await this.connectToAppServer();
|
|
64
|
+
this.startProxy();
|
|
65
|
+
this.log(`Proxy ready on ${this.proxyUrl}`);
|
|
66
|
+
}
|
|
67
|
+
disconnect() {
|
|
68
|
+
this.intentionalDisconnect = true;
|
|
69
|
+
if (this.reconnectTimer) {
|
|
70
|
+
clearTimeout(this.reconnectTimer);
|
|
71
|
+
this.reconnectTimer = null;
|
|
72
|
+
}
|
|
73
|
+
this.appServerWs?.close();
|
|
74
|
+
this.appServerWs = null;
|
|
75
|
+
this.proxyServer?.stop();
|
|
76
|
+
this.proxyServer = null;
|
|
77
|
+
this.clearResponseTrackingState();
|
|
78
|
+
}
|
|
79
|
+
stop() {
|
|
80
|
+
this.intentionalDisconnect = true;
|
|
81
|
+
this.disconnect();
|
|
82
|
+
if (this.proc) {
|
|
83
|
+
const proc = this.proc;
|
|
84
|
+
this.proc = null;
|
|
85
|
+
proc.kill("SIGTERM");
|
|
86
|
+
const killTimer = setTimeout(() => {
|
|
87
|
+
try {
|
|
88
|
+
proc.kill("SIGKILL");
|
|
89
|
+
} catch {}
|
|
90
|
+
}, 2000);
|
|
91
|
+
proc.on("exit", () => clearTimeout(killTimer));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
injectMessage(text) {
|
|
95
|
+
if (!this.threadId) {
|
|
96
|
+
this.log("Cannot inject: no active thread");
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
100
|
+
this.log("Cannot inject: app-server WebSocket not connected");
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (this.turnInProgress) {
|
|
104
|
+
this.log(`Rejected injection: Codex turn is in progress (thread ${this.threadId})`);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
this.log(`Injecting message into Codex (${text.length} chars)`);
|
|
108
|
+
const requestId = this.nextInjectionId--;
|
|
109
|
+
this.trackBridgeRequestId(requestId);
|
|
110
|
+
try {
|
|
111
|
+
this.appServerWs.send(JSON.stringify({
|
|
112
|
+
method: "turn/start",
|
|
113
|
+
id: requestId,
|
|
114
|
+
params: { threadId: this.threadId, input: [{ type: "text", text }] }
|
|
115
|
+
}));
|
|
116
|
+
return true;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this.untrackBridgeRequestId(requestId);
|
|
119
|
+
this.log(`Injection send failed: ${err.message}`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async waitForHealthy(maxRetries = 20, delayMs = 500) {
|
|
124
|
+
for (let i = 0;i < maxRetries; i++) {
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(`http://127.0.0.1:${this.appPort}/healthz`);
|
|
127
|
+
if (res.ok)
|
|
128
|
+
return;
|
|
129
|
+
} catch {}
|
|
130
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
131
|
+
}
|
|
132
|
+
throw new Error("Codex app-server failed to become healthy");
|
|
133
|
+
}
|
|
134
|
+
connectToAppServer(isReconnect = false) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const appWs = new WebSocket(this.appServerUrl);
|
|
137
|
+
appWs.onopen = () => {
|
|
138
|
+
this.appServerWs = appWs;
|
|
139
|
+
this.intentionalDisconnect = false;
|
|
140
|
+
this.reconnectAttempts = 0;
|
|
141
|
+
this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server (persistent)");
|
|
142
|
+
resolve();
|
|
143
|
+
};
|
|
144
|
+
appWs.onmessage = (event) => {
|
|
145
|
+
const data = typeof event.data === "string" ? event.data : event.data.toString();
|
|
146
|
+
const forwarded = this.handleAppServerPayload(data);
|
|
147
|
+
if (forwarded === null)
|
|
148
|
+
return;
|
|
149
|
+
if (this.tuiWs) {
|
|
150
|
+
try {
|
|
151
|
+
this.tuiWs.send(forwarded);
|
|
152
|
+
} catch (e) {
|
|
153
|
+
this.log(`Failed to forward message to TUI: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
this.log("WARNING: response from app-server but no TUI connected, message dropped");
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
appWs.onerror = () => {
|
|
160
|
+
this.log("App-server connection error");
|
|
161
|
+
if (!isReconnect)
|
|
162
|
+
reject(new Error("Failed to connect to app-server"));
|
|
163
|
+
};
|
|
164
|
+
appWs.onclose = () => {
|
|
165
|
+
this.log("App-server connection closed");
|
|
166
|
+
this.appServerWs = null;
|
|
167
|
+
this.clearResponseTrackingState();
|
|
168
|
+
this.activeTurnIds.clear();
|
|
169
|
+
this.turnInProgress = false;
|
|
170
|
+
if (!this.intentionalDisconnect) {
|
|
171
|
+
this.scheduleReconnect();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
reconnectAttempts = 0;
|
|
177
|
+
reconnectTimer = null;
|
|
178
|
+
static MAX_RECONNECT_ATTEMPTS = 10;
|
|
179
|
+
static RECONNECT_BASE_DELAY_MS = 1000;
|
|
180
|
+
scheduleReconnect() {
|
|
181
|
+
if (!this.proc)
|
|
182
|
+
return;
|
|
183
|
+
if (this.reconnectAttempts >= CodexAdapter.MAX_RECONNECT_ATTEMPTS) {
|
|
184
|
+
this.log(`App-server reconnect failed after ${this.reconnectAttempts} attempts. Giving up.`);
|
|
185
|
+
this.emit("error", new Error("App-server connection lost and reconnect failed"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const delay = Math.min(CodexAdapter.RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts), 30000);
|
|
189
|
+
this.reconnectAttempts++;
|
|
190
|
+
this.log(`Scheduling app-server reconnect attempt ${this.reconnectAttempts}/${CodexAdapter.MAX_RECONNECT_ATTEMPTS} in ${delay}ms...`);
|
|
191
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
192
|
+
try {
|
|
193
|
+
await this.connectToAppServer(true);
|
|
194
|
+
this.log("App-server reconnect successful");
|
|
195
|
+
} catch {
|
|
196
|
+
this.log("App-server reconnect attempt failed");
|
|
197
|
+
this.scheduleReconnect();
|
|
198
|
+
}
|
|
199
|
+
}, delay);
|
|
200
|
+
}
|
|
201
|
+
startProxy() {
|
|
202
|
+
const self = this;
|
|
203
|
+
this.proxyServer = Bun.serve({
|
|
204
|
+
port: this.proxyPort,
|
|
205
|
+
hostname: "127.0.0.1",
|
|
206
|
+
fetch(req, server) {
|
|
207
|
+
const url = new URL(req.url);
|
|
208
|
+
if (url.pathname === "/healthz" || url.pathname === "/readyz") {
|
|
209
|
+
return fetch(`http://127.0.0.1:${self.appPort}${url.pathname}`);
|
|
210
|
+
}
|
|
211
|
+
if (server.upgrade(req, { data: { connId: 0 } }))
|
|
212
|
+
return;
|
|
213
|
+
return new Response("AgentBridge Codex Proxy");
|
|
214
|
+
},
|
|
215
|
+
websocket: {
|
|
216
|
+
open: (ws) => self.onTuiConnect(ws),
|
|
217
|
+
close: (ws) => self.onTuiDisconnect(ws),
|
|
218
|
+
message: (ws, msg) => self.onTuiMessage(ws, msg)
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
onTuiConnect(ws) {
|
|
223
|
+
this.tuiConnId++;
|
|
224
|
+
ws.data.connId = this.tuiConnId;
|
|
225
|
+
this.tuiWs = ws;
|
|
226
|
+
this.log(`TUI connected (conn #${this.tuiConnId})`);
|
|
227
|
+
this.emit("tuiConnected", this.tuiConnId);
|
|
228
|
+
}
|
|
229
|
+
onTuiDisconnect(ws) {
|
|
230
|
+
const connId = ws.data.connId;
|
|
231
|
+
if (this.tuiWs === ws) {
|
|
232
|
+
this.log(`TUI disconnected (conn #${connId})`);
|
|
233
|
+
this.tuiWs = null;
|
|
234
|
+
this.emit("tuiDisconnected", connId);
|
|
235
|
+
} else {
|
|
236
|
+
this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
|
|
237
|
+
}
|
|
238
|
+
this.retireConnectionState(connId);
|
|
239
|
+
}
|
|
240
|
+
onTuiMessage(ws, msg) {
|
|
241
|
+
const data = typeof msg === "string" ? msg : msg.toString();
|
|
242
|
+
const connId = ws.data.connId;
|
|
243
|
+
if (connId !== this.tuiConnId) {
|
|
244
|
+
this.log(`Dropping message from stale TUI conn #${connId} (current is #${this.tuiConnId})`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
let forwarded = data;
|
|
248
|
+
try {
|
|
249
|
+
const parsed = JSON.parse(data);
|
|
250
|
+
const method = parsed.method ?? `response:${parsed.id}`;
|
|
251
|
+
this.log(`TUI \u2192 app-server: ${method}`);
|
|
252
|
+
if (parsed.id !== undefined && parsed.method) {
|
|
253
|
+
const proxyId = this.nextProxyId++;
|
|
254
|
+
this.upstreamToClient.set(proxyId, { connId, clientId: parsed.id });
|
|
255
|
+
this.trackPendingRequest(parsed, connId, proxyId);
|
|
256
|
+
parsed.id = proxyId;
|
|
257
|
+
forwarded = JSON.stringify(parsed);
|
|
258
|
+
} else {
|
|
259
|
+
this.trackPendingRequest(parsed, connId);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
this.log(`TUI \u2192 app-server: (unparseable)`);
|
|
263
|
+
}
|
|
264
|
+
if (this.appServerWs?.readyState === WebSocket.OPEN) {
|
|
265
|
+
this.appServerWs.send(forwarded);
|
|
266
|
+
} else {
|
|
267
|
+
this.log(`WARNING: app-server not connected, dropping message`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
handleAppServerPayload(raw) {
|
|
271
|
+
try {
|
|
272
|
+
const parsed = JSON.parse(raw);
|
|
273
|
+
if (parsed.id === undefined) {
|
|
274
|
+
const forwarded = this.patchResponse(parsed, raw);
|
|
275
|
+
this.interceptServerMessage(parsed);
|
|
276
|
+
return forwarded;
|
|
277
|
+
}
|
|
278
|
+
return this.handleAppServerResponse(parsed, raw);
|
|
279
|
+
} catch {
|
|
280
|
+
return raw;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
handleAppServerResponse(parsed, raw) {
|
|
284
|
+
const responseId = parsed.id;
|
|
285
|
+
const numericId = typeof responseId === "number" ? responseId : typeof responseId === "string" && /^-?\d+$/.test(responseId) ? Number(responseId) : NaN;
|
|
286
|
+
const mapping = !isNaN(numericId) ? this.upstreamToClient.get(numericId) : undefined;
|
|
287
|
+
if (mapping) {
|
|
288
|
+
this.upstreamToClient.delete(numericId);
|
|
289
|
+
if (mapping.connId !== this.tuiConnId) {
|
|
290
|
+
this.log(`Dropping stale response (upstream id ${responseId}, from conn #${mapping.connId}, current #${this.tuiConnId})`);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
parsed.id = mapping.clientId;
|
|
294
|
+
const forwarded = this.patchResponse(parsed, JSON.stringify(parsed));
|
|
295
|
+
this.interceptServerMessage(parsed, mapping.connId);
|
|
296
|
+
return forwarded;
|
|
297
|
+
}
|
|
298
|
+
if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
|
|
299
|
+
if (parsed.error) {
|
|
300
|
+
this.log(`Bridge-originated request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
|
|
301
|
+
} else {
|
|
302
|
+
this.log(`Bridge-originated request completed (id ${responseId})`);
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
if (!isNaN(numericId) && this.consumeStaleProxyId(numericId)) {
|
|
307
|
+
this.log(`Dropping stale response for retired upstream id ${responseId}`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
this.log(`Dropping unmatched app-server response id ${String(responseId)}`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
patchResponse(parsed, raw) {
|
|
314
|
+
if (parsed.error && parsed.id !== undefined) {
|
|
315
|
+
const errMsg = parsed.error.message ?? "";
|
|
316
|
+
if (errMsg.includes("rate limits") || errMsg.includes("rateLimits")) {
|
|
317
|
+
this.log(`Patching rateLimits error \u2192 mock success (id: ${parsed.id})`);
|
|
318
|
+
return JSON.stringify({
|
|
319
|
+
id: parsed.id,
|
|
320
|
+
result: {
|
|
321
|
+
rateLimits: {
|
|
322
|
+
limitId: null,
|
|
323
|
+
limitName: null,
|
|
324
|
+
primary: { usedPercent: 0, windowDurationMins: 60, resetsAt: null },
|
|
325
|
+
secondary: null,
|
|
326
|
+
credits: null,
|
|
327
|
+
planType: null
|
|
328
|
+
},
|
|
329
|
+
rateLimitsByLimitId: null
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (errMsg.includes("Already initialized")) {
|
|
334
|
+
this.log(`Patching "Already initialized" error (id: ${parsed.id})`);
|
|
335
|
+
return JSON.stringify({
|
|
336
|
+
id: parsed.id,
|
|
337
|
+
result: {
|
|
338
|
+
userAgent: "agent_bridge/0.1.0",
|
|
339
|
+
platformFamily: "unix",
|
|
340
|
+
platformOs: "macos"
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return raw;
|
|
346
|
+
}
|
|
347
|
+
interceptServerMessage(msg, connId) {
|
|
348
|
+
this.handleTrackedResponse(msg, connId);
|
|
349
|
+
if (msg.method)
|
|
350
|
+
this.handleServerNotification(msg);
|
|
351
|
+
}
|
|
352
|
+
handleServerNotification(msg) {
|
|
353
|
+
const { method, params } = msg;
|
|
354
|
+
switch (method) {
|
|
355
|
+
case "turn/started":
|
|
356
|
+
this.markTurnStarted(params?.turn?.id);
|
|
357
|
+
break;
|
|
358
|
+
case "item/started": {
|
|
359
|
+
const item = params?.item;
|
|
360
|
+
if (item?.type === "agentMessage")
|
|
361
|
+
this.agentMessageBuffers.set(item.id, []);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case "item/agentMessage/delta": {
|
|
365
|
+
const buf = this.agentMessageBuffers.get(params?.itemId);
|
|
366
|
+
if (buf && params?.delta)
|
|
367
|
+
buf.push(params.delta);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "item/completed": {
|
|
371
|
+
const item = params?.item;
|
|
372
|
+
if (item?.type === "agentMessage") {
|
|
373
|
+
const content = this.extractContent(item);
|
|
374
|
+
this.agentMessageBuffers.delete(item.id);
|
|
375
|
+
if (content) {
|
|
376
|
+
this.log(`Agent message completed (${content.length} chars)`);
|
|
377
|
+
this.emit("agentMessage", {
|
|
378
|
+
id: item.id,
|
|
379
|
+
source: "codex",
|
|
380
|
+
content,
|
|
381
|
+
timestamp: Date.now()
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
case "turn/completed": {
|
|
388
|
+
const wasInProgress = this.turnInProgress;
|
|
389
|
+
this.markTurnCompleted(params?.turn?.id);
|
|
390
|
+
if (wasInProgress && !this.turnInProgress) {
|
|
391
|
+
this.emit("turnCompleted");
|
|
392
|
+
}
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
extractContent(item) {
|
|
398
|
+
if (item.content?.length) {
|
|
399
|
+
return item.content.filter((c) => c.type === "text" && c.text).map((c) => c.text).join("");
|
|
400
|
+
}
|
|
401
|
+
return this.agentMessageBuffers.get(item.id)?.join("") ?? "";
|
|
402
|
+
}
|
|
403
|
+
pendingKey(rpcId, connId) {
|
|
404
|
+
const base = this.requestKey(rpcId);
|
|
405
|
+
if (!base)
|
|
406
|
+
return null;
|
|
407
|
+
return `${connId ?? this.tuiConnId}:${base}`;
|
|
408
|
+
}
|
|
409
|
+
trackPendingRequest(message, connId, _proxyId) {
|
|
410
|
+
const method = message?.method;
|
|
411
|
+
const key = this.pendingKey(message?.id, connId);
|
|
412
|
+
this.log(`[track] method=${method} id=${message?.id} (type=${typeof message?.id}) key=${key}`);
|
|
413
|
+
if (!key || !TRACKED_REQUEST_METHODS.has(method))
|
|
414
|
+
return;
|
|
415
|
+
const pending = { method };
|
|
416
|
+
if (method === "turn/start") {
|
|
417
|
+
const threadId = message?.params?.threadId;
|
|
418
|
+
if (typeof threadId === "string" && threadId.length > 0) {
|
|
419
|
+
pending.threadId = threadId;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (this.pendingRequests.has(key)) {
|
|
423
|
+
this.log(`WARNING: overwriting pending request for key ${key}`);
|
|
424
|
+
}
|
|
425
|
+
this.pendingRequests.set(key, pending);
|
|
426
|
+
}
|
|
427
|
+
handleTrackedResponse(message, connId) {
|
|
428
|
+
const key = this.pendingKey(message?.id, connId);
|
|
429
|
+
if (!key)
|
|
430
|
+
return;
|
|
431
|
+
const pending = this.pendingRequests.get(key);
|
|
432
|
+
if (!pending) {
|
|
433
|
+
if (message?.result?.thread?.id) {
|
|
434
|
+
this.log(`[track-resp] Unmatched response with thread.id=${message.result.thread.id}, key=${key}, pending keys=[${[...this.pendingRequests.keys()].join(",")}]`);
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.pendingRequests.delete(key);
|
|
439
|
+
if (message?.error) {
|
|
440
|
+
this.log(`Tracked request failed (${pending.method}, id ${key}): ${message.error.message ?? "unknown error"}`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
switch (pending.method) {
|
|
444
|
+
case "thread/start": {
|
|
445
|
+
const threadId = message?.result?.thread?.id;
|
|
446
|
+
if (typeof threadId === "string" && threadId.length > 0) {
|
|
447
|
+
this.setActiveThreadId(threadId, `thread/start response ${key}`);
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
case "thread/resume": {
|
|
452
|
+
const threadId = message?.result?.thread?.id;
|
|
453
|
+
if (typeof threadId === "string" && threadId.length > 0) {
|
|
454
|
+
this.setActiveThreadId(threadId, `thread/resume response ${key}`);
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
case "turn/start":
|
|
459
|
+
if (pending.threadId) {
|
|
460
|
+
this.setActiveThreadId(pending.threadId, `turn/start response ${key}`);
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
setActiveThreadId(threadId, reason) {
|
|
466
|
+
if (this.threadId === threadId)
|
|
467
|
+
return;
|
|
468
|
+
const previousThreadId = this.threadId;
|
|
469
|
+
this.threadId = threadId;
|
|
470
|
+
if (previousThreadId) {
|
|
471
|
+
this.log(`Active thread changed: ${previousThreadId} \u2192 ${threadId} (${reason})`);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.log(`Thread detected: ${threadId} (${reason})`);
|
|
475
|
+
this.emit("ready", threadId);
|
|
476
|
+
}
|
|
477
|
+
markTurnStarted(turnId) {
|
|
478
|
+
const wasInProgress = this.turnInProgress;
|
|
479
|
+
if (typeof turnId === "string" && turnId.length > 0) {
|
|
480
|
+
this.activeTurnIds.add(turnId);
|
|
481
|
+
} else {
|
|
482
|
+
this.activeTurnIds.add(`unknown:${Date.now()}`);
|
|
483
|
+
}
|
|
484
|
+
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
485
|
+
if (!wasInProgress && this.turnInProgress) {
|
|
486
|
+
this.emit("turnStarted");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
markTurnCompleted(turnId) {
|
|
490
|
+
if (typeof turnId === "string" && turnId.length > 0) {
|
|
491
|
+
this.activeTurnIds.delete(turnId);
|
|
492
|
+
} else {
|
|
493
|
+
this.activeTurnIds.clear();
|
|
494
|
+
}
|
|
495
|
+
this.turnInProgress = this.activeTurnIds.size > 0;
|
|
496
|
+
}
|
|
497
|
+
requestKey(id) {
|
|
498
|
+
if (typeof id === "number" || typeof id === "string")
|
|
499
|
+
return String(id);
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
retireConnectionState(connId) {
|
|
503
|
+
const prefix = `${connId}:`;
|
|
504
|
+
for (const key of this.pendingRequests.keys()) {
|
|
505
|
+
if (key.startsWith(prefix))
|
|
506
|
+
this.pendingRequests.delete(key);
|
|
507
|
+
}
|
|
508
|
+
for (const [upId, mapping] of this.upstreamToClient.entries()) {
|
|
509
|
+
if (mapping.connId !== connId)
|
|
510
|
+
continue;
|
|
511
|
+
this.upstreamToClient.delete(upId);
|
|
512
|
+
this.trackStaleProxyId(upId);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
trackStaleProxyId(proxyId) {
|
|
516
|
+
this.clearTrackedId(this.staleProxyIds, proxyId);
|
|
517
|
+
const timer = setTimeout(() => {
|
|
518
|
+
this.staleProxyIds.delete(proxyId);
|
|
519
|
+
}, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
|
|
520
|
+
timer.unref?.();
|
|
521
|
+
this.staleProxyIds.set(proxyId, timer);
|
|
522
|
+
}
|
|
523
|
+
consumeStaleProxyId(proxyId) {
|
|
524
|
+
return this.clearTrackedId(this.staleProxyIds, proxyId);
|
|
525
|
+
}
|
|
526
|
+
trackBridgeRequestId(requestId) {
|
|
527
|
+
this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
528
|
+
const timer = setTimeout(() => {
|
|
529
|
+
this.bridgeRequestIds.delete(requestId);
|
|
530
|
+
}, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
|
|
531
|
+
timer.unref?.();
|
|
532
|
+
this.bridgeRequestIds.set(requestId, timer);
|
|
533
|
+
}
|
|
534
|
+
consumeBridgeRequestId(requestId) {
|
|
535
|
+
return this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
536
|
+
}
|
|
537
|
+
untrackBridgeRequestId(requestId) {
|
|
538
|
+
this.clearTrackedId(this.bridgeRequestIds, requestId);
|
|
539
|
+
}
|
|
540
|
+
clearTrackedId(store, id) {
|
|
541
|
+
const timer = store.get(id);
|
|
542
|
+
if (!timer)
|
|
543
|
+
return false;
|
|
544
|
+
clearTimeout(timer);
|
|
545
|
+
store.delete(id);
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
clearResponseTrackingState() {
|
|
549
|
+
this.pendingRequests.clear();
|
|
550
|
+
this.upstreamToClient.clear();
|
|
551
|
+
for (const timer of this.staleProxyIds.values()) {
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
}
|
|
554
|
+
this.staleProxyIds.clear();
|
|
555
|
+
for (const timer of this.bridgeRequestIds.values()) {
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
}
|
|
558
|
+
this.bridgeRequestIds.clear();
|
|
559
|
+
}
|
|
560
|
+
async checkPorts() {
|
|
561
|
+
for (const port of [this.appPort, this.proxyPort]) {
|
|
562
|
+
try {
|
|
563
|
+
const pids = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
564
|
+
if (!pids)
|
|
565
|
+
continue;
|
|
566
|
+
const pidList = pids.split(`
|
|
567
|
+
`).map((p) => p.trim()).filter(Boolean);
|
|
568
|
+
const staleCodexPids = [];
|
|
569
|
+
const foreignPids = [];
|
|
570
|
+
for (const pid of pidList) {
|
|
571
|
+
try {
|
|
572
|
+
const cmdline = execSync(`ps -p ${pid} -o args=`, { encoding: "utf-8" }).trim();
|
|
573
|
+
if (cmdline.includes("codex") && cmdline.includes("app-server")) {
|
|
574
|
+
staleCodexPids.push(pid);
|
|
575
|
+
} else {
|
|
576
|
+
foreignPids.push(pid);
|
|
577
|
+
}
|
|
578
|
+
} catch {}
|
|
579
|
+
}
|
|
580
|
+
if (staleCodexPids.length > 0) {
|
|
581
|
+
this.log(`Cleaning up stale codex app-server on port ${port}: PID(s) ${staleCodexPids.join(", ")}`);
|
|
582
|
+
for (const pid of staleCodexPids) {
|
|
583
|
+
try {
|
|
584
|
+
execSync(`kill ${pid}`, { encoding: "utf-8" });
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
588
|
+
}
|
|
589
|
+
if (foreignPids.length > 0) {
|
|
590
|
+
throw new Error(`Port ${port} is already in use by non-Codex process(es): PID(s) ${foreignPids.join(", ")}. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const remaining = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
|
|
594
|
+
if (remaining) {
|
|
595
|
+
throw new Error(`Port ${port} is still occupied (PID(s): ${remaining.replace(/\n/g, ", ")}) after cleanup. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
|
|
596
|
+
}
|
|
597
|
+
} catch (err) {
|
|
598
|
+
if (err.message?.includes("Port"))
|
|
599
|
+
throw err;
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (err.message?.includes("Port") || err.message?.includes("non-Codex"))
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
log(msg) {
|
|
608
|
+
const line = `[${new Date().toISOString()}] [CodexAdapter] ${msg}
|
|
609
|
+
`;
|
|
610
|
+
process.stderr.write(line);
|
|
611
|
+
try {
|
|
612
|
+
appendFileSync(LOG_FILE, line);
|
|
613
|
+
} catch {}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/message-filter.ts
|
|
618
|
+
var MARKER_REGEX = /^\s*\[(IMPORTANT|STATUS|FYI)\]\s*/i;
|
|
619
|
+
function parseMarker(content) {
|
|
620
|
+
const match = content.match(MARKER_REGEX);
|
|
621
|
+
if (!match)
|
|
622
|
+
return { marker: "untagged", body: content };
|
|
623
|
+
return {
|
|
624
|
+
marker: match[1].toLowerCase(),
|
|
625
|
+
body: content.slice(match[0].length)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function classifyMessage(content, mode) {
|
|
629
|
+
if (mode === "full")
|
|
630
|
+
return { action: "forward", marker: "untagged" };
|
|
631
|
+
const { marker } = parseMarker(content);
|
|
632
|
+
switch (marker) {
|
|
633
|
+
case "important":
|
|
634
|
+
return { action: "forward", marker };
|
|
635
|
+
case "status":
|
|
636
|
+
return { action: "buffer", marker };
|
|
637
|
+
case "fyi":
|
|
638
|
+
return { action: "drop", marker };
|
|
639
|
+
case "untagged":
|
|
640
|
+
return { action: "forward", marker };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
var BRIDGE_CONTRACT_REMINDER = `[Bridge Contract] When sending agentMessage, put the marker at the very start of the message:
|
|
644
|
+
- [IMPORTANT] for decisions, reviews, completions, blockers
|
|
645
|
+
- [STATUS] for progress updates
|
|
646
|
+
- [FYI] for background context
|
|
647
|
+
The marker MUST be the first text in the message (e.g. "[IMPORTANT] Task done", not "Task done [IMPORTANT]").
|
|
648
|
+
Keep agentMessage for high-value communication only.
|
|
649
|
+
|
|
650
|
+
[Git Operations \u2014 FORBIDDEN]
|
|
651
|
+
You MUST NOT execute any git write commands. This includes but is not limited to:
|
|
652
|
+
git commit, git push, git pull, git fetch, git checkout -b, git branch, git merge, git rebase, git cherry-pick, git tag, git stash.
|
|
653
|
+
These commands write to the .git directory, which is blocked by your sandbox. Attempting them will cause your session to hang indefinitely.
|
|
654
|
+
Read-only git commands (git status, git log, git diff, git show, git rev-parse) are allowed.
|
|
655
|
+
All git write operations must be delegated to Claude Code via agentMessage. Report what you changed and let Claude handle branching, committing, and pushing.
|
|
656
|
+
|
|
657
|
+
[Role Guidance for Codex]
|
|
658
|
+
- Your default role: Implementer, Executor, Verifier
|
|
659
|
+
- Analytical/review tasks: Independent Analysis & Convergence
|
|
660
|
+
- Implementation tasks: Architect -> Builder -> Critic
|
|
661
|
+
- Debugging tasks: Hypothesis -> Experiment -> Interpretation
|
|
662
|
+
- Do not blindly follow Claude - challenge with evidence when you disagree
|
|
663
|
+
- Use explicit collaboration phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"`;
|
|
664
|
+
var REPLY_REQUIRED_INSTRUCTION = `
|
|
665
|
+
|
|
666
|
+
[\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.`;
|
|
667
|
+
class StatusBuffer {
|
|
668
|
+
onFlush;
|
|
669
|
+
buffer = [];
|
|
670
|
+
flushTimer = null;
|
|
671
|
+
flushThreshold;
|
|
672
|
+
flushTimeoutMs;
|
|
673
|
+
paused = false;
|
|
674
|
+
constructor(onFlush, options) {
|
|
675
|
+
this.onFlush = onFlush;
|
|
676
|
+
this.flushThreshold = options?.flushThreshold ?? 3;
|
|
677
|
+
this.flushTimeoutMs = options?.flushTimeoutMs ?? 15000;
|
|
678
|
+
}
|
|
679
|
+
get size() {
|
|
680
|
+
return this.buffer.length;
|
|
681
|
+
}
|
|
682
|
+
pause() {
|
|
683
|
+
this.paused = true;
|
|
684
|
+
this.clearTimer();
|
|
685
|
+
}
|
|
686
|
+
resume() {
|
|
687
|
+
this.paused = false;
|
|
688
|
+
if (this.buffer.length > 0) {
|
|
689
|
+
this.resetTimer();
|
|
690
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
691
|
+
this.flush("threshold reached after resume");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
add(message) {
|
|
696
|
+
this.buffer.push(message);
|
|
697
|
+
if (this.paused)
|
|
698
|
+
return;
|
|
699
|
+
this.resetTimer();
|
|
700
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
701
|
+
this.flush("threshold reached");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
flush(reason) {
|
|
705
|
+
if (this.buffer.length === 0)
|
|
706
|
+
return;
|
|
707
|
+
this.clearTimer();
|
|
708
|
+
const combined = this.buffer.map((m) => parseMarker(m.content).body).join(`
|
|
709
|
+
---
|
|
710
|
+
`);
|
|
711
|
+
const summary = {
|
|
712
|
+
id: `status_summary_${Date.now()}`,
|
|
713
|
+
source: "codex",
|
|
714
|
+
content: `[STATUS summary \u2014 ${this.buffer.length} update(s), flushed: ${reason}]
|
|
715
|
+
${combined}`,
|
|
716
|
+
timestamp: Date.now()
|
|
717
|
+
};
|
|
718
|
+
this.onFlush(summary);
|
|
719
|
+
this.buffer = [];
|
|
720
|
+
}
|
|
721
|
+
dispose() {
|
|
722
|
+
this.clearTimer();
|
|
723
|
+
this.buffer = [];
|
|
724
|
+
}
|
|
725
|
+
clearTimer() {
|
|
726
|
+
if (this.flushTimer) {
|
|
727
|
+
clearTimeout(this.flushTimer);
|
|
728
|
+
this.flushTimer = null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
resetTimer() {
|
|
732
|
+
this.clearTimer();
|
|
733
|
+
this.flushTimer = setTimeout(() => {
|
|
734
|
+
this.flushTimer = null;
|
|
735
|
+
this.flush("timeout");
|
|
736
|
+
}, this.flushTimeoutMs);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/tui-connection-state.ts
|
|
741
|
+
class TuiConnectionState {
|
|
742
|
+
options;
|
|
743
|
+
bridgeReady = false;
|
|
744
|
+
tuiConnected = false;
|
|
745
|
+
disconnectNotificationShown = false;
|
|
746
|
+
disconnectNotificationTimer = null;
|
|
747
|
+
constructor(options) {
|
|
748
|
+
this.options = options;
|
|
749
|
+
}
|
|
750
|
+
canReply() {
|
|
751
|
+
if (!this.bridgeReady)
|
|
752
|
+
return false;
|
|
753
|
+
return this.tuiConnected || this.disconnectNotificationTimer !== null;
|
|
754
|
+
}
|
|
755
|
+
snapshot() {
|
|
756
|
+
return {
|
|
757
|
+
bridgeReady: this.bridgeReady,
|
|
758
|
+
tuiConnected: this.tuiConnected,
|
|
759
|
+
disconnectNotificationShown: this.disconnectNotificationShown,
|
|
760
|
+
hasPendingDisconnectNotification: this.disconnectNotificationTimer !== null
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
markBridgeReady() {
|
|
764
|
+
this.bridgeReady = true;
|
|
765
|
+
this.disconnectNotificationShown = false;
|
|
766
|
+
this.clearPendingDisconnectNotification("thread became ready");
|
|
767
|
+
}
|
|
768
|
+
handleTuiConnected(connId) {
|
|
769
|
+
const reconnectingAfterNotice = this.disconnectNotificationShown && this.bridgeReady;
|
|
770
|
+
this.tuiConnected = true;
|
|
771
|
+
this.clearPendingDisconnectNotification(`TUI reconnected as conn #${connId}`);
|
|
772
|
+
if (reconnectingAfterNotice) {
|
|
773
|
+
this.disconnectNotificationShown = false;
|
|
774
|
+
this.options.onReconnectAfterNotice(connId);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
handleTuiDisconnected(connId) {
|
|
778
|
+
this.tuiConnected = false;
|
|
779
|
+
if (!this.bridgeReady) {
|
|
780
|
+
this.options.log?.(`Suppressing pre-ready TUI disconnect notification (conn #${connId})`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
this.scheduleDisconnectNotification(connId);
|
|
784
|
+
}
|
|
785
|
+
handleCodexExit() {
|
|
786
|
+
this.bridgeReady = false;
|
|
787
|
+
this.tuiConnected = false;
|
|
788
|
+
this.disconnectNotificationShown = false;
|
|
789
|
+
this.clearPendingDisconnectNotification("Codex process exited");
|
|
790
|
+
}
|
|
791
|
+
dispose(reason = "disposed") {
|
|
792
|
+
this.clearPendingDisconnectNotification(reason);
|
|
793
|
+
}
|
|
794
|
+
clearPendingDisconnectNotification(reason) {
|
|
795
|
+
if (!this.disconnectNotificationTimer)
|
|
796
|
+
return;
|
|
797
|
+
clearTimeout(this.disconnectNotificationTimer);
|
|
798
|
+
this.disconnectNotificationTimer = null;
|
|
799
|
+
if (reason) {
|
|
800
|
+
this.options.log?.(`Cleared pending TUI disconnect notification (${reason})`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
scheduleDisconnectNotification(connId) {
|
|
804
|
+
this.clearPendingDisconnectNotification("rescheduled");
|
|
805
|
+
this.disconnectNotificationTimer = setTimeout(() => {
|
|
806
|
+
this.disconnectNotificationTimer = null;
|
|
807
|
+
if (this.tuiConnected) {
|
|
808
|
+
this.options.log?.(`Skipping TUI disconnect notification for conn #${connId} because TUI already reconnected`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
this.disconnectNotificationShown = true;
|
|
812
|
+
this.options.log?.(`Codex TUI disconnect persisted past grace window (conn #${connId})`);
|
|
813
|
+
this.options.onDisconnectPersisted(connId);
|
|
814
|
+
}, this.options.disconnectGraceMs);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/daemon-lifecycle.ts
|
|
819
|
+
import { spawn as spawn2, execFileSync } from "child_process";
|
|
820
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
821
|
+
import { fileURLToPath } from "url";
|
|
822
|
+
var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY ?? "./daemon.ts";
|
|
823
|
+
var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
|
|
824
|
+
|
|
825
|
+
class DaemonLifecycle {
|
|
826
|
+
stateDir;
|
|
827
|
+
controlPort;
|
|
828
|
+
log;
|
|
829
|
+
constructor(opts) {
|
|
830
|
+
this.stateDir = opts.stateDir;
|
|
831
|
+
this.controlPort = opts.controlPort;
|
|
832
|
+
this.log = opts.log;
|
|
833
|
+
}
|
|
834
|
+
get healthUrl() {
|
|
835
|
+
return `http://127.0.0.1:${this.controlPort}/healthz`;
|
|
836
|
+
}
|
|
837
|
+
get readyUrl() {
|
|
838
|
+
return `http://127.0.0.1:${this.controlPort}/readyz`;
|
|
839
|
+
}
|
|
840
|
+
get controlWsUrl() {
|
|
841
|
+
return `ws://127.0.0.1:${this.controlPort}/ws`;
|
|
842
|
+
}
|
|
843
|
+
async ensureRunning() {
|
|
844
|
+
if (await this.isHealthy()) {
|
|
845
|
+
await this.waitForReady();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const existingPid = this.readPid();
|
|
849
|
+
if (existingPid) {
|
|
850
|
+
if (isProcessAlive(existingPid)) {
|
|
851
|
+
if (this.isDaemonProcess(existingPid)) {
|
|
852
|
+
try {
|
|
853
|
+
await this.waitForReady(12, 250);
|
|
854
|
+
return;
|
|
855
|
+
} catch {
|
|
856
|
+
throw new Error(`Found existing daemon process ${existingPid}, but control port ${this.controlPort} never became ready.`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
this.log(`Pid ${existingPid} is alive but not an AgentBridge daemon, removing stale pid file`);
|
|
860
|
+
}
|
|
861
|
+
this.removeStalePidFile();
|
|
862
|
+
}
|
|
863
|
+
const lockAcquired = this.acquireLock();
|
|
864
|
+
if (!lockAcquired) {
|
|
865
|
+
this.log("Another process is starting the daemon, waiting for readiness...");
|
|
866
|
+
await this.waitForReady();
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
this.launch();
|
|
871
|
+
await this.waitForReady();
|
|
872
|
+
} finally {
|
|
873
|
+
this.releaseLock();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async isHealthy() {
|
|
877
|
+
try {
|
|
878
|
+
const response = await fetch(this.healthUrl);
|
|
879
|
+
return response.ok;
|
|
880
|
+
} catch {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async waitForHealthy(maxRetries = 40, delayMs = 250) {
|
|
885
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
886
|
+
if (await this.isHealthy())
|
|
887
|
+
return;
|
|
888
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
889
|
+
}
|
|
890
|
+
throw new Error(`Timed out waiting for AgentBridge daemon health on ${this.healthUrl}`);
|
|
891
|
+
}
|
|
892
|
+
async isReady() {
|
|
893
|
+
try {
|
|
894
|
+
const response = await fetch(this.readyUrl);
|
|
895
|
+
return response.ok;
|
|
896
|
+
} catch {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async waitForReady(maxRetries = 40, delayMs = 250) {
|
|
901
|
+
for (let attempt = 0;attempt < maxRetries; attempt++) {
|
|
902
|
+
if (await this.isReady())
|
|
903
|
+
return;
|
|
904
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
905
|
+
}
|
|
906
|
+
throw new Error(`Timed out waiting for AgentBridge daemon readiness on ${this.readyUrl}`);
|
|
907
|
+
}
|
|
908
|
+
readStatus() {
|
|
909
|
+
try {
|
|
910
|
+
const raw = readFileSync(this.stateDir.statusFile, "utf-8");
|
|
911
|
+
return JSON.parse(raw);
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
writeStatus(status) {
|
|
917
|
+
this.stateDir.ensure();
|
|
918
|
+
writeFileSync(this.stateDir.statusFile, JSON.stringify(status, null, 2) + `
|
|
919
|
+
`, "utf-8");
|
|
920
|
+
}
|
|
921
|
+
readPid() {
|
|
922
|
+
try {
|
|
923
|
+
const raw = readFileSync(this.stateDir.pidFile, "utf-8").trim();
|
|
924
|
+
if (!raw)
|
|
925
|
+
return null;
|
|
926
|
+
const pid = Number.parseInt(raw, 10);
|
|
927
|
+
return Number.isFinite(pid) ? pid : null;
|
|
928
|
+
} catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
writePid(pid) {
|
|
933
|
+
this.stateDir.ensure();
|
|
934
|
+
writeFileSync(this.stateDir.pidFile, `${pid ?? process.pid}
|
|
935
|
+
`, "utf-8");
|
|
936
|
+
}
|
|
937
|
+
removePidFile() {
|
|
938
|
+
try {
|
|
939
|
+
unlinkSync(this.stateDir.pidFile);
|
|
940
|
+
} catch {}
|
|
941
|
+
}
|
|
942
|
+
removeStatusFile() {
|
|
943
|
+
try {
|
|
944
|
+
unlinkSync(this.stateDir.statusFile);
|
|
945
|
+
} catch {}
|
|
946
|
+
}
|
|
947
|
+
markKilled() {
|
|
948
|
+
this.stateDir.ensure();
|
|
949
|
+
writeFileSync(this.stateDir.killedFile, `${Date.now()}
|
|
950
|
+
`, "utf-8");
|
|
951
|
+
}
|
|
952
|
+
clearKilled() {
|
|
953
|
+
try {
|
|
954
|
+
unlinkSync(this.stateDir.killedFile);
|
|
955
|
+
} catch {}
|
|
956
|
+
}
|
|
957
|
+
wasKilled() {
|
|
958
|
+
return existsSync(this.stateDir.killedFile);
|
|
959
|
+
}
|
|
960
|
+
launch() {
|
|
961
|
+
this.stateDir.ensure();
|
|
962
|
+
this.log(`Launching detached daemon on control port ${this.controlPort}`);
|
|
963
|
+
const daemonProc = spawn2(process.execPath, ["run", DAEMON_PATH], {
|
|
964
|
+
cwd: process.cwd(),
|
|
965
|
+
env: {
|
|
966
|
+
...process.env,
|
|
967
|
+
AGENTBRIDGE_CONTROL_PORT: String(this.controlPort),
|
|
968
|
+
AGENTBRIDGE_STATE_DIR: this.stateDir.dir
|
|
969
|
+
},
|
|
970
|
+
detached: true,
|
|
971
|
+
stdio: "ignore"
|
|
972
|
+
});
|
|
973
|
+
daemonProc.unref();
|
|
974
|
+
}
|
|
975
|
+
removeStalePidFile() {
|
|
976
|
+
this.log("Removing stale pid file");
|
|
977
|
+
this.removePidFile();
|
|
978
|
+
}
|
|
979
|
+
acquireLock(depth = 0) {
|
|
980
|
+
if (depth > 1) {
|
|
981
|
+
this.log("Lock acquisition failed after retry, proceeding without lock");
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
this.stateDir.ensure();
|
|
985
|
+
try {
|
|
986
|
+
const fd = openSync(this.stateDir.lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
|
987
|
+
writeFileSync(fd, `${process.pid}
|
|
988
|
+
`);
|
|
989
|
+
closeSync(fd);
|
|
990
|
+
return true;
|
|
991
|
+
} catch (err) {
|
|
992
|
+
if (err.code === "EEXIST") {
|
|
993
|
+
try {
|
|
994
|
+
const holderPid = Number.parseInt(readFileSync(this.stateDir.lockFile, "utf-8").trim(), 10);
|
|
995
|
+
if (Number.isFinite(holderPid) && !isProcessAlive(holderPid)) {
|
|
996
|
+
this.log(`Stale lock file from dead process ${holderPid}, removing`);
|
|
997
|
+
this.releaseLock();
|
|
998
|
+
return this.acquireLock(depth + 1);
|
|
999
|
+
}
|
|
1000
|
+
} catch {
|
|
1001
|
+
this.log("Cannot read lock file, removing stale lock");
|
|
1002
|
+
this.releaseLock();
|
|
1003
|
+
return this.acquireLock(depth + 1);
|
|
1004
|
+
}
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
this.log(`Warning: could not acquire startup lock: ${err.message}`);
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
releaseLock() {
|
|
1012
|
+
try {
|
|
1013
|
+
unlinkSync(this.stateDir.lockFile);
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
async kill(gracefulTimeoutMs = 3000) {
|
|
1017
|
+
const pid = this.readPid();
|
|
1018
|
+
if (!pid) {
|
|
1019
|
+
this.log("No daemon pid file found");
|
|
1020
|
+
this.cleanup();
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
if (!isProcessAlive(pid)) {
|
|
1024
|
+
this.log(`Daemon pid ${pid} is not alive, cleaning up stale files`);
|
|
1025
|
+
this.cleanup();
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
if (!this.isDaemonProcess(pid)) {
|
|
1029
|
+
this.log(`Pid ${pid} is alive but is NOT an AgentBridge daemon \u2014 refusing to kill. Cleaning up stale pid file.`);
|
|
1030
|
+
this.cleanup();
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
this.log(`Sending SIGTERM to daemon pid ${pid}`);
|
|
1034
|
+
try {
|
|
1035
|
+
process.kill(pid, "SIGTERM");
|
|
1036
|
+
} catch {
|
|
1037
|
+
this.cleanup();
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
const deadline = Date.now() + gracefulTimeoutMs;
|
|
1041
|
+
while (Date.now() < deadline) {
|
|
1042
|
+
if (!isProcessAlive(pid)) {
|
|
1043
|
+
this.log(`Daemon pid ${pid} stopped gracefully`);
|
|
1044
|
+
this.cleanup();
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
1048
|
+
}
|
|
1049
|
+
this.log(`Daemon pid ${pid} did not stop gracefully, sending SIGKILL`);
|
|
1050
|
+
try {
|
|
1051
|
+
process.kill(pid, "SIGKILL");
|
|
1052
|
+
} catch {}
|
|
1053
|
+
this.cleanup();
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
isDaemonProcess(pid) {
|
|
1057
|
+
try {
|
|
1058
|
+
const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
|
|
1059
|
+
return cmd.includes("daemon") && (cmd.includes("agentbridge") || cmd.includes("agent_bridge"));
|
|
1060
|
+
} catch {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
cleanup() {
|
|
1065
|
+
this.removePidFile();
|
|
1066
|
+
this.removeStatusFile();
|
|
1067
|
+
this.releaseLock();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function isProcessAlive(pid) {
|
|
1071
|
+
try {
|
|
1072
|
+
process.kill(pid, 0);
|
|
1073
|
+
return true;
|
|
1074
|
+
} catch {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/state-dir.ts
|
|
1080
|
+
import { mkdirSync, existsSync as existsSync2 } from "fs";
|
|
1081
|
+
import { join } from "path";
|
|
1082
|
+
import { homedir, platform } from "os";
|
|
1083
|
+
|
|
1084
|
+
class StateDirResolver {
|
|
1085
|
+
stateDir;
|
|
1086
|
+
constructor(envOverride) {
|
|
1087
|
+
const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
|
|
1088
|
+
if (override) {
|
|
1089
|
+
this.stateDir = override;
|
|
1090
|
+
} else if (platform() === "darwin") {
|
|
1091
|
+
this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
1092
|
+
} else {
|
|
1093
|
+
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
1094
|
+
this.stateDir = join(xdgState, "agentbridge");
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
ensure() {
|
|
1098
|
+
if (!existsSync2(this.stateDir)) {
|
|
1099
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
get dir() {
|
|
1103
|
+
return this.stateDir;
|
|
1104
|
+
}
|
|
1105
|
+
get pidFile() {
|
|
1106
|
+
return join(this.stateDir, "daemon.pid");
|
|
1107
|
+
}
|
|
1108
|
+
get tuiPidFile() {
|
|
1109
|
+
return join(this.stateDir, "codex-tui.pid");
|
|
1110
|
+
}
|
|
1111
|
+
get lockFile() {
|
|
1112
|
+
return join(this.stateDir, "daemon.lock");
|
|
1113
|
+
}
|
|
1114
|
+
get statusFile() {
|
|
1115
|
+
return join(this.stateDir, "status.json");
|
|
1116
|
+
}
|
|
1117
|
+
get portsFile() {
|
|
1118
|
+
return join(this.stateDir, "ports.json");
|
|
1119
|
+
}
|
|
1120
|
+
get logFile() {
|
|
1121
|
+
return join(this.stateDir, "agentbridge.log");
|
|
1122
|
+
}
|
|
1123
|
+
get killedFile() {
|
|
1124
|
+
return join(this.stateDir, "killed");
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/config-service.ts
|
|
1129
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
|
|
1130
|
+
import { join as join2 } from "path";
|
|
1131
|
+
var DEFAULT_CONFIG = {
|
|
1132
|
+
version: "1.0",
|
|
1133
|
+
daemon: {
|
|
1134
|
+
port: 4500,
|
|
1135
|
+
proxyPort: 4501
|
|
1136
|
+
},
|
|
1137
|
+
agents: {
|
|
1138
|
+
claude: {
|
|
1139
|
+
role: "Reviewer, Planner",
|
|
1140
|
+
mode: "push"
|
|
1141
|
+
},
|
|
1142
|
+
codex: {
|
|
1143
|
+
role: "Implementer, Executor"
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
markers: ["IMPORTANT", "STATUS", "FYI"],
|
|
1147
|
+
turnCoordination: {
|
|
1148
|
+
attentionWindowSeconds: 15,
|
|
1149
|
+
busyGuard: true
|
|
1150
|
+
},
|
|
1151
|
+
idleShutdownSeconds: 30
|
|
1152
|
+
};
|
|
1153
|
+
var DEFAULT_COLLABORATION_MD = `# Collaboration Rules
|
|
1154
|
+
|
|
1155
|
+
## Roles
|
|
1156
|
+
- Claude: Reviewer, Planner, Hypothesis Challenger
|
|
1157
|
+
- Codex: Implementer, Executor, Reproducer/Verifier
|
|
1158
|
+
|
|
1159
|
+
## Thinking Patterns
|
|
1160
|
+
- Analytical/review tasks: Independent Analysis & Convergence
|
|
1161
|
+
- Implementation tasks: Architect -> Builder -> Critic
|
|
1162
|
+
- Debugging tasks: Hypothesis -> Experiment -> Interpretation
|
|
1163
|
+
|
|
1164
|
+
## Communication
|
|
1165
|
+
- Use explicit phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"
|
|
1166
|
+
- Tag messages with [IMPORTANT], [STATUS], or [FYI]
|
|
1167
|
+
|
|
1168
|
+
## Review Process
|
|
1169
|
+
- Cross-review: author never reviews their own code
|
|
1170
|
+
- All changes go through feature/fix branches + PR
|
|
1171
|
+
- Merge via squash merge
|
|
1172
|
+
|
|
1173
|
+
## Custom Rules
|
|
1174
|
+
<!-- Add your project-specific collaboration rules here -->
|
|
1175
|
+
`;
|
|
1176
|
+
var CONFIG_DIR = ".agentbridge";
|
|
1177
|
+
var CONFIG_FILE = "config.json";
|
|
1178
|
+
var COLLABORATION_FILE = "collaboration.md";
|
|
1179
|
+
|
|
1180
|
+
class ConfigService {
|
|
1181
|
+
configDir;
|
|
1182
|
+
configPath;
|
|
1183
|
+
collaborationPath;
|
|
1184
|
+
constructor(projectRoot) {
|
|
1185
|
+
const root = projectRoot ?? process.cwd();
|
|
1186
|
+
this.configDir = join2(root, CONFIG_DIR);
|
|
1187
|
+
this.configPath = join2(this.configDir, CONFIG_FILE);
|
|
1188
|
+
this.collaborationPath = join2(this.configDir, COLLABORATION_FILE);
|
|
1189
|
+
}
|
|
1190
|
+
hasConfig() {
|
|
1191
|
+
return existsSync3(this.configPath);
|
|
1192
|
+
}
|
|
1193
|
+
load() {
|
|
1194
|
+
try {
|
|
1195
|
+
const raw = readFileSync2(this.configPath, "utf-8");
|
|
1196
|
+
return JSON.parse(raw);
|
|
1197
|
+
} catch {
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
loadOrDefault() {
|
|
1202
|
+
return this.load() ?? structuredClone(DEFAULT_CONFIG);
|
|
1203
|
+
}
|
|
1204
|
+
save(config) {
|
|
1205
|
+
this.ensureConfigDir();
|
|
1206
|
+
writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
|
|
1207
|
+
`, "utf-8");
|
|
1208
|
+
}
|
|
1209
|
+
loadCollaboration() {
|
|
1210
|
+
try {
|
|
1211
|
+
return readFileSync2(this.collaborationPath, "utf-8");
|
|
1212
|
+
} catch {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
saveCollaboration(content) {
|
|
1217
|
+
this.ensureConfigDir();
|
|
1218
|
+
writeFileSync2(this.collaborationPath, content, "utf-8");
|
|
1219
|
+
}
|
|
1220
|
+
initDefaults() {
|
|
1221
|
+
this.ensureConfigDir();
|
|
1222
|
+
const created = [];
|
|
1223
|
+
if (!existsSync3(this.configPath)) {
|
|
1224
|
+
this.save(DEFAULT_CONFIG);
|
|
1225
|
+
created.push(this.configPath);
|
|
1226
|
+
}
|
|
1227
|
+
if (!existsSync3(this.collaborationPath)) {
|
|
1228
|
+
this.saveCollaboration(DEFAULT_COLLABORATION_MD);
|
|
1229
|
+
created.push(this.collaborationPath);
|
|
1230
|
+
}
|
|
1231
|
+
return created;
|
|
1232
|
+
}
|
|
1233
|
+
get configFilePath() {
|
|
1234
|
+
return this.configPath;
|
|
1235
|
+
}
|
|
1236
|
+
get collaborationFilePath() {
|
|
1237
|
+
return this.collaborationPath;
|
|
1238
|
+
}
|
|
1239
|
+
ensureConfigDir() {
|
|
1240
|
+
if (!existsSync3(this.configDir)) {
|
|
1241
|
+
mkdirSync2(this.configDir, { recursive: true });
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/daemon.ts
|
|
1247
|
+
var stateDir = new StateDirResolver;
|
|
1248
|
+
stateDir.ensure();
|
|
1249
|
+
var configService = new ConfigService;
|
|
1250
|
+
var config = configService.loadOrDefault();
|
|
1251
|
+
var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.daemon.port), 10);
|
|
1252
|
+
var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.daemon.proxyPort), 10);
|
|
1253
|
+
var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
|
|
1254
|
+
var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
|
|
1255
|
+
var CLAUDE_DISCONNECT_GRACE_MS = 5000;
|
|
1256
|
+
var MAX_BUFFERED_MESSAGES = parseInt(process.env.AGENTBRIDGE_MAX_BUFFERED_MESSAGES ?? "100", 10);
|
|
1257
|
+
var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "filtered";
|
|
1258
|
+
var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
|
|
1259
|
+
var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
|
|
1260
|
+
var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
|
|
1261
|
+
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT);
|
|
1262
|
+
var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
|
|
1263
|
+
var controlServer = null;
|
|
1264
|
+
var attachedClaude = null;
|
|
1265
|
+
var nextControlClientId = 0;
|
|
1266
|
+
var nextSystemMessageId = 0;
|
|
1267
|
+
var codexBootstrapped = false;
|
|
1268
|
+
var attentionWindowTimer = null;
|
|
1269
|
+
var inAttentionWindow = false;
|
|
1270
|
+
var replyRequired = false;
|
|
1271
|
+
var replyReceivedDuringTurn = false;
|
|
1272
|
+
var shuttingDown = false;
|
|
1273
|
+
var idleShutdownTimer = null;
|
|
1274
|
+
var claudeDisconnectTimer = null;
|
|
1275
|
+
var claudeOnlineNoticeSent = false;
|
|
1276
|
+
var claudeOfflineNoticeShown = false;
|
|
1277
|
+
var lastAttachStatusSentTs = 0;
|
|
1278
|
+
var ATTACH_STATUS_COOLDOWN_MS = 30000;
|
|
1279
|
+
var bufferedMessages = [];
|
|
1280
|
+
var tuiConnectionState = new TuiConnectionState({
|
|
1281
|
+
disconnectGraceMs: TUI_DISCONNECT_GRACE_MS,
|
|
1282
|
+
log,
|
|
1283
|
+
onDisconnectPersisted: (connId) => {
|
|
1284
|
+
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.`));
|
|
1285
|
+
},
|
|
1286
|
+
onReconnectAfterNotice: (connId) => {
|
|
1287
|
+
emitToClaude(systemMessage("system_tui_reconnected", `\u2705 Codex TUI reconnected (conn #${connId}). Bridge restored, communication can continue.`));
|
|
1288
|
+
codex.injectMessage("\u2705 Claude Code is still online, bridge restored. Bidirectional communication can continue.");
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
var statusBuffer = new StatusBuffer((summary) => emitToClaude(summary));
|
|
1292
|
+
codex.on("turnStarted", () => {
|
|
1293
|
+
log("Codex turn started");
|
|
1294
|
+
emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
|
|
1295
|
+
});
|
|
1296
|
+
codex.on("agentMessage", (msg) => {
|
|
1297
|
+
if (msg.source !== "codex")
|
|
1298
|
+
return;
|
|
1299
|
+
const result = classifyMessage(msg.content, FILTER_MODE);
|
|
1300
|
+
if (replyRequired) {
|
|
1301
|
+
log(`Codex \u2192 Claude [${result.marker}/force-forward-reply-required] (${msg.content.length} chars)`);
|
|
1302
|
+
replyReceivedDuringTurn = true;
|
|
1303
|
+
if (statusBuffer.size > 0) {
|
|
1304
|
+
statusBuffer.flush("reply-required message arrived");
|
|
1305
|
+
}
|
|
1306
|
+
emitToClaude(msg);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (inAttentionWindow && result.marker === "status") {
|
|
1310
|
+
log(`Codex \u2192 Claude [${result.marker}/buffer-attention] (${msg.content.length} chars)`);
|
|
1311
|
+
statusBuffer.add(msg);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
log(`Codex \u2192 Claude [${result.marker}/${result.action}] (${msg.content.length} chars)`);
|
|
1315
|
+
switch (result.action) {
|
|
1316
|
+
case "forward":
|
|
1317
|
+
if (result.marker === "important" && statusBuffer.size > 0) {
|
|
1318
|
+
statusBuffer.flush("important message arrived");
|
|
1319
|
+
}
|
|
1320
|
+
emitToClaude(msg);
|
|
1321
|
+
if (result.marker === "important") {
|
|
1322
|
+
startAttentionWindow();
|
|
1323
|
+
}
|
|
1324
|
+
break;
|
|
1325
|
+
case "buffer":
|
|
1326
|
+
statusBuffer.add(msg);
|
|
1327
|
+
break;
|
|
1328
|
+
case "drop":
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
codex.on("turnCompleted", () => {
|
|
1333
|
+
log("Codex turn completed");
|
|
1334
|
+
statusBuffer.flush("turn completed");
|
|
1335
|
+
if (replyRequired && !replyReceivedDuringTurn) {
|
|
1336
|
+
log("\u26A0\uFE0F Reply was required but Codex did not send any agentMessage");
|
|
1337
|
+
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."));
|
|
1338
|
+
}
|
|
1339
|
+
replyRequired = false;
|
|
1340
|
+
replyReceivedDuringTurn = false;
|
|
1341
|
+
emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
|
|
1342
|
+
startAttentionWindow();
|
|
1343
|
+
});
|
|
1344
|
+
codex.on("ready", (threadId) => {
|
|
1345
|
+
tuiConnectionState.markBridgeReady();
|
|
1346
|
+
log(`Codex ready \u2014 thread ${threadId}`);
|
|
1347
|
+
log("Bridge fully operational");
|
|
1348
|
+
emitToClaude(systemMessage("system_ready", currentReadyMessage()));
|
|
1349
|
+
if (attachedClaude && shouldNotifyCodexClaudeOnline()) {
|
|
1350
|
+
notifyCodexClaudeOnline();
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
codex.on("tuiConnected", (connId) => {
|
|
1354
|
+
tuiConnectionState.handleTuiConnected(connId);
|
|
1355
|
+
cancelIdleShutdown();
|
|
1356
|
+
log(`Codex TUI connected (conn #${connId})`);
|
|
1357
|
+
broadcastStatus();
|
|
1358
|
+
});
|
|
1359
|
+
codex.on("tuiDisconnected", (connId) => {
|
|
1360
|
+
tuiConnectionState.handleTuiDisconnected(connId);
|
|
1361
|
+
log(`Codex TUI disconnected (conn #${connId})`);
|
|
1362
|
+
broadcastStatus();
|
|
1363
|
+
scheduleIdleShutdown();
|
|
1364
|
+
});
|
|
1365
|
+
codex.on("error", (err) => {
|
|
1366
|
+
log(`Codex error: ${err.message}`);
|
|
1367
|
+
});
|
|
1368
|
+
codex.on("exit", (code) => {
|
|
1369
|
+
log(`Codex process exited (code ${code})`);
|
|
1370
|
+
codexBootstrapped = false;
|
|
1371
|
+
statusBuffer.flush("codex exited");
|
|
1372
|
+
tuiConnectionState.handleCodexExit();
|
|
1373
|
+
clearPendingClaudeDisconnect("Codex process exited");
|
|
1374
|
+
claudeOnlineNoticeSent = false;
|
|
1375
|
+
claudeOfflineNoticeShown = false;
|
|
1376
|
+
emitToClaude(systemMessage("system_codex_exit", `\u26A0\uFE0F Codex app-server exited (code ${code ?? "unknown"}). AgentBridge daemon is still running, but the Codex side needs to be restarted.`));
|
|
1377
|
+
broadcastStatus();
|
|
1378
|
+
});
|
|
1379
|
+
function startControlServer() {
|
|
1380
|
+
controlServer = Bun.serve({
|
|
1381
|
+
port: CONTROL_PORT,
|
|
1382
|
+
hostname: "127.0.0.1",
|
|
1383
|
+
fetch(req, server) {
|
|
1384
|
+
const url = new URL(req.url);
|
|
1385
|
+
if (url.pathname === "/healthz") {
|
|
1386
|
+
return Response.json(currentStatus());
|
|
1387
|
+
}
|
|
1388
|
+
if (url.pathname === "/readyz") {
|
|
1389
|
+
return Response.json(currentStatus(), { status: codexBootstrapped ? 200 : 503 });
|
|
1390
|
+
}
|
|
1391
|
+
if (url.pathname === "/ws" && server.upgrade(req, { data: { clientId: 0, attached: false } })) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
return new Response("AgentBridge daemon");
|
|
1395
|
+
},
|
|
1396
|
+
websocket: {
|
|
1397
|
+
idleTimeout: 960,
|
|
1398
|
+
sendPings: true,
|
|
1399
|
+
open: (ws) => {
|
|
1400
|
+
ws.data.clientId = ++nextControlClientId;
|
|
1401
|
+
log(`Frontend socket opened (#${ws.data.clientId})`);
|
|
1402
|
+
},
|
|
1403
|
+
close: (ws, code, reason) => {
|
|
1404
|
+
log(`Frontend socket closed (#${ws.data.clientId}, code=${code}, reason=${reason || "none"}, wasAttached=${attachedClaude === ws})`);
|
|
1405
|
+
if (attachedClaude === ws) {
|
|
1406
|
+
detachClaude(ws, "frontend socket closed");
|
|
1407
|
+
}
|
|
1408
|
+
},
|
|
1409
|
+
message: (ws, raw) => {
|
|
1410
|
+
handleControlMessage(ws, raw);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
function handleControlMessage(ws, raw) {
|
|
1416
|
+
let message;
|
|
1417
|
+
try {
|
|
1418
|
+
const text = typeof raw === "string" ? raw : raw.toString();
|
|
1419
|
+
message = JSON.parse(text);
|
|
1420
|
+
} catch (e) {
|
|
1421
|
+
log(`Failed to parse control message: ${e.message}`);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
switch (message.type) {
|
|
1425
|
+
case "claude_connect":
|
|
1426
|
+
attachClaude(ws);
|
|
1427
|
+
return;
|
|
1428
|
+
case "claude_disconnect":
|
|
1429
|
+
detachClaude(ws, "frontend requested disconnect");
|
|
1430
|
+
return;
|
|
1431
|
+
case "status":
|
|
1432
|
+
sendStatus(ws);
|
|
1433
|
+
return;
|
|
1434
|
+
case "claude_to_codex": {
|
|
1435
|
+
if (message.message.source !== "claude") {
|
|
1436
|
+
sendProtocolMessage(ws, {
|
|
1437
|
+
type: "claude_to_codex_result",
|
|
1438
|
+
requestId: message.requestId,
|
|
1439
|
+
success: false,
|
|
1440
|
+
error: "Invalid message source"
|
|
1441
|
+
});
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (!tuiConnectionState.canReply()) {
|
|
1445
|
+
sendProtocolMessage(ws, {
|
|
1446
|
+
type: "claude_to_codex_result",
|
|
1447
|
+
requestId: message.requestId,
|
|
1448
|
+
success: false,
|
|
1449
|
+
error: "Codex is not ready. Wait for TUI to connect and create a thread."
|
|
1450
|
+
});
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
const requireReply = !!message.requireReply;
|
|
1454
|
+
let contentWithReminder = message.message.content + `
|
|
1455
|
+
|
|
1456
|
+
` + BRIDGE_CONTRACT_REMINDER;
|
|
1457
|
+
if (requireReply) {
|
|
1458
|
+
contentWithReminder += REPLY_REQUIRED_INSTRUCTION;
|
|
1459
|
+
replyRequired = true;
|
|
1460
|
+
replyReceivedDuringTurn = false;
|
|
1461
|
+
log(`Reply required flag set for this message`);
|
|
1462
|
+
}
|
|
1463
|
+
log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
|
|
1464
|
+
const injected = codex.injectMessage(contentWithReminder);
|
|
1465
|
+
if (!injected) {
|
|
1466
|
+
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.";
|
|
1467
|
+
log(`Injection rejected: ${reason}`);
|
|
1468
|
+
sendProtocolMessage(ws, {
|
|
1469
|
+
type: "claude_to_codex_result",
|
|
1470
|
+
requestId: message.requestId,
|
|
1471
|
+
success: false,
|
|
1472
|
+
error: reason
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
clearAttentionWindow();
|
|
1477
|
+
sendProtocolMessage(ws, {
|
|
1478
|
+
type: "claude_to_codex_result",
|
|
1479
|
+
requestId: message.requestId,
|
|
1480
|
+
success: true
|
|
1481
|
+
});
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
function attachClaude(ws) {
|
|
1487
|
+
if (attachedClaude && attachedClaude !== ws) {
|
|
1488
|
+
attachedClaude.close(4001, "replaced by a newer Claude session");
|
|
1489
|
+
}
|
|
1490
|
+
clearPendingClaudeDisconnect("Claude frontend attached");
|
|
1491
|
+
attachedClaude = ws;
|
|
1492
|
+
ws.data.attached = true;
|
|
1493
|
+
cancelIdleShutdown();
|
|
1494
|
+
log(`Claude frontend attached (#${ws.data.clientId})`);
|
|
1495
|
+
statusBuffer.flush("claude reconnected");
|
|
1496
|
+
sendStatus(ws);
|
|
1497
|
+
const now = Date.now();
|
|
1498
|
+
const isRapidReattach = now - lastAttachStatusSentTs < ATTACH_STATUS_COOLDOWN_MS;
|
|
1499
|
+
if (bufferedMessages.length > 0) {
|
|
1500
|
+
flushBufferedMessages(ws);
|
|
1501
|
+
} else if (!isRapidReattach) {
|
|
1502
|
+
if (tuiConnectionState.canReply()) {
|
|
1503
|
+
sendBridgeMessage(ws, systemMessage("system_ready", currentReadyMessage()));
|
|
1504
|
+
} else if (codexBootstrapped) {
|
|
1505
|
+
sendBridgeMessage(ws, systemMessage("system_waiting", currentWaitingMessage()));
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
lastAttachStatusSentTs = now;
|
|
1509
|
+
if (tuiConnectionState.canReply() && shouldNotifyCodexClaudeOnline()) {
|
|
1510
|
+
notifyCodexClaudeOnline();
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
function detachClaude(ws, reason) {
|
|
1514
|
+
if (attachedClaude !== ws)
|
|
1515
|
+
return;
|
|
1516
|
+
attachedClaude = null;
|
|
1517
|
+
ws.data.attached = false;
|
|
1518
|
+
log(`Claude frontend detached (#${ws.data.clientId}, ${reason})`);
|
|
1519
|
+
scheduleClaudeDisconnectNotification(ws.data.clientId);
|
|
1520
|
+
scheduleIdleShutdown();
|
|
1521
|
+
}
|
|
1522
|
+
function startAttentionWindow() {
|
|
1523
|
+
clearAttentionWindow();
|
|
1524
|
+
inAttentionWindow = true;
|
|
1525
|
+
statusBuffer.pause();
|
|
1526
|
+
log(`Attention window started (${ATTENTION_WINDOW_MS}ms)`);
|
|
1527
|
+
attentionWindowTimer = setTimeout(() => {
|
|
1528
|
+
attentionWindowTimer = null;
|
|
1529
|
+
inAttentionWindow = false;
|
|
1530
|
+
statusBuffer.resume();
|
|
1531
|
+
log("Attention window ended");
|
|
1532
|
+
}, ATTENTION_WINDOW_MS);
|
|
1533
|
+
}
|
|
1534
|
+
function clearAttentionWindow() {
|
|
1535
|
+
if (attentionWindowTimer) {
|
|
1536
|
+
clearTimeout(attentionWindowTimer);
|
|
1537
|
+
attentionWindowTimer = null;
|
|
1538
|
+
}
|
|
1539
|
+
if (inAttentionWindow) {
|
|
1540
|
+
statusBuffer.resume();
|
|
1541
|
+
}
|
|
1542
|
+
inAttentionWindow = false;
|
|
1543
|
+
}
|
|
1544
|
+
function scheduleIdleShutdown() {
|
|
1545
|
+
cancelIdleShutdown();
|
|
1546
|
+
if (attachedClaude)
|
|
1547
|
+
return;
|
|
1548
|
+
const snapshot = tuiConnectionState.snapshot();
|
|
1549
|
+
if (snapshot.tuiConnected)
|
|
1550
|
+
return;
|
|
1551
|
+
log(`No clients connected. Daemon will shut down in ${IDLE_SHUTDOWN_MS}ms if no one reconnects.`);
|
|
1552
|
+
idleShutdownTimer = setTimeout(() => {
|
|
1553
|
+
if (attachedClaude || tuiConnectionState.snapshot().tuiConnected) {
|
|
1554
|
+
log("Idle shutdown cancelled: client reconnected during grace period");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
shutdown("idle \u2014 no clients connected");
|
|
1558
|
+
}, IDLE_SHUTDOWN_MS);
|
|
1559
|
+
}
|
|
1560
|
+
function cancelIdleShutdown() {
|
|
1561
|
+
if (idleShutdownTimer) {
|
|
1562
|
+
clearTimeout(idleShutdownTimer);
|
|
1563
|
+
idleShutdownTimer = null;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function clearPendingClaudeDisconnect(reason) {
|
|
1567
|
+
if (!claudeDisconnectTimer)
|
|
1568
|
+
return;
|
|
1569
|
+
clearTimeout(claudeDisconnectTimer);
|
|
1570
|
+
claudeDisconnectTimer = null;
|
|
1571
|
+
if (reason) {
|
|
1572
|
+
log(`Cleared pending Claude disconnect notification (${reason})`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
function scheduleClaudeDisconnectNotification(clientId) {
|
|
1576
|
+
clearPendingClaudeDisconnect("rescheduled");
|
|
1577
|
+
claudeDisconnectTimer = setTimeout(() => {
|
|
1578
|
+
claudeDisconnectTimer = null;
|
|
1579
|
+
if (attachedClaude) {
|
|
1580
|
+
log(`Skipping Claude disconnect notification for client #${clientId} because Claude already reconnected`);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (!tuiConnectionState.canReply()) {
|
|
1584
|
+
log(`Suppressing Claude disconnect notification for client #${clientId} because Codex cannot reply`);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (!claudeOnlineNoticeSent) {
|
|
1588
|
+
log(`Suppressing Claude disconnect notification for client #${clientId} because Claude was never announced online`);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
codex.injectMessage("\u26A0\uFE0F Claude Code went offline. AgentBridge is still running in the background; it will reconnect automatically when Claude reopens.");
|
|
1592
|
+
claudeOnlineNoticeSent = false;
|
|
1593
|
+
claudeOfflineNoticeShown = true;
|
|
1594
|
+
log(`Claude disconnect persisted past grace window (client #${clientId})`);
|
|
1595
|
+
}, CLAUDE_DISCONNECT_GRACE_MS);
|
|
1596
|
+
}
|
|
1597
|
+
function emitToClaude(message) {
|
|
1598
|
+
if (attachedClaude && attachedClaude.readyState === WebSocket.OPEN) {
|
|
1599
|
+
if (trySendBridgeMessage(attachedClaude, message))
|
|
1600
|
+
return;
|
|
1601
|
+
log("Send to Claude failed, buffering message for retry on reconnect");
|
|
1602
|
+
}
|
|
1603
|
+
bufferedMessages.push(message);
|
|
1604
|
+
if (bufferedMessages.length > MAX_BUFFERED_MESSAGES) {
|
|
1605
|
+
const dropped = bufferedMessages.length - MAX_BUFFERED_MESSAGES;
|
|
1606
|
+
bufferedMessages.splice(0, dropped);
|
|
1607
|
+
log(`Message buffer overflow: dropped ${dropped} oldest message(s), ${MAX_BUFFERED_MESSAGES} remaining`);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
function trySendBridgeMessage(ws, message) {
|
|
1611
|
+
try {
|
|
1612
|
+
const result = ws.send(JSON.stringify({ type: "codex_to_claude", message }));
|
|
1613
|
+
if (typeof result === "number" && result <= 0) {
|
|
1614
|
+
log(`Bridge message send returned ${result} (0=dropped, -1=backpressure)`);
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
return true;
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
log(`Failed to send bridge message: ${err.message}`);
|
|
1620
|
+
return false;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
function flushBufferedMessages(ws) {
|
|
1624
|
+
const messages = bufferedMessages.splice(0, bufferedMessages.length);
|
|
1625
|
+
for (const message of messages) {
|
|
1626
|
+
if (!trySendBridgeMessage(ws, message)) {
|
|
1627
|
+
const failedIndex = messages.indexOf(message);
|
|
1628
|
+
const remaining = messages.slice(failedIndex);
|
|
1629
|
+
bufferedMessages.unshift(...remaining);
|
|
1630
|
+
log(`Flush interrupted: re-buffered ${remaining.length} message(s) after send failure`);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
function sendBridgeMessage(ws, message) {
|
|
1636
|
+
trySendBridgeMessage(ws, message);
|
|
1637
|
+
}
|
|
1638
|
+
function sendStatus(ws) {
|
|
1639
|
+
sendProtocolMessage(ws, { type: "status", status: currentStatus() });
|
|
1640
|
+
}
|
|
1641
|
+
function broadcastStatus() {
|
|
1642
|
+
if (!attachedClaude)
|
|
1643
|
+
return;
|
|
1644
|
+
sendStatus(attachedClaude);
|
|
1645
|
+
}
|
|
1646
|
+
function sendProtocolMessage(ws, message) {
|
|
1647
|
+
try {
|
|
1648
|
+
ws.send(JSON.stringify(message));
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
log(`Failed to send control message: ${err.message}`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
function currentStatus() {
|
|
1654
|
+
const snapshot = tuiConnectionState.snapshot();
|
|
1655
|
+
return {
|
|
1656
|
+
bridgeReady: tuiConnectionState.canReply(),
|
|
1657
|
+
tuiConnected: snapshot.tuiConnected,
|
|
1658
|
+
threadId: codex.activeThreadId,
|
|
1659
|
+
queuedMessageCount: bufferedMessages.length + statusBuffer.size,
|
|
1660
|
+
proxyUrl: codex.proxyUrl,
|
|
1661
|
+
appServerUrl: codex.appServerUrl,
|
|
1662
|
+
pid: process.pid
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
function currentWaitingMessage() {
|
|
1666
|
+
return `\u23F3 Waiting for Codex TUI to connect. Run in another terminal:
|
|
1667
|
+
${attachCmd}`;
|
|
1668
|
+
}
|
|
1669
|
+
function currentReadyMessage() {
|
|
1670
|
+
return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
|
|
1671
|
+
}
|
|
1672
|
+
function notifyCodexClaudeOnline() {
|
|
1673
|
+
claudeOnlineNoticeSent = true;
|
|
1674
|
+
claudeOfflineNoticeShown = false;
|
|
1675
|
+
codex.injectMessage("\u2705 AgentBridge connected to Claude Code.");
|
|
1676
|
+
}
|
|
1677
|
+
function shouldNotifyCodexClaudeOnline() {
|
|
1678
|
+
return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;
|
|
1679
|
+
}
|
|
1680
|
+
function systemMessage(idPrefix, content) {
|
|
1681
|
+
return {
|
|
1682
|
+
id: `${idPrefix}_${++nextSystemMessageId}`,
|
|
1683
|
+
source: "codex",
|
|
1684
|
+
content,
|
|
1685
|
+
timestamp: Date.now()
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
function writePidFile() {
|
|
1689
|
+
daemonLifecycle.writePid();
|
|
1690
|
+
}
|
|
1691
|
+
function removePidFile() {
|
|
1692
|
+
daemonLifecycle.removePidFile();
|
|
1693
|
+
}
|
|
1694
|
+
function writeStatusFile() {
|
|
1695
|
+
daemonLifecycle.writeStatus({
|
|
1696
|
+
proxyUrl: codex.proxyUrl,
|
|
1697
|
+
appServerUrl: codex.appServerUrl,
|
|
1698
|
+
controlPort: CONTROL_PORT,
|
|
1699
|
+
pid: process.pid
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
function removeStatusFile() {
|
|
1703
|
+
daemonLifecycle.removeStatusFile();
|
|
1704
|
+
}
|
|
1705
|
+
async function bootCodex() {
|
|
1706
|
+
log("Starting AgentBridge daemon...");
|
|
1707
|
+
log(`Codex app-server: ${codex.appServerUrl}`);
|
|
1708
|
+
log(`Codex proxy: ${codex.proxyUrl}`);
|
|
1709
|
+
log(`Control server: ws://127.0.0.1:${CONTROL_PORT}/ws`);
|
|
1710
|
+
try {
|
|
1711
|
+
await codex.start();
|
|
1712
|
+
codexBootstrapped = true;
|
|
1713
|
+
writeStatusFile();
|
|
1714
|
+
emitToClaude(systemMessage("system_waiting", currentWaitingMessage()));
|
|
1715
|
+
broadcastStatus();
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
log(`Failed to start Codex: ${err.message}`);
|
|
1718
|
+
emitToClaude(systemMessage("system_codex_start_failed", `\u274C AgentBridge failed to start Codex app-server: ${err.message}`));
|
|
1719
|
+
broadcastStatus();
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
function shutdown(reason) {
|
|
1723
|
+
if (shuttingDown)
|
|
1724
|
+
return;
|
|
1725
|
+
shuttingDown = true;
|
|
1726
|
+
log(`Shutting down daemon (${reason})...`);
|
|
1727
|
+
tuiConnectionState.dispose(`daemon shutdown (${reason})`);
|
|
1728
|
+
clearPendingClaudeDisconnect(`daemon shutdown (${reason})`);
|
|
1729
|
+
controlServer?.stop();
|
|
1730
|
+
controlServer = null;
|
|
1731
|
+
codex.stop();
|
|
1732
|
+
removePidFile();
|
|
1733
|
+
removeStatusFile();
|
|
1734
|
+
process.exit(0);
|
|
1735
|
+
}
|
|
1736
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1737
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1738
|
+
process.on("exit", () => {
|
|
1739
|
+
removePidFile();
|
|
1740
|
+
removeStatusFile();
|
|
1741
|
+
});
|
|
1742
|
+
process.on("uncaughtException", (err) => {
|
|
1743
|
+
log(`UNCAUGHT EXCEPTION: ${err.stack ?? err.message}`);
|
|
1744
|
+
});
|
|
1745
|
+
process.on("unhandledRejection", (reason) => {
|
|
1746
|
+
log(`UNHANDLED REJECTION: ${reason?.stack ?? reason}`);
|
|
1747
|
+
});
|
|
1748
|
+
function log(msg) {
|
|
1749
|
+
const line = `[${new Date().toISOString()}] [AgentBridgeDaemon] ${msg}
|
|
1750
|
+
`;
|
|
1751
|
+
process.stderr.write(line);
|
|
1752
|
+
try {
|
|
1753
|
+
appendFileSync2(stateDir.logFile, line);
|
|
1754
|
+
} catch {}
|
|
1755
|
+
}
|
|
1756
|
+
if (daemonLifecycle.wasKilled()) {
|
|
1757
|
+
log("Killed sentinel found \u2014 daemon was intentionally stopped. Exiting immediately.");
|
|
1758
|
+
process.exit(0);
|
|
1759
|
+
}
|
|
1760
|
+
writePidFile();
|
|
1761
|
+
startControlServer();
|
|
1762
|
+
bootCodex();
|