@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/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
- if (dashboard) {
350
- uiEvents.push({ seq: ++uiSequence, event: e });
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 === "peer" || msg.role === "tools" ? msg.role : "console";
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 === "console") void stop();
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
- async function stop(): Promise<void> {
724
- if (stopping) return;
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
- for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
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
+ }