@raysonmeng/agentbridge 0.1.5 → 0.1.6
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 +2 -3
- package/README.zh-CN.md +2 -3
- package/dist/cli.js +324 -88
- package/package.json +1 -1
- package/plugins/agentbridge/.claude-plugin/plugin.json +1 -1
- package/plugins/agentbridge/server/bridge-server.js +121 -117
- package/plugins/agentbridge/server/daemon.js +336 -124
- package/scripts/postinstall.cjs +44 -4
|
@@ -10,6 +10,58 @@ import { createInterface } from "readline";
|
|
|
10
10
|
import { EventEmitter } from "events";
|
|
11
11
|
import { appendFileSync } from "fs";
|
|
12
12
|
|
|
13
|
+
// src/state-dir.ts
|
|
14
|
+
import { mkdirSync, existsSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { homedir, platform } from "os";
|
|
17
|
+
|
|
18
|
+
class StateDirResolver {
|
|
19
|
+
stateDir;
|
|
20
|
+
constructor(envOverride) {
|
|
21
|
+
const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
|
|
22
|
+
if (override) {
|
|
23
|
+
this.stateDir = override;
|
|
24
|
+
} else if (platform() === "darwin") {
|
|
25
|
+
this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
26
|
+
} else {
|
|
27
|
+
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
28
|
+
this.stateDir = join(xdgState, "agentbridge");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
ensure() {
|
|
32
|
+
if (!existsSync(this.stateDir)) {
|
|
33
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
get dir() {
|
|
37
|
+
return this.stateDir;
|
|
38
|
+
}
|
|
39
|
+
get pidFile() {
|
|
40
|
+
return join(this.stateDir, "daemon.pid");
|
|
41
|
+
}
|
|
42
|
+
get tuiPidFile() {
|
|
43
|
+
return join(this.stateDir, "codex-tui.pid");
|
|
44
|
+
}
|
|
45
|
+
get lockFile() {
|
|
46
|
+
return join(this.stateDir, "daemon.lock");
|
|
47
|
+
}
|
|
48
|
+
get statusFile() {
|
|
49
|
+
return join(this.stateDir, "status.json");
|
|
50
|
+
}
|
|
51
|
+
get portsFile() {
|
|
52
|
+
return join(this.stateDir, "ports.json");
|
|
53
|
+
}
|
|
54
|
+
get logFile() {
|
|
55
|
+
return join(this.stateDir, "agentbridge.log");
|
|
56
|
+
}
|
|
57
|
+
get codexWrapperLogFile() {
|
|
58
|
+
return join(this.stateDir, "codex-wrapper.log");
|
|
59
|
+
}
|
|
60
|
+
get killedFile() {
|
|
61
|
+
return join(this.stateDir, "killed");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
13
65
|
// src/app-server-protocol.ts
|
|
14
66
|
var APP_SERVER_TRACKED_REQUEST_METHODS = [
|
|
15
67
|
"thread/start",
|
|
@@ -54,8 +106,6 @@ function isAppServerResponseMessage(value) {
|
|
|
54
106
|
}
|
|
55
107
|
|
|
56
108
|
// src/codex-adapter.ts
|
|
57
|
-
var LOG_FILE = "/tmp/agentbridge.log";
|
|
58
|
-
|
|
59
109
|
class CodexAdapter extends EventEmitter {
|
|
60
110
|
static RESPONSE_TRACKING_TTL_MS = 30000;
|
|
61
111
|
proc = null;
|
|
@@ -66,6 +116,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
66
116
|
nextInjectionId = -1;
|
|
67
117
|
appPort;
|
|
68
118
|
proxyPort;
|
|
119
|
+
logFile;
|
|
69
120
|
tuiConnId = 0;
|
|
70
121
|
connIdCounter = 0;
|
|
71
122
|
secondaryConnections = new Map;
|
|
@@ -85,10 +136,20 @@ class CodexAdapter extends EventEmitter {
|
|
|
85
136
|
reconnectingForNewSession = false;
|
|
86
137
|
replayingBufferedMessages = false;
|
|
87
138
|
appServerGeneration = 0;
|
|
88
|
-
|
|
139
|
+
outageQueue = [];
|
|
140
|
+
outageTimer = null;
|
|
141
|
+
static OUTAGE_QUEUE_MAX = 64;
|
|
142
|
+
static OUTAGE_TIMEOUT_MS = 5000;
|
|
143
|
+
lastInitializeRaw = null;
|
|
144
|
+
lastInitializedRaw = null;
|
|
145
|
+
sessionRestoreInProgress = false;
|
|
146
|
+
replayPending = new Map;
|
|
147
|
+
static SESSION_REPLAY_TIMEOUT_MS = 5000;
|
|
148
|
+
constructor(appPort = 4500, proxyPort = 4501, logFile = new StateDirResolver().logFile) {
|
|
89
149
|
super();
|
|
90
150
|
this.appPort = appPort;
|
|
91
151
|
this.proxyPort = proxyPort;
|
|
152
|
+
this.logFile = logFile;
|
|
92
153
|
}
|
|
93
154
|
get appServerUrl() {
|
|
94
155
|
return `ws://127.0.0.1:${this.appPort}`;
|
|
@@ -123,6 +184,8 @@ class CodexAdapter extends EventEmitter {
|
|
|
123
184
|
clearTimeout(this.reconnectTimer);
|
|
124
185
|
this.reconnectTimer = null;
|
|
125
186
|
}
|
|
187
|
+
this.outageQueue = [];
|
|
188
|
+
this.clearOutageTimer();
|
|
126
189
|
this.appServerWs?.close();
|
|
127
190
|
this.appServerWs = null;
|
|
128
191
|
for (const [id, sec] of this.secondaryConnections) {
|
|
@@ -204,6 +267,14 @@ class CodexAdapter extends EventEmitter {
|
|
|
204
267
|
this.reconnectAttempts = 0;
|
|
205
268
|
this.log(isReconnect ? "Reconnected to app-server" : "Connected to app-server");
|
|
206
269
|
this.flushPendingServerResponses();
|
|
270
|
+
if (isReconnect) {
|
|
271
|
+
this.handleSessionRestoreAfterReconnect().finally(() => this.drainOutageQueue()).catch((e) => {
|
|
272
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
273
|
+
this.log(`session restore unexpected error: ${m}`);
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
this.drainOutageQueue();
|
|
277
|
+
}
|
|
207
278
|
resolve();
|
|
208
279
|
};
|
|
209
280
|
appWs.onmessage = (event) => {
|
|
@@ -302,15 +373,175 @@ class CodexAdapter extends EventEmitter {
|
|
|
302
373
|
}, delay);
|
|
303
374
|
}
|
|
304
375
|
handleAppServerClose() {
|
|
305
|
-
this.
|
|
376
|
+
const intentional = this.intentionalDisconnect;
|
|
377
|
+
const tuiConnected = this.tuiWs !== null;
|
|
378
|
+
this.log(`App-server connection closed (intentional=${intentional}, tuiConnected=${tuiConnected}, turnInProgress=${this.turnInProgress})`);
|
|
306
379
|
this.appServerWs = null;
|
|
307
380
|
this.clearResponseTrackingState();
|
|
308
381
|
this.activeTurnIds.clear();
|
|
309
382
|
this.turnInProgress = false;
|
|
310
|
-
if (!
|
|
383
|
+
if (!intentional) {
|
|
311
384
|
this.scheduleReconnect();
|
|
312
385
|
}
|
|
313
386
|
}
|
|
387
|
+
bufferDuringOutage(ws, raw) {
|
|
388
|
+
if (this.outageQueue.length >= CodexAdapter.OUTAGE_QUEUE_MAX) {
|
|
389
|
+
this.log(`ERROR: outage queue overflow (${this.outageQueue.length}/${CodexAdapter.OUTAGE_QUEUE_MAX}) \u2014 closing TUI with 1011`);
|
|
390
|
+
this.outageQueue = [];
|
|
391
|
+
this.clearOutageTimer();
|
|
392
|
+
if (this.tuiWs && this.tuiWs === ws) {
|
|
393
|
+
try {
|
|
394
|
+
ws.close(1011, "agentbridge: app-server unavailable; pending TUI queue overflow");
|
|
395
|
+
} catch (e) {
|
|
396
|
+
this.log(`Failed to close TUI WS after outage queue overflow: ${e.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.outageQueue.push({ raw, connId: ws.data.connId });
|
|
402
|
+
this.log(`DIAGNOSTIC: buffered TUI message while app-server unavailable (queue size=${this.outageQueue.length}/${CodexAdapter.OUTAGE_QUEUE_MAX})`);
|
|
403
|
+
this.ensureOutageTimer();
|
|
404
|
+
}
|
|
405
|
+
ensureOutageTimer() {
|
|
406
|
+
if (this.outageTimer !== null)
|
|
407
|
+
return;
|
|
408
|
+
this.outageTimer = setTimeout(() => {
|
|
409
|
+
this.outageTimer = null;
|
|
410
|
+
const buffered = this.outageQueue.length;
|
|
411
|
+
this.outageQueue = [];
|
|
412
|
+
this.log(`ERROR: app-server did not return within ${CodexAdapter.OUTAGE_TIMEOUT_MS}ms (buffered=${buffered}) \u2014 closing TUI with 1011`);
|
|
413
|
+
const ws = this.tuiWs;
|
|
414
|
+
if (ws) {
|
|
415
|
+
try {
|
|
416
|
+
ws.close(1011, `agentbridge: app-server unavailable after ${CodexAdapter.OUTAGE_TIMEOUT_MS}ms; buffered=${buffered}`);
|
|
417
|
+
} catch (e) {
|
|
418
|
+
this.log(`Failed to close TUI WS on outage timeout: ${e.message}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}, CodexAdapter.OUTAGE_TIMEOUT_MS);
|
|
422
|
+
}
|
|
423
|
+
clearOutageTimer() {
|
|
424
|
+
if (this.outageTimer !== null) {
|
|
425
|
+
clearTimeout(this.outageTimer);
|
|
426
|
+
this.outageTimer = null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async handleSessionRestoreAfterReconnect() {
|
|
430
|
+
if (!this.lastInitializeRaw) {
|
|
431
|
+
this.log("DIAGNOSTIC: no cached initialize to replay after unintentional reconnect");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
435
|
+
this.log("DIAGNOSTIC: app-server not open at session restore start \u2014 skipping");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.sessionRestoreInProgress = true;
|
|
439
|
+
try {
|
|
440
|
+
this.log(`DIAGNOSTIC: replaying cached initialize to restore session (threadId=${this.threadId ?? "none"})`);
|
|
441
|
+
await this.sendReplayAndAwait(this.lastInitializeRaw, "initialize");
|
|
442
|
+
if (this.lastInitializedRaw && this.appServerWs.readyState === WebSocket.OPEN) {
|
|
443
|
+
this.appServerWs.send(this.lastInitializedRaw);
|
|
444
|
+
}
|
|
445
|
+
if (this.threadId && this.appServerWs.readyState === WebSocket.OPEN) {
|
|
446
|
+
const replayId = `agentbridge-replay-thread-resume-${Date.now()}`;
|
|
447
|
+
const resumeRaw = JSON.stringify({
|
|
448
|
+
jsonrpc: "2.0",
|
|
449
|
+
id: replayId,
|
|
450
|
+
method: "thread/resume",
|
|
451
|
+
params: { threadId: this.threadId }
|
|
452
|
+
});
|
|
453
|
+
await this.sendReplayAndAwait(resumeRaw, "thread/resume");
|
|
454
|
+
}
|
|
455
|
+
this.log(`DIAGNOSTIC: session restored after unintentional reconnect (threadId=${this.threadId ?? "none"})`);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
458
|
+
this.log(`ERROR: session restore failed (${msg}) \u2014 closing TUI with 1011`);
|
|
459
|
+
const tuiWs = this.tuiWs;
|
|
460
|
+
if (tuiWs) {
|
|
461
|
+
try {
|
|
462
|
+
tuiWs.close(1011, `agentbridge: session restore failed: ${msg}`);
|
|
463
|
+
} catch (closeErr) {
|
|
464
|
+
const cm = closeErr instanceof Error ? closeErr.message : String(closeErr);
|
|
465
|
+
this.log(`Failed to close TUI after session restore failure: ${cm}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} finally {
|
|
469
|
+
this.sessionRestoreInProgress = false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
sendReplayAndAwait(raw, method) {
|
|
473
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
|
|
474
|
+
return Promise.reject(new Error("app-server not open"));
|
|
475
|
+
}
|
|
476
|
+
let id;
|
|
477
|
+
try {
|
|
478
|
+
const parsed = JSON.parse(raw);
|
|
479
|
+
if (parsed.id === undefined) {
|
|
480
|
+
return Promise.reject(new Error(`replay payload for ${method} has no id`));
|
|
481
|
+
}
|
|
482
|
+
id = parsed.id;
|
|
483
|
+
} catch (e) {
|
|
484
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
485
|
+
return Promise.reject(new Error(`replay parse failed for ${method}: ${m}`));
|
|
486
|
+
}
|
|
487
|
+
return new Promise((resolve, reject) => {
|
|
488
|
+
const timer = setTimeout(() => {
|
|
489
|
+
this.replayPending.delete(id);
|
|
490
|
+
reject(new Error(`replay timeout (${CodexAdapter.SESSION_REPLAY_TIMEOUT_MS}ms) for ${method} id=${JSON.stringify(id)}`));
|
|
491
|
+
}, CodexAdapter.SESSION_REPLAY_TIMEOUT_MS);
|
|
492
|
+
this.replayPending.set(id, { method, resolve, reject, timer });
|
|
493
|
+
try {
|
|
494
|
+
this.appServerWs.send(raw);
|
|
495
|
+
} catch (e) {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
this.replayPending.delete(id);
|
|
498
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
499
|
+
reject(new Error(`replay send failed for ${method}: ${m}`));
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
tryConsumeReplayResponse(payload) {
|
|
504
|
+
const id = payload.id;
|
|
505
|
+
if (id === undefined)
|
|
506
|
+
return false;
|
|
507
|
+
const pending = this.replayPending.get(id);
|
|
508
|
+
if (!pending)
|
|
509
|
+
return false;
|
|
510
|
+
clearTimeout(pending.timer);
|
|
511
|
+
this.replayPending.delete(id);
|
|
512
|
+
if (payload.error !== undefined) {
|
|
513
|
+
const errMsg = typeof payload.error === "object" && payload.error !== null && "message" in payload.error ? String(payload.error.message ?? "unknown") : JSON.stringify(payload.error);
|
|
514
|
+
pending.reject(new Error(`${pending.method} rejected: ${errMsg}`));
|
|
515
|
+
} else {
|
|
516
|
+
pending.resolve(payload);
|
|
517
|
+
}
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
drainOutageQueue() {
|
|
521
|
+
if (this.outageQueue.length === 0) {
|
|
522
|
+
this.clearOutageTimer();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN)
|
|
526
|
+
return;
|
|
527
|
+
const ws = this.tuiWs;
|
|
528
|
+
if (!ws) {
|
|
529
|
+
this.outageQueue = [];
|
|
530
|
+
this.clearOutageTimer();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const messages = this.outageQueue;
|
|
534
|
+
this.outageQueue = [];
|
|
535
|
+
this.clearOutageTimer();
|
|
536
|
+
this.log(`DIAGNOSTIC: replaying ${messages.length} buffered TUI messages after app-server reconnect`);
|
|
537
|
+
for (const msg of messages) {
|
|
538
|
+
try {
|
|
539
|
+
this.onTuiMessage(ws, msg.raw);
|
|
540
|
+
} catch (e) {
|
|
541
|
+
this.log(`Failed to replay buffered TUI message (conn #${msg.connId}): ${e.message}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
314
545
|
startProxy() {
|
|
315
546
|
const self = this;
|
|
316
547
|
this.proxyServer = Bun.serve({
|
|
@@ -456,13 +687,19 @@ class CodexAdapter extends EventEmitter {
|
|
|
456
687
|
return;
|
|
457
688
|
}
|
|
458
689
|
if (this.tuiWs === ws) {
|
|
459
|
-
this.
|
|
690
|
+
const appServerOpen = this.appServerWs?.readyState === WebSocket.OPEN;
|
|
691
|
+
this.log(`TUI disconnected (conn #${connId}, appServerOpen=${appServerOpen}, turnInProgress=${this.turnInProgress}, pendingTuiMessages=${this.pendingTuiMessages.length}, outageQueue=${this.outageQueue.length}, reconnectingForNewSession=${this.reconnectingForNewSession})`);
|
|
460
692
|
this.tuiWs = null;
|
|
461
693
|
if (this.reconnectingForNewSession) {
|
|
462
694
|
this.log("Clearing pending TUI message buffer (TUI disconnected during app-server reconnect)");
|
|
463
695
|
this.pendingTuiMessages = [];
|
|
464
696
|
this.reconnectingForNewSession = false;
|
|
465
697
|
}
|
|
698
|
+
if (this.outageQueue.length > 0 || this.outageTimer !== null) {
|
|
699
|
+
this.log(`Clearing outage queue on TUI disconnect (buffered=${this.outageQueue.length})`);
|
|
700
|
+
this.outageQueue = [];
|
|
701
|
+
this.clearOutageTimer();
|
|
702
|
+
}
|
|
466
703
|
this.emit("tuiDisconnected", connId);
|
|
467
704
|
} else {
|
|
468
705
|
this.log(`Stale TUI disconnected (conn #${connId}, current is #${this.tuiConnId})`);
|
|
@@ -525,6 +762,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
525
762
|
} catch {}
|
|
526
763
|
if (!this.replayingBufferedMessages) {
|
|
527
764
|
if (detectedMethod === "initialize") {
|
|
765
|
+
this.lastInitializeRaw = data;
|
|
528
766
|
this.log("Detected initialize \u2014 reconnecting app-server for fresh session");
|
|
529
767
|
this.reconnectingForNewSession = true;
|
|
530
768
|
this.pendingTuiMessages = [data];
|
|
@@ -536,6 +774,17 @@ class CodexAdapter extends EventEmitter {
|
|
|
536
774
|
return;
|
|
537
775
|
}
|
|
538
776
|
}
|
|
777
|
+
if (detectedMethod === "initialized") {
|
|
778
|
+
this.lastInitializedRaw = data;
|
|
779
|
+
}
|
|
780
|
+
if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN || this.sessionRestoreInProgress) {
|
|
781
|
+
if (this.tuiWs && this.tuiWs === ws) {
|
|
782
|
+
this.bufferDuringOutage(ws, data);
|
|
783
|
+
} else {
|
|
784
|
+
this.log(`WARNING: non-primary TUI attempted to send while app-server down \u2014 dropped (connId=${connId})`);
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
539
788
|
let forwarded = data;
|
|
540
789
|
try {
|
|
541
790
|
const parsed = JSON.parse(data);
|
|
@@ -556,14 +805,24 @@ class CodexAdapter extends EventEmitter {
|
|
|
556
805
|
if (this.appServerWs?.readyState === WebSocket.OPEN) {
|
|
557
806
|
this.appServerWs.send(forwarded);
|
|
558
807
|
} else {
|
|
559
|
-
this.log(`WARNING: app-server
|
|
808
|
+
this.log(`WARNING: app-server closed between OPEN check and send \u2014 message lost (connId=${ws.data.connId})`);
|
|
560
809
|
}
|
|
561
810
|
}
|
|
562
811
|
handleAppServerPayload(raw) {
|
|
563
812
|
try {
|
|
564
813
|
const parsed = JSON.parse(raw);
|
|
814
|
+
if (typeof parsed === "object" && parsed !== null && "id" in parsed) {
|
|
815
|
+
if (this.tryConsumeReplayResponse(parsed)) {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
565
819
|
if (isAppServerNotification(parsed) || typeof parsed === "object" && parsed !== null && !("id" in parsed)) {
|
|
566
820
|
const notificationLike = parsed;
|
|
821
|
+
if (notificationLike.method === "thread/closed") {
|
|
822
|
+
const params = notificationLike.params;
|
|
823
|
+
const threadId = typeof params?.threadId === "string" ? params.threadId : "unknown";
|
|
824
|
+
this.log(`DIAGNOSTIC: app-server emitted thread/closed (threadId=${threadId}) \u2014 TUI will exit(0) silently`);
|
|
825
|
+
}
|
|
567
826
|
const forwarded = this.patchResponse(notificationLike, raw);
|
|
568
827
|
this.interceptServerMessage(notificationLike);
|
|
569
828
|
return forwarded;
|
|
@@ -959,10 +1218,15 @@ class CodexAdapter extends EventEmitter {
|
|
|
959
1218
|
this.serverRequestToProxy.clear();
|
|
960
1219
|
this.pendingServerResponses.clear();
|
|
961
1220
|
}
|
|
1221
|
+
static buildPortListenLsofCommand(port) {
|
|
1222
|
+
return `lsof -ti tcp:${port} -sTCP:LISTEN`;
|
|
1223
|
+
}
|
|
962
1224
|
async checkPorts() {
|
|
963
1225
|
for (const port of [this.appPort, this.proxyPort]) {
|
|
964
1226
|
try {
|
|
965
|
-
const pids = execSync(
|
|
1227
|
+
const pids = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
|
|
1228
|
+
encoding: "utf-8"
|
|
1229
|
+
}).trim();
|
|
966
1230
|
if (!pids)
|
|
967
1231
|
continue;
|
|
968
1232
|
const pidList = pids.split(`
|
|
@@ -992,7 +1256,9 @@ class CodexAdapter extends EventEmitter {
|
|
|
992
1256
|
throw new Error(`Port ${port} is already in use by non-Codex process(es): PID(s) ${foreignPids.join(", ")}. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
|
|
993
1257
|
}
|
|
994
1258
|
try {
|
|
995
|
-
const remaining = execSync(
|
|
1259
|
+
const remaining = execSync(CodexAdapter.buildPortListenLsofCommand(port), {
|
|
1260
|
+
encoding: "utf-8"
|
|
1261
|
+
}).trim();
|
|
996
1262
|
if (remaining) {
|
|
997
1263
|
throw new Error(`Port ${port} is still occupied (PID(s): ${remaining.replace(/\n/g, ", ")}) after cleanup. ` + `Please stop the process or set a different port via ${port === this.appPort ? "CODEX_WS_PORT" : "CODEX_PROXY_PORT"} env var.`);
|
|
998
1264
|
}
|
|
@@ -1011,7 +1277,7 @@ class CodexAdapter extends EventEmitter {
|
|
|
1011
1277
|
`;
|
|
1012
1278
|
process.stderr.write(line);
|
|
1013
1279
|
try {
|
|
1014
|
-
appendFileSync(
|
|
1280
|
+
appendFileSync(this.logFile, line);
|
|
1015
1281
|
} catch {}
|
|
1016
1282
|
}
|
|
1017
1283
|
}
|
|
@@ -1219,7 +1485,7 @@ class TuiConnectionState {
|
|
|
1219
1485
|
|
|
1220
1486
|
// src/daemon-lifecycle.ts
|
|
1221
1487
|
import { spawn as spawn2, execFileSync } from "child_process";
|
|
1222
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
1488
|
+
import { existsSync as existsSync2, readFileSync, unlinkSync, writeFileSync, openSync, closeSync, constants } from "fs";
|
|
1223
1489
|
import { fileURLToPath } from "url";
|
|
1224
1490
|
var DAEMON_ENTRY = process.env.AGENTBRIDGE_DAEMON_ENTRY ?? "./daemon.ts";
|
|
1225
1491
|
var DAEMON_PATH = fileURLToPath(new URL(DAEMON_ENTRY, import.meta.url));
|
|
@@ -1357,7 +1623,7 @@ class DaemonLifecycle {
|
|
|
1357
1623
|
} catch {}
|
|
1358
1624
|
}
|
|
1359
1625
|
wasKilled() {
|
|
1360
|
-
return
|
|
1626
|
+
return existsSync2(this.stateDir.killedFile);
|
|
1361
1627
|
}
|
|
1362
1628
|
launch() {
|
|
1363
1629
|
this.stateDir.ensure();
|
|
@@ -1478,116 +1744,62 @@ function isProcessAlive(pid) {
|
|
|
1478
1744
|
}
|
|
1479
1745
|
}
|
|
1480
1746
|
|
|
1481
|
-
// src/state-dir.ts
|
|
1482
|
-
import { mkdirSync, existsSync as existsSync2 } from "fs";
|
|
1483
|
-
import { join } from "path";
|
|
1484
|
-
import { homedir, platform } from "os";
|
|
1485
|
-
|
|
1486
|
-
class StateDirResolver {
|
|
1487
|
-
stateDir;
|
|
1488
|
-
constructor(envOverride) {
|
|
1489
|
-
const override = envOverride ?? process.env.AGENTBRIDGE_STATE_DIR;
|
|
1490
|
-
if (override) {
|
|
1491
|
-
this.stateDir = override;
|
|
1492
|
-
} else if (platform() === "darwin") {
|
|
1493
|
-
this.stateDir = join(homedir(), "Library", "Application Support", "AgentBridge");
|
|
1494
|
-
} else {
|
|
1495
|
-
const xdgState = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
1496
|
-
this.stateDir = join(xdgState, "agentbridge");
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
ensure() {
|
|
1500
|
-
if (!existsSync2(this.stateDir)) {
|
|
1501
|
-
mkdirSync(this.stateDir, { recursive: true });
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
get dir() {
|
|
1505
|
-
return this.stateDir;
|
|
1506
|
-
}
|
|
1507
|
-
get pidFile() {
|
|
1508
|
-
return join(this.stateDir, "daemon.pid");
|
|
1509
|
-
}
|
|
1510
|
-
get tuiPidFile() {
|
|
1511
|
-
return join(this.stateDir, "codex-tui.pid");
|
|
1512
|
-
}
|
|
1513
|
-
get lockFile() {
|
|
1514
|
-
return join(this.stateDir, "daemon.lock");
|
|
1515
|
-
}
|
|
1516
|
-
get statusFile() {
|
|
1517
|
-
return join(this.stateDir, "status.json");
|
|
1518
|
-
}
|
|
1519
|
-
get portsFile() {
|
|
1520
|
-
return join(this.stateDir, "ports.json");
|
|
1521
|
-
}
|
|
1522
|
-
get logFile() {
|
|
1523
|
-
return join(this.stateDir, "agentbridge.log");
|
|
1524
|
-
}
|
|
1525
|
-
get killedFile() {
|
|
1526
|
-
return join(this.stateDir, "killed");
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
1747
|
// src/config-service.ts
|
|
1531
1748
|
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync3 } from "fs";
|
|
1532
1749
|
import { join as join2 } from "path";
|
|
1533
1750
|
var DEFAULT_CONFIG = {
|
|
1534
1751
|
version: "1.0",
|
|
1535
|
-
|
|
1536
|
-
|
|
1752
|
+
codex: {
|
|
1753
|
+
appPort: 4500,
|
|
1537
1754
|
proxyPort: 4501
|
|
1538
1755
|
},
|
|
1539
|
-
agents: {
|
|
1540
|
-
claude: {
|
|
1541
|
-
role: "Reviewer, Planner",
|
|
1542
|
-
mode: "push"
|
|
1543
|
-
},
|
|
1544
|
-
codex: {
|
|
1545
|
-
role: "Implementer, Executor"
|
|
1546
|
-
}
|
|
1547
|
-
},
|
|
1548
|
-
markers: ["IMPORTANT", "STATUS", "FYI"],
|
|
1549
1756
|
turnCoordination: {
|
|
1550
|
-
attentionWindowSeconds: 15
|
|
1551
|
-
busyGuard: true
|
|
1757
|
+
attentionWindowSeconds: 15
|
|
1552
1758
|
},
|
|
1553
1759
|
idleShutdownSeconds: 30
|
|
1554
1760
|
};
|
|
1555
|
-
var DEFAULT_COLLABORATION_MD = `# Collaboration Rules
|
|
1556
|
-
|
|
1557
|
-
## Roles
|
|
1558
|
-
- Claude: Reviewer, Planner, Hypothesis Challenger
|
|
1559
|
-
- Codex: Implementer, Executor, Reproducer/Verifier
|
|
1560
|
-
|
|
1561
|
-
## Thinking Patterns
|
|
1562
|
-
- Analytical/review tasks: Independent Analysis & Convergence
|
|
1563
|
-
- Implementation tasks: Architect -> Builder -> Critic
|
|
1564
|
-
- Debugging tasks: Hypothesis -> Experiment -> Interpretation
|
|
1565
|
-
|
|
1566
|
-
## Communication
|
|
1567
|
-
- Use explicit phrases: "My independent view is:", "I agree on:", "I disagree on:", "Current consensus:"
|
|
1568
|
-
- Tag messages with [IMPORTANT], [STATUS], or [FYI]
|
|
1569
|
-
|
|
1570
|
-
## Review Process
|
|
1571
|
-
- Cross-review: author never reviews their own code
|
|
1572
|
-
- All changes go through feature/fix branches + PR
|
|
1573
|
-
- Merge via squash merge
|
|
1574
|
-
|
|
1575
|
-
## Custom Rules
|
|
1576
|
-
<!-- Add your project-specific collaboration rules here -->
|
|
1577
|
-
`;
|
|
1578
1761
|
var CONFIG_DIR = ".agentbridge";
|
|
1579
1762
|
var CONFIG_FILE = "config.json";
|
|
1580
|
-
|
|
1763
|
+
function isRecord(value) {
|
|
1764
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1765
|
+
}
|
|
1766
|
+
function normalizeInteger(value, fallback) {
|
|
1767
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
1768
|
+
return value;
|
|
1769
|
+
if (typeof value === "string") {
|
|
1770
|
+
const parsed = Number(value);
|
|
1771
|
+
if (Number.isFinite(parsed))
|
|
1772
|
+
return parsed;
|
|
1773
|
+
}
|
|
1774
|
+
return fallback;
|
|
1775
|
+
}
|
|
1776
|
+
function normalizeConfig(raw) {
|
|
1777
|
+
if (!isRecord(raw))
|
|
1778
|
+
return null;
|
|
1779
|
+
const config = raw;
|
|
1780
|
+
const codex = isRecord(config.codex) ? config.codex : {};
|
|
1781
|
+
const daemon = isRecord(config.daemon) ? config.daemon : {};
|
|
1782
|
+
const turnCoordination = isRecord(config.turnCoordination) ? config.turnCoordination : {};
|
|
1783
|
+
return {
|
|
1784
|
+
version: typeof config.version === "string" ? config.version : DEFAULT_CONFIG.version,
|
|
1785
|
+
codex: {
|
|
1786
|
+
appPort: normalizeInteger(codex.appPort ?? daemon.port, DEFAULT_CONFIG.codex.appPort),
|
|
1787
|
+
proxyPort: normalizeInteger(codex.proxyPort ?? daemon.proxyPort, DEFAULT_CONFIG.codex.proxyPort)
|
|
1788
|
+
},
|
|
1789
|
+
turnCoordination: {
|
|
1790
|
+
attentionWindowSeconds: normalizeInteger(turnCoordination.attentionWindowSeconds, DEFAULT_CONFIG.turnCoordination.attentionWindowSeconds)
|
|
1791
|
+
},
|
|
1792
|
+
idleShutdownSeconds: normalizeInteger(config.idleShutdownSeconds, DEFAULT_CONFIG.idleShutdownSeconds)
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1581
1795
|
|
|
1582
1796
|
class ConfigService {
|
|
1583
1797
|
configDir;
|
|
1584
1798
|
configPath;
|
|
1585
|
-
collaborationPath;
|
|
1586
1799
|
constructor(projectRoot) {
|
|
1587
1800
|
const root = projectRoot ?? process.cwd();
|
|
1588
1801
|
this.configDir = join2(root, CONFIG_DIR);
|
|
1589
1802
|
this.configPath = join2(this.configDir, CONFIG_FILE);
|
|
1590
|
-
this.collaborationPath = join2(this.configDir, COLLABORATION_FILE);
|
|
1591
1803
|
}
|
|
1592
1804
|
hasConfig() {
|
|
1593
1805
|
return existsSync3(this.configPath);
|
|
@@ -1595,7 +1807,7 @@ class ConfigService {
|
|
|
1595
1807
|
load() {
|
|
1596
1808
|
try {
|
|
1597
1809
|
const raw = readFileSync2(this.configPath, "utf-8");
|
|
1598
|
-
return JSON.parse(raw);
|
|
1810
|
+
return normalizeConfig(JSON.parse(raw));
|
|
1599
1811
|
} catch {
|
|
1600
1812
|
return null;
|
|
1601
1813
|
}
|
|
@@ -1608,17 +1820,6 @@ class ConfigService {
|
|
|
1608
1820
|
writeFileSync2(this.configPath, JSON.stringify(config, null, 2) + `
|
|
1609
1821
|
`, "utf-8");
|
|
1610
1822
|
}
|
|
1611
|
-
loadCollaboration() {
|
|
1612
|
-
try {
|
|
1613
|
-
return readFileSync2(this.collaborationPath, "utf-8");
|
|
1614
|
-
} catch {
|
|
1615
|
-
return null;
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
saveCollaboration(content) {
|
|
1619
|
-
this.ensureConfigDir();
|
|
1620
|
-
writeFileSync2(this.collaborationPath, content, "utf-8");
|
|
1621
|
-
}
|
|
1622
1823
|
initDefaults() {
|
|
1623
1824
|
this.ensureConfigDir();
|
|
1624
1825
|
const created = [];
|
|
@@ -1626,18 +1827,11 @@ class ConfigService {
|
|
|
1626
1827
|
this.save(DEFAULT_CONFIG);
|
|
1627
1828
|
created.push(this.configPath);
|
|
1628
1829
|
}
|
|
1629
|
-
if (!existsSync3(this.collaborationPath)) {
|
|
1630
|
-
this.saveCollaboration(DEFAULT_COLLABORATION_MD);
|
|
1631
|
-
created.push(this.collaborationPath);
|
|
1632
|
-
}
|
|
1633
1830
|
return created;
|
|
1634
1831
|
}
|
|
1635
1832
|
get configFilePath() {
|
|
1636
1833
|
return this.configPath;
|
|
1637
1834
|
}
|
|
1638
|
-
get collaborationFilePath() {
|
|
1639
|
-
return this.collaborationPath;
|
|
1640
|
-
}
|
|
1641
1835
|
ensureConfigDir() {
|
|
1642
1836
|
if (!existsSync3(this.configDir)) {
|
|
1643
1837
|
mkdirSync2(this.configDir, { recursive: true });
|
|
@@ -1653,8 +1847,8 @@ var stateDir = new StateDirResolver;
|
|
|
1653
1847
|
stateDir.ensure();
|
|
1654
1848
|
var configService = new ConfigService;
|
|
1655
1849
|
var config = configService.loadOrDefault();
|
|
1656
|
-
var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.
|
|
1657
|
-
var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.
|
|
1850
|
+
var CODEX_APP_PORT = parseInt(process.env.CODEX_WS_PORT ?? String(config.codex.appPort), 10);
|
|
1851
|
+
var CODEX_PROXY_PORT = parseInt(process.env.CODEX_PROXY_PORT ?? String(config.codex.proxyPort), 10);
|
|
1658
1852
|
var CONTROL_PORT = parseInt(process.env.AGENTBRIDGE_CONTROL_PORT ?? "4502", 10);
|
|
1659
1853
|
var TUI_DISCONNECT_GRACE_MS = parseInt(process.env.TUI_DISCONNECT_GRACE_MS ?? "2500", 10);
|
|
1660
1854
|
var CLAUDE_DISCONNECT_GRACE_MS = 5000;
|
|
@@ -1663,7 +1857,7 @@ var FILTER_MODE = process.env.AGENTBRIDGE_FILTER_MODE === "full" ? "full" : "fil
|
|
|
1663
1857
|
var IDLE_SHUTDOWN_MS = parseInt(process.env.AGENTBRIDGE_IDLE_SHUTDOWN_MS ?? String(config.idleShutdownSeconds * 1000), 10);
|
|
1664
1858
|
var ATTENTION_WINDOW_MS = parseInt(process.env.AGENTBRIDGE_ATTENTION_WINDOW_MS ?? String(config.turnCoordination.attentionWindowSeconds * 1000), 10);
|
|
1665
1859
|
var daemonLifecycle = new DaemonLifecycle({ stateDir, controlPort: CONTROL_PORT, log });
|
|
1666
|
-
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT);
|
|
1860
|
+
var codex = new CodexAdapter(CODEX_APP_PORT, CODEX_PROXY_PORT, stateDir.logFile);
|
|
1667
1861
|
var attachCmd = `codex --enable tui_app_server --remote ${codex.proxyUrl}`;
|
|
1668
1862
|
var controlServer = null;
|
|
1669
1863
|
var attachedClaude = null;
|
|
@@ -1679,6 +1873,7 @@ var idleShutdownTimer = null;
|
|
|
1679
1873
|
var claudeDisconnectTimer = null;
|
|
1680
1874
|
var claudeOnlineNoticeSent = false;
|
|
1681
1875
|
var claudeOfflineNoticeShown = false;
|
|
1876
|
+
var codexCollaborationKickoffSent = false;
|
|
1682
1877
|
var lastAttachStatusSentTs = 0;
|
|
1683
1878
|
var ATTACH_STATUS_COOLDOWN_MS = 30000;
|
|
1684
1879
|
var bufferedMessages = [];
|
|
@@ -1745,6 +1940,9 @@ codex.on("turnCompleted", () => {
|
|
|
1745
1940
|
replyReceivedDuringTurn = false;
|
|
1746
1941
|
emitToClaude(systemMessage("system_turn_completed", "\u2705 Codex finished the current turn. You can reply now if needed."));
|
|
1747
1942
|
startAttentionWindow();
|
|
1943
|
+
if (attachedClaude && shouldNotifyCodexClaudeOnline()) {
|
|
1944
|
+
notifyCodexClaudeOnline();
|
|
1945
|
+
}
|
|
1748
1946
|
});
|
|
1749
1947
|
codex.on("ready", (threadId) => {
|
|
1750
1948
|
tuiConnectionState.markBridgeReady();
|
|
@@ -2077,9 +2275,23 @@ function currentReadyMessage() {
|
|
|
2077
2275
|
return `\u2705 Codex TUI connected (${codex.activeThreadId}). Bridge ready.`;
|
|
2078
2276
|
}
|
|
2079
2277
|
function notifyCodexClaudeOnline() {
|
|
2278
|
+
const message = !codexCollaborationKickoffSent ? [
|
|
2279
|
+
"\uD83E\uDD1D Claude Code has connected via AgentBridge.",
|
|
2280
|
+
"You are now in a multi-agent collaboration session.",
|
|
2281
|
+
"When you receive a complex task, propose a division of labor to Claude.",
|
|
2282
|
+
"Claude can send you messages \u2014 they will appear as injected user messages.",
|
|
2283
|
+
"Respond naturally and Claude will receive your output via AgentBridge."
|
|
2284
|
+
].join(`
|
|
2285
|
+
`) : "\u2705 AgentBridge connected to Claude Code.";
|
|
2286
|
+
const delivered = codex.injectMessage(message);
|
|
2287
|
+
if (!delivered) {
|
|
2288
|
+
log("Deferred Claude-online notice to Codex \u2014 will retry after current turn completes");
|
|
2289
|
+
return false;
|
|
2290
|
+
}
|
|
2080
2291
|
claudeOnlineNoticeSent = true;
|
|
2081
2292
|
claudeOfflineNoticeShown = false;
|
|
2082
|
-
|
|
2293
|
+
codexCollaborationKickoffSent = true;
|
|
2294
|
+
return true;
|
|
2083
2295
|
}
|
|
2084
2296
|
function shouldNotifyCodexClaudeOnline() {
|
|
2085
2297
|
return !claudeOnlineNoticeSent || claudeOfflineNoticeShown;
|