@staix/agent-hub 0.3.2 → 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 +9 -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 +148 -20
- package/src/adapters/acp.ts +6 -2
- package/src/adapters/claude-channel.ts +9 -3
- 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 +50 -9
- package/src/hub/daemon.ts +99 -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;
|
|
@@ -134,6 +137,11 @@ class WsPeer extends BasePeer {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
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 {
|
|
137
145
|
const config = opts.config ?? loadConfig(opts.cwd);
|
|
138
146
|
mkdirSync(opts.stateDir, { recursive: true });
|
|
139
147
|
const logFile = join(opts.stateDir, "hub.log");
|
|
@@ -143,6 +151,33 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
143
151
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
144
152
|
const token = randomBytes(24).toString("hex");
|
|
145
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
|
+
|
|
146
181
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
147
182
|
let inference: Inference | undefined;
|
|
148
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) });
|
|
@@ -180,6 +215,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
180
215
|
for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "notice", line }));
|
|
181
216
|
};
|
|
182
217
|
const board = new Board(join(opts.stateDir, "hub.db"));
|
|
218
|
+
startupCleanup.push(() => board.close());
|
|
183
219
|
const tasks = new Tasks({
|
|
184
220
|
board,
|
|
185
221
|
bus,
|
|
@@ -235,6 +271,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
235
271
|
notify: (line) => notify(line),
|
|
236
272
|
});
|
|
237
273
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
274
|
+
startupCleanup.push(() => budget.close());
|
|
238
275
|
let kimiSessionTotal = 0;
|
|
239
276
|
/** `total` is the session's running count: only what was added since the last update goes into the rolling window. */
|
|
240
277
|
const onKimiTokens = (total: number) => {
|
|
@@ -267,6 +304,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
267
304
|
}, 5_000),
|
|
268
305
|
];
|
|
269
306
|
for (const i of intervals) i.unref?.();
|
|
307
|
+
startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
|
|
270
308
|
|
|
271
309
|
/** What the console stream and the log may show: a private envelope (PII task) keeps its body to its recipients. */
|
|
272
310
|
const redact = (e: BusEvent): BusEvent => {
|
|
@@ -277,7 +315,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
277
315
|
return { ...e, env: { ...env, ...(task ? { refs: { task } } : {}), body: `[private${task ? `: task #${task}, see ahub task show ${task}` : ""}]` } };
|
|
278
316
|
};
|
|
279
317
|
const SERVER_JS = join(import.meta.dir, "..", "..", "plugins", "agent-hub", "server.js");
|
|
280
|
-
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 });
|
|
281
319
|
|
|
282
320
|
/** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
|
|
283
321
|
async function taskOp(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
@@ -333,6 +371,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
333
371
|
return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
|
|
334
372
|
};
|
|
335
373
|
const status = () => ({
|
|
374
|
+
projectId,
|
|
375
|
+
instanceId,
|
|
376
|
+
version: VERSION,
|
|
377
|
+
protocol: PROTOCOL,
|
|
378
|
+
stopping,
|
|
336
379
|
pid: process.pid,
|
|
337
380
|
cwd: opts.cwd,
|
|
338
381
|
controlPort: server.port,
|
|
@@ -346,16 +389,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
346
389
|
});
|
|
347
390
|
const writeStatus = () => {
|
|
348
391
|
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
349
|
-
writeFileSync(`${file}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
350
|
-
renameSync(`${file}.tmp`, file);
|
|
392
|
+
writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
|
|
393
|
+
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
351
394
|
};
|
|
352
395
|
|
|
353
396
|
bus.tap((e) => {
|
|
354
397
|
e = redact(e);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (uiEvents.length > 200) uiEvents.shift();
|
|
358
|
-
}
|
|
398
|
+
uiEvents.push({ seq: ++uiSequence, event: e });
|
|
399
|
+
if (uiEvents.length > 200) uiEvents.shift();
|
|
359
400
|
if (e.t === "state") log(`state ${e.peer} -> ${e.state}`);
|
|
360
401
|
else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
|
|
361
402
|
else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
@@ -364,6 +405,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
364
405
|
});
|
|
365
406
|
|
|
366
407
|
async function onPermission(req: PermissionRequest): Promise<string | undefined> {
|
|
408
|
+
if (stopping) return undefined;
|
|
367
409
|
if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
|
|
368
410
|
const id = randomUUID().slice(0, 8);
|
|
369
411
|
// The title is written by an agent and read by the person approving it: escape sequences and carriage returns
|
|
@@ -388,6 +430,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
388
430
|
// One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
|
|
389
431
|
const starting = new Map<string, Promise<Record<string, unknown>>>();
|
|
390
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" });
|
|
391
434
|
const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
|
|
392
435
|
starting.set(peer, running);
|
|
393
436
|
return running;
|
|
@@ -450,7 +493,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
450
493
|
// The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
|
|
451
494
|
const routingKey = JSON.stringify([routing.targets, routing.routes]);
|
|
452
495
|
if (sidecar && routingKey !== sidecarRouting) {
|
|
453
|
-
sidecar.stop();
|
|
496
|
+
await sidecar.stop();
|
|
454
497
|
sidecar = undefined;
|
|
455
498
|
}
|
|
456
499
|
if (route && opts.switchyardPort) {
|
|
@@ -507,6 +550,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
507
550
|
function uiSnapshot(after: number) {
|
|
508
551
|
return {
|
|
509
552
|
ok: true,
|
|
553
|
+
projectId,
|
|
554
|
+
instanceId,
|
|
510
555
|
status: { peers: status().peers },
|
|
511
556
|
tasks: board.list().map((task) => {
|
|
512
557
|
const view = tasks.publicView(task);
|
|
@@ -568,7 +613,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
568
613
|
|
|
569
614
|
function onMessage(sock: Sock, msg: any): void {
|
|
570
615
|
const c = sock.data;
|
|
571
|
-
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 })); };
|
|
572
617
|
if (!c.authed) {
|
|
573
618
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
574
619
|
if (msg.v !== PROTOCOL) {
|
|
@@ -576,8 +621,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
576
621
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
577
622
|
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
578
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");
|
|
579
628
|
c.authed = true;
|
|
580
|
-
c.role = msg.role
|
|
629
|
+
c.role = msg.role;
|
|
581
630
|
if (c.role === "console") consoles.add(sock);
|
|
582
631
|
else if (c.role === "tools") {
|
|
583
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.
|
|
@@ -595,8 +644,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
595
644
|
if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
|
|
596
645
|
});
|
|
597
646
|
}
|
|
598
|
-
return void reply({ t: "welcome" });
|
|
647
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
|
|
599
648
|
}
|
|
649
|
+
if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
|
|
600
650
|
switch (msg.t) {
|
|
601
651
|
case "send": {
|
|
602
652
|
// A human at the console should not wait out the batch window; agents default to status.
|
|
@@ -625,6 +675,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
625
675
|
}
|
|
626
676
|
case "status":
|
|
627
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;
|
|
628
688
|
case "start":
|
|
629
689
|
if (c.role !== "console") return;
|
|
630
690
|
startPeer(String(msg.peer), msg.args ?? {})
|
|
@@ -694,7 +754,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
694
754
|
if (c.role === "console") permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined);
|
|
695
755
|
return;
|
|
696
756
|
case "kill":
|
|
697
|
-
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);
|
|
698
762
|
return;
|
|
699
763
|
default:
|
|
700
764
|
// A newer CLI talking to an older hub must get an answer, not wait forever.
|
|
@@ -702,45 +766,34 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
702
766
|
}
|
|
703
767
|
}
|
|
704
768
|
|
|
705
|
-
const server = Bun.serve<Client>({
|
|
706
|
-
hostname: "127.0.0.1",
|
|
707
|
-
port: opts.controlPort,
|
|
708
|
-
fetch(req, srv) {
|
|
709
|
-
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
710
|
-
if (new URL(req.url).pathname === "/healthz") return new Response("ok");
|
|
711
|
-
return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
|
|
712
|
-
},
|
|
713
|
-
websocket: {
|
|
714
|
-
message(sock, data) {
|
|
715
|
-
try {
|
|
716
|
-
onMessage(sock, JSON.parse(String(data)));
|
|
717
|
-
} catch (e) {
|
|
718
|
-
log(`bad control message: ${(e as Error).message}`);
|
|
719
|
-
}
|
|
720
|
-
},
|
|
721
|
-
close(sock) {
|
|
722
|
-
consoles.delete(sock);
|
|
723
|
-
sock.data.tail?.();
|
|
724
|
-
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
725
|
-
if (peer instanceof WsPeer) peer.detach(sock);
|
|
726
|
-
},
|
|
727
|
-
},
|
|
728
|
-
});
|
|
729
769
|
|
|
730
|
-
|
|
731
|
-
|
|
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> {
|
|
732
777
|
stopping = true;
|
|
733
778
|
log("hub stopping");
|
|
779
|
+
writeStatus();
|
|
734
780
|
dashboard?.stop();
|
|
735
|
-
for (const p of permissions.values()) p.done(undefined);
|
|
736
|
-
await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()));
|
|
737
|
-
sidecar?.stop();
|
|
738
781
|
for (const i of intervals) clearInterval(i);
|
|
739
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;
|
|
740
790
|
budget.close();
|
|
741
791
|
board.close();
|
|
742
792
|
server.stop(true);
|
|
743
|
-
|
|
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 */ }
|
|
744
797
|
onStop?.();
|
|
745
798
|
}
|
|
746
799
|
let onStop: (() => void) | undefined;
|
|
@@ -752,5 +805,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
752
805
|
writeFileSync(join(opts.stateDir, "hub.pid"), `${process.pid}\n`);
|
|
753
806
|
writeStatus();
|
|
754
807
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
808
|
+
ready = true;
|
|
755
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
|
+
}
|
|
756
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
|
+
}
|