@staix/agent-hub 0.3.1 → 0.4.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/CHANGELOG.md +15 -0
- package/README.md +51 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +171 -26
- package/src/adapters/acp.ts +6 -2
- package/src/adapters/claude-channel.ts +32 -6
- package/src/adapters/codex-appserver.ts +37 -9
- package/src/cli/main.ts +117 -65
- package/src/hub/child-process.ts +44 -0
- package/src/hub/control-client.ts +56 -13
- package/src/hub/daemon.ts +106 -42
- package/src/hub/lifecycle.ts +181 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
package/src/hub/daemon.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { PROTOCOL, stateDirFor } from "./control-client.ts";
|
|
|
26
26
|
import { newEnvelope, parseMarker, USER, type Envelope, type PeerId } from "./envelope.ts";
|
|
27
27
|
import { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
|
|
28
28
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
29
|
+
import { VERSION } from "../version.ts";
|
|
29
30
|
import { projectChain, recallFor } from "../memory/recall.ts";
|
|
30
31
|
|
|
31
32
|
export interface HubConfig {
|
|
@@ -82,6 +83,8 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export interface DaemonOptions {
|
|
86
|
+
projectId?: string;
|
|
87
|
+
instanceId?: string;
|
|
85
88
|
cwd: string;
|
|
86
89
|
stateDir: string;
|
|
87
90
|
controlPort: number;
|
|
@@ -107,7 +110,13 @@ type Sock = ServerWebSocket<Client>;
|
|
|
107
110
|
/** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
|
|
108
111
|
class WsPeer extends BasePeer {
|
|
109
112
|
private sock: Sock | undefined;
|
|
113
|
+
private claimed: Sock | undefined;
|
|
114
|
+
/** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
|
|
115
|
+
claim(sock: Sock): void {
|
|
116
|
+
this.claimed = sock;
|
|
117
|
+
}
|
|
110
118
|
attach(sock: Sock): void {
|
|
119
|
+
if (sock !== this.claimed) return void sock.close(4000, "replaced"); // a newer session said hello meanwhile
|
|
111
120
|
this.sock?.close(4000, "replaced");
|
|
112
121
|
this.sock = sock;
|
|
113
122
|
this.setState("idle");
|
|
@@ -128,6 +137,11 @@ class WsPeer extends BasePeer {
|
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
export async function startDaemon(opts: DaemonOptions) {
|
|
140
|
+
const projectId = opts.projectId ?? randomUUID();
|
|
141
|
+
const instanceId = opts.instanceId ?? randomUUID();
|
|
142
|
+
const startupCleanup: (() => void)[] = [];
|
|
143
|
+
let ready = false;
|
|
144
|
+
try {
|
|
131
145
|
const config = opts.config ?? loadConfig(opts.cwd);
|
|
132
146
|
mkdirSync(opts.stateDir, { recursive: true });
|
|
133
147
|
const logFile = join(opts.stateDir, "hub.log");
|
|
@@ -137,6 +151,33 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
137
151
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
138
152
|
const token = randomBytes(24).toString("hex");
|
|
139
153
|
|
|
154
|
+
const server = Bun.serve<Client>({
|
|
155
|
+
hostname: "127.0.0.1",
|
|
156
|
+
port: opts.controlPort,
|
|
157
|
+
fetch(req, srv) {
|
|
158
|
+
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
159
|
+
if (new URL(req.url).pathname === "/healthz") return new Response("ok");
|
|
160
|
+
return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
|
|
161
|
+
},
|
|
162
|
+
websocket: {
|
|
163
|
+
message(sock, data) {
|
|
164
|
+
try {
|
|
165
|
+
onMessage(sock, JSON.parse(String(data)));
|
|
166
|
+
} catch (e) {
|
|
167
|
+
log(`bad control message: ${(e as Error).message}`);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
close(sock) {
|
|
171
|
+
consoles.delete(sock);
|
|
172
|
+
sock.data.tail?.();
|
|
173
|
+
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
174
|
+
if (peer instanceof WsPeer) peer.detach(sock);
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
startupCleanup.push(() => server.stop(true));
|
|
179
|
+
|
|
180
|
+
|
|
140
181
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
141
182
|
let inference: Inference | undefined;
|
|
142
183
|
const bus = new Bus({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
|
|
@@ -174,6 +215,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
174
215
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "notice", line }));
|
|
175
216
|
};
|
|
176
217
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
218
|
+
startupCleanup.push(() => board.close());
|
|
177
219
|
const tasks = new Tasks({
|
|
178
220
|
board,
|
|
179
221
|
bus,
|
|
@@ -229,6 +271,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
229
271
|
notify: (line) => notify(line),
|
|
230
272
|
});
|
|
231
273
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
274
|
+
startupCleanup.push(() => budget.close());
|
|
232
275
|
let kimiSessionTotal = 0;
|
|
233
276
|
/** `total` is the session's running count: only what was added since the last update goes into the rolling window. */
|
|
234
277
|
const onKimiTokens = (total: number) => {
|
|
@@ -261,6 +304,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
261
304
|
}, 5_000),
|
|
262
305
|
];
|
|
263
306
|
for (const i of intervals) i.unref?.();
|
|
307
|
+
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
264
308
|
|
|
265
309
|
/** What the console stream and the log may show: a private envelope (PII task) keeps its body to its recipients. */
|
|
266
310
|
const redact = (e: BusEvent): BusEvent => {
|
|
@@ -271,7 +315,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
271
315
|
return { ...e, env: { ...env, ...(task ? { refs: { task } } : {}), body: `[private${task ? `: task #${task}, see ahub task show ${task}` : ""}]` } };
|
|
272
316
|
};
|
|
273
317
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
274
|
-
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir });
|
|
318
|
+
const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
|
|
275
319
|
|
|
276
320
|
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
277
321
|
async function taskOp(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
@@ -327,6 +371,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
327
371
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
|
|
328
372
|
};
|
|
329
373
|
const status = () => ({
|
|
374
|
+
projectId,
|
|
375
|
+
instanceId,
|
|
376
|
+
version: VERSION,
|
|
377
|
+
protocol: PROTOCOL,
|
|
378
|
+
stopping,
|
|
330
379
|
pid: process.pid,
|
|
331
380
|
cwd: opts.cwd,
|
|
332
381
|
controlPort: server.port,
|
|
@@ -340,16 +389,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
340
389
|
});
|
|
341
390
|
const writeStatus = () => {
|
|
342
391
|
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
343
|
-
writeFileSync(`${file}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
344
|
-
renameSync(`${file}.tmp`, file);
|
|
392
|
+
writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
393
|
+
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
345
394
|
};
|
|
346
395
|
|
|
347
396
|
bus.tap((e) => {
|
|
348
397
|
e = redact(e);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (uiEvents.length > 200) uiEvents.shift();
|
|
352
|
-
}
|
|
398
|
+
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
399
|
+
if (uiEvents.length > 200) uiEvents.shift();
|
|
353
400
|
if (e.t === "state") log(`state ${e.peer} -> ${e.state}`);
|
|
354
401
|
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
355
402
|
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
@@ -358,6 +405,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
358
405
|
});
|
|
359
406
|
|
|
360
407
|
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
408
|
+
if (stopping) return undefined;
|
|
361
409
|
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
362
410
|
const id = randomUUID().slice(0, 8);
|
|
363
411
|
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
@@ -382,6 +430,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
382
430
|
// One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
|
|
383
431
|
const starting = new Map<string, Promise<Record<string, unknown>>>();
|
|
384
432
|
function startPeer(peer: string, args: { model?: string; route?: string }): Promise<Record<string, unknown>> {
|
|
433
|
+
if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
|
|
385
434
|
const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
|
|
386
435
|
starting.set(peer, running);
|
|
387
436
|
return running;
|
|
@@ -444,7 +493,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
444
493
|
// The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
|
|
445
494
|
const routingKey = JSON.stringify([routing.targets, routing.routes]);
|
|
446
495
|
if (sidecar && routingKey !== sidecarRouting) {
|
|
447
|
-
sidecar.stop();
|
|
496
|
+
await sidecar.stop();
|
|
448
497
|
sidecar = undefined;
|
|
449
498
|
}
|
|
450
499
|
if (route && opts.switchyardPort) {
|
|
@@ -501,6 +550,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
501
550
|
function uiSnapshot(after: number) {
|
|
502
551
|
return {
|
|
503
552
|
ok: true,
|
|
553
|
+
projectId,
|
|
554
|
+
instanceId,
|
|
504
555
|
status: { peers: status().peers },
|
|
505
556
|
tasks: board.list().map((task) => {
|
|
506
557
|
const view = tasks.publicView(task);
|
|
@@ -562,7 +613,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
562
613
|
|
|
563
614
|
function onMessage(sock: Sock, msg: any): void {
|
|
564
615
|
const c = sock.data;
|
|
565
|
-
const reply = (body: Record<string, unknown>) => sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
616
|
+
const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
|
|
566
617
|
if (!c.authed) {
|
|
567
618
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
568
619
|
if (msg.v !== PROTOCOL) {
|
|
@@ -570,8 +621,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
570
621
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
571
622
|
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
572
623
|
}
|
|
624
|
+
if ((msg.projectId && msg.projectId !== projectId) || (msg.instanceId && msg.instanceId !== instanceId) ||
|
|
625
|
+
(msg.projectRoot && msg.projectRoot !== opts.cwd)) return sock.close(4404, "project or instance mismatch");
|
|
626
|
+
if (!["peer", "tools", "console"].includes(msg.role)) return sock.close(4403, "invalid client role");
|
|
627
|
+
if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
|
|
573
628
|
c.authed = true;
|
|
574
|
-
c.role = msg.role
|
|
629
|
+
c.role = msg.role;
|
|
575
630
|
if (c.role === "console") consoles.add(sock);
|
|
576
631
|
else if (c.role === "tools") {
|
|
577
632
|
// Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
|
|
@@ -584,12 +639,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
584
639
|
if (!peer) bus.add((peer = new WsPeer(c.peer)));
|
|
585
640
|
if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
|
|
586
641
|
const ws = peer;
|
|
642
|
+
ws.claim(sock);
|
|
587
643
|
void ensurePreface(c.peer).finally(() => {
|
|
588
644
|
if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
|
|
589
645
|
});
|
|
590
646
|
}
|
|
591
|
-
return void reply({ t: "welcome" });
|
|
647
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
|
|
592
648
|
}
|
|
649
|
+
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
593
650
|
switch (msg.t) {
|
|
594
651
|
case "send": {
|
|
595
652
|
// A human at the console should not wait out the batch window; agents default to status.
|
|
@@ -618,6 +675,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
618
675
|
}
|
|
619
676
|
case "status":
|
|
620
677
|
return void reply({ t: "status", status: status() });
|
|
678
|
+
case "ui_snapshot":
|
|
679
|
+
if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
|
|
680
|
+
if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
|
|
681
|
+
return void reply(uiSnapshot(msg.after ?? 0));
|
|
682
|
+
case "ui_action":
|
|
683
|
+
if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
|
|
684
|
+
if (msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before acting" });
|
|
685
|
+
if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
|
|
686
|
+
void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
|
|
687
|
+
return;
|
|
621
688
|
case "start":
|
|
622
689
|
if (c.role !== "console") return;
|
|
623
690
|
startPeer(String(msg.peer), msg.args ?? {})
|
|
@@ -687,7 +754,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
687
754
|
if (c.role === "console") permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined);
|
|
688
755
|
return;
|
|
689
756
|
case "kill":
|
|
690
|
-
if (c.role
|
|
757
|
+
if (c.role !== "console") return void reply({ ok: false, error: "kill is a console command" });
|
|
758
|
+
if (msg.instanceId !== undefined && msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before stopping" });
|
|
759
|
+
reply({ t: "stopping", ok: true, instanceId });
|
|
760
|
+
// Let the acknowledgement flush before closing the control listener.
|
|
761
|
+
setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
|
|
691
762
|
return;
|
|
692
763
|
default:
|
|
693
764
|
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
@@ -695,45 +766,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
695
766
|
}
|
|
696
767
|
}
|
|
697
768
|
|
|
698
|
-
const server = Bun.serve<Client>({
|
|
699
|
-
hostname: "127.0.0.1",
|
|
700
|
-
port: opts.controlPort,
|
|
701
|
-
fetch(req, srv) {
|
|
702
|
-
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
703
|
-
if (new URL(req.url).pathname === "/healthz") return new Response("ok");
|
|
704
|
-
return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
|
|
705
|
-
},
|
|
706
|
-
websocket: {
|
|
707
|
-
message(sock, data) {
|
|
708
|
-
try {
|
|
709
|
-
onMessage(sock, JSON.parse(String(data)));
|
|
710
|
-
} catch (e) {
|
|
711
|
-
log(`bad control message: ${(e as Error).message}`);
|
|
712
|
-
}
|
|
713
|
-
},
|
|
714
|
-
close(sock) {
|
|
715
|
-
consoles.delete(sock);
|
|
716
|
-
sock.data.tail?.();
|
|
717
|
-
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
718
|
-
if (peer instanceof WsPeer) peer.detach(sock);
|
|
719
|
-
},
|
|
720
|
-
},
|
|
721
|
-
});
|
|
722
769
|
|
|
723
|
-
|
|
724
|
-
|
|
770
|
+
let shutdown: Promise<void> | undefined;
|
|
771
|
+
function stop(): Promise<void> {
|
|
772
|
+
if (shutdown) return shutdown;
|
|
773
|
+
shutdown = stopOnce().catch((error) => { shutdown = undefined; throw error; });
|
|
774
|
+
return shutdown;
|
|
775
|
+
}
|
|
776
|
+
async function stopOnce(): Promise<void> {
|
|
725
777
|
stopping = true;
|
|
726
778
|
log("hub stopping");
|
|
779
|
+
writeStatus();
|
|
727
780
|
dashboard?.stop();
|
|
728
|
-
for (const p of permissions.values()) p.done(undefined);
|
|
729
|
-
await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()));
|
|
730
|
-
sidecar?.stop();
|
|
731
781
|
for (const i of intervals) clearInterval(i);
|
|
732
782
|
for (const done of checkpointWaits.values()) done(undefined);
|
|
783
|
+
for (const p of permissions.values()) p.done(undefined);
|
|
784
|
+
// A peer can still be inside memory recall or its native handshake when kill arrives.
|
|
785
|
+
// Drain those starts before taking the final owned-process snapshot.
|
|
786
|
+
await Promise.allSettled([...starting.values()]);
|
|
787
|
+
const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()).concat(sidecar ? [sidecar.stop()] : []));
|
|
788
|
+
const failed = exits.find((r) => r.status === "rejected");
|
|
789
|
+
if (failed?.status === "rejected") throw failed.reason;
|
|
733
790
|
budget.close();
|
|
734
791
|
board.close();
|
|
735
792
|
server.stop(true);
|
|
736
|
-
|
|
793
|
+
try {
|
|
794
|
+
const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
|
|
795
|
+
if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
|
|
796
|
+
} catch { /* another owner or no published state: never remove it */ }
|
|
737
797
|
onStop?.();
|
|
738
798
|
}
|
|
739
799
|
let onStop: (() => void) | undefined;
|
|
@@ -745,5 +805,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
745
805
|
writeFileSync(join(opts.stateDir, "hub.pid"), `${process.pid}\n`);
|
|
746
806
|
writeStatus();
|
|
747
807
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
808
|
+
ready = true;
|
|
748
809
|
return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
|
|
810
|
+
} finally {
|
|
811
|
+
if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
|
|
812
|
+
}
|
|
749
813
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createServer, type Server } from "node:net";
|
|
6
|
+
import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
7
|
+
import { startDaemon } from "./daemon.ts";
|
|
8
|
+
import { Registry, type Project } from "./registry.ts";
|
|
9
|
+
import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
|
|
10
|
+
|
|
11
|
+
export type ProjectInspection = {
|
|
12
|
+
state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
|
|
13
|
+
status?: any;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function processAlive(pid: unknown): boolean | undefined {
|
|
18
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
19
|
+
try { process.kill(pid, 0); return true; }
|
|
20
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A port or PID alone is never evidence that the selected project's hub is alive. */
|
|
24
|
+
export async function inspectProject(project: Project): Promise<ProjectInspection> {
|
|
25
|
+
if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
|
|
26
|
+
const control = readControl(project.stateDir);
|
|
27
|
+
if (control) {
|
|
28
|
+
if (processAlive(control.pid) === false) return { state: "stopped" };
|
|
29
|
+
if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
|
|
30
|
+
return { state: "incompatible", error: `hub protocol ${control.protocol}; stop with its matching CLI before upgrading` };
|
|
31
|
+
}
|
|
32
|
+
if ((control.projectId && control.projectId !== project.id) || control.cwd !== project.root) {
|
|
33
|
+
return { state: "unavailable", error: "state directory belongs to a different project" };
|
|
34
|
+
}
|
|
35
|
+
let client: ControlClient | undefined;
|
|
36
|
+
try {
|
|
37
|
+
// Older manifests omit identity. Authenticate first, then require the full current contract.
|
|
38
|
+
client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root,
|
|
39
|
+
...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) });
|
|
40
|
+
const response = await client.request({ t: "status" }, 3000);
|
|
41
|
+
if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
|
|
42
|
+
return { state: "unavailable", error: "authenticated hub identity does not match registration" };
|
|
43
|
+
}
|
|
44
|
+
if (project.instanceId && project.instanceId !== response.status.instanceId && processAlive(project.pid) !== false) {
|
|
45
|
+
return { state: "unavailable", error: "daemon instance differs from the registry claim" };
|
|
46
|
+
}
|
|
47
|
+
return response.status.stopping
|
|
48
|
+
? { state: "stopping", status: response.status, error: "hub shutdown is pending; wait before starting it again" }
|
|
49
|
+
: { state: "running", status: response.status };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return { state: (error as { code?: number }).code === 4426 ? "incompatible" : "unavailable", error: (error as Error).message };
|
|
52
|
+
} finally { client?.close(); }
|
|
53
|
+
}
|
|
54
|
+
if (project.instanceId && processAlive(project.pid) !== false) return { state: "starting", error: "daemon owns a startup or shutdown claim" };
|
|
55
|
+
try {
|
|
56
|
+
const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
|
|
57
|
+
if (processAlive(pid) !== false) return { state: "unavailable", error: "state has a live or uncertain owner but no usable control connection" };
|
|
58
|
+
} catch { /* no legacy PID file */ }
|
|
59
|
+
return { state: "stopped" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Probe the full stride's used ports before publishing a daemon; close only our own probes. */
|
|
63
|
+
async function available(base: number): Promise<boolean> {
|
|
64
|
+
const servers: Server[] = [];
|
|
65
|
+
try {
|
|
66
|
+
for (const offset of [CONTROL, CODEX_APP, CODEX_PROXY, SWITCHYARD]) {
|
|
67
|
+
const server = createServer();
|
|
68
|
+
servers.push(server);
|
|
69
|
+
await new Promise<void>((resolve, reject) => {
|
|
70
|
+
server.once("error", reject);
|
|
71
|
+
server.listen(base + offset, "127.0.0.1", () => resolve());
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") return false;
|
|
77
|
+
throw error;
|
|
78
|
+
} finally {
|
|
79
|
+
await Promise.all(servers.map((server) => new Promise<void>((resolve) => {
|
|
80
|
+
if (!server.listening) return resolve();
|
|
81
|
+
server.close(() => resolve());
|
|
82
|
+
})));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Called only in the detached daemon process. A competing starter exits without touching state. */
|
|
87
|
+
export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
|
|
88
|
+
const registry = new Registry();
|
|
89
|
+
const instanceId = randomUUID();
|
|
90
|
+
let claimed = false;
|
|
91
|
+
let daemon: Awaited<ReturnType<typeof startDaemon>> | undefined;
|
|
92
|
+
try {
|
|
93
|
+
const before = await inspectProject(registry.get(project.id) ?? project);
|
|
94
|
+
if (before.state === "running" || before.state === "starting") return;
|
|
95
|
+
if (before.state !== "stopped") throw new Error(before.error ?? before.state);
|
|
96
|
+
claimed = registry.claim(project.id, instanceId, process.pid);
|
|
97
|
+
if (!claimed) return;
|
|
98
|
+
let base = registry.get(project.id)!.basePort;
|
|
99
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
100
|
+
if (await available(base)) {
|
|
101
|
+
try {
|
|
102
|
+
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
103
|
+
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
104
|
+
switchyardPort: base + SWITCHYARD, unattended });
|
|
105
|
+
break;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
base = registry.reallocate(project.id, instanceId);
|
|
111
|
+
}
|
|
112
|
+
if (!daemon) throw new Error("no free hub port range after 100 attempts");
|
|
113
|
+
const stop = () => { void daemon!.stop().catch((error) => console.error(`shutdown incomplete: ${(error as Error).message}`)); };
|
|
114
|
+
process.on("SIGINT", stop);
|
|
115
|
+
process.on("SIGTERM", stop);
|
|
116
|
+
try { await daemon.stopped; }
|
|
117
|
+
finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); }
|
|
118
|
+
} finally {
|
|
119
|
+
if (claimed) registry.release(project.id, instanceId);
|
|
120
|
+
registry.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
|
|
125
|
+
const registry = new Registry();
|
|
126
|
+
try {
|
|
127
|
+
const current = registry.get(project.id);
|
|
128
|
+
if (!current || current.root !== project.root || current.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
129
|
+
const before = await inspectProject(current);
|
|
130
|
+
if (before.state === "running") return before.status;
|
|
131
|
+
if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
|
|
132
|
+
if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
|
|
133
|
+
if (before.state === "stopped") {
|
|
134
|
+
mkdirSync(project.stateDir, { recursive: true });
|
|
135
|
+
const marker = join(project.stateDir, "project.json");
|
|
136
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
137
|
+
writeFileSync(temporary, JSON.stringify({ root: project.root, projectId: project.id }), { mode: 0o600 });
|
|
138
|
+
renameSync(temporary, marker);
|
|
139
|
+
const log = openSync(join(project.stateDir, "hub.log"), "a");
|
|
140
|
+
try {
|
|
141
|
+
const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
|
|
142
|
+
...(options.unattended ? ["--unattended"] : [])], {
|
|
143
|
+
cwd: project.root, detached: true, stdio: ["ignore", log, log],
|
|
144
|
+
env: { ...(options.env ?? process.env), AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
|
145
|
+
AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
|
|
146
|
+
});
|
|
147
|
+
child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
|
|
148
|
+
child.unref();
|
|
149
|
+
} finally { closeSync(log); }
|
|
150
|
+
}
|
|
151
|
+
const deadline = Date.now() + 12_000;
|
|
152
|
+
while (Date.now() < deadline) {
|
|
153
|
+
const observed = await inspectProject(registry.get(project.id) ?? project);
|
|
154
|
+
if (observed.state === "running") return observed.status;
|
|
155
|
+
if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
|
|
156
|
+
await Bun.sleep(100);
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`hub readiness not confirmed; inspect ${join(project.stateDir, "hub.log")} before retrying`);
|
|
159
|
+
} finally { registry.close(); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
|
|
163
|
+
const inspection = await inspectProject(project);
|
|
164
|
+
if (inspection.state === "stopped") return;
|
|
165
|
+
if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
|
|
166
|
+
const instanceId = inspection.status.instanceId as string;
|
|
167
|
+
if (expectedInstance && expectedInstance !== instanceId) throw new Error("hub restarted; refresh before stopping");
|
|
168
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId });
|
|
169
|
+
try {
|
|
170
|
+
const result = await client.request({ t: "kill", instanceId }, 3000);
|
|
171
|
+
if (!result.ok) throw new Error(result.error ?? "hub did not acknowledge shutdown");
|
|
172
|
+
} finally { client.close(); }
|
|
173
|
+
const deadline = Date.now() + 10_000;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
const current = readControl(project.stateDir);
|
|
176
|
+
if (!current) return; // daemon removes its manifest only after owned children stop
|
|
177
|
+
if (current.instanceId !== instanceId) throw new Error("a new hub instance started; it was not stopped");
|
|
178
|
+
await Bun.sleep(100);
|
|
179
|
+
}
|
|
180
|
+
throw new Error("shutdown is still pending; state and ownership were retained");
|
|
181
|
+
}
|