@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/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
- if (dashboard) {
356
- uiEvents.push({ seq: ++uiSequence, event: e });
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 === "peer" || msg.role === "tools" ? msg.role : "console";
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 === "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);
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
- async function stop(): Promise<void> {
731
- 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> {
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
- 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 */ }
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
+ }