@staix/agent-hub 0.3.2 → 0.5.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
@@ -1,5 +1,5 @@
1
- import { randomBytes, randomUUID } from "node:crypto";
2
- import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { ServerWebSocket } from "bun";
5
5
  import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
@@ -26,7 +26,9 @@ 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";
31
+ import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
30
32
 
31
33
  export interface HubConfig {
32
34
  watchdog_ms: number;
@@ -82,6 +84,8 @@ export function loadConfig(cwd: string): HubConfig {
82
84
  }
83
85
 
84
86
  export interface DaemonOptions {
87
+ projectId?: string;
88
+ instanceId?: string;
85
89
  cwd: string;
86
90
  stateDir: string;
87
91
  controlPort: number;
@@ -131,9 +135,18 @@ class WsPeer extends BasePeer {
131
135
  async stop(): Promise<void> {
132
136
  this.sock?.close();
133
137
  }
138
+
139
+ recoveryMetadata(): Record<string, unknown> {
140
+ return { launch: { kind: "claude-channel", peer: this.id } };
141
+ }
134
142
  }
135
143
 
136
144
  export async function startDaemon(opts: DaemonOptions) {
145
+ const projectId = opts.projectId ?? randomUUID();
146
+ const instanceId = opts.instanceId ?? randomUUID();
147
+ const startupCleanup: (() => void)[] = [];
148
+ let ready = false;
149
+ try {
137
150
  const config = opts.config ?? loadConfig(opts.cwd);
138
151
  mkdirSync(opts.stateDir, { recursive: true });
139
152
  const logFile = join(opts.stateDir, "hub.log");
@@ -143,9 +156,73 @@ export async function startDaemon(opts: DaemonOptions) {
143
156
  // The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
144
157
  const token = randomBytes(24).toString("hex");
145
158
 
159
+ const server = Bun.serve<Client>({
160
+ hostname: "127.0.0.1",
161
+ port: opts.controlPort,
162
+ fetch(req, srv) {
163
+ if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
164
+ if (new URL(req.url).pathname === "/healthz") return new Response("ok");
165
+ return srv.upgrade(req, { data: { authed: false } }) ? undefined : new Response("agent-hub control");
166
+ },
167
+ websocket: {
168
+ message(sock, data) {
169
+ try {
170
+ onMessage(sock, JSON.parse(String(data)));
171
+ } catch (e) {
172
+ log(`bad control message: ${(e as Error).message}`);
173
+ }
174
+ },
175
+ close(sock) {
176
+ consoles.delete(sock);
177
+ sock.data.tail?.();
178
+ const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
179
+ if (peer instanceof WsPeer) peer.detach(sock);
180
+ },
181
+ },
182
+ });
183
+ startupCleanup.push(() => server.stop(true));
184
+
185
+
146
186
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
147
187
  let inference: Inference | undefined;
148
188
  const bus = new Bus({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs) });
189
+ const manualPaused = new Set<PeerId>(); // `ahub pause`: recovery never lifts these
190
+ let recoveryOperationId: string | undefined;
191
+ let recoveryPhase: RecoveryPhase | undefined;
192
+ let recoveryCommitted = false;
193
+ let recoveryPeerSnapshot: RestartPeerSnapshot[] | undefined;
194
+ let recoveryLeaseTimer: ReturnType<typeof setTimeout> | undefined;
195
+ const recoveryActive = () => !!recoveryOperationId && recoveryPhase !== "released";
196
+ const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
197
+ const restartFilePresent = existsSync(restartPath(opts.stateDir));
198
+ const restored = recoveryOperation
199
+ ? readRestartSnapshot(opts.stateDir, { projectRoot: opts.cwd, projectId, operationId: recoveryOperation })
200
+ : undefined;
201
+ if ((restartFilePresent && !restored) || (recoveryOperation && !restored)) throw new Error("restart state is unreadable, missing, or does not match this project and recovery operation");
202
+ if (restored) {
203
+ bus.restore(restored.bus);
204
+ for (const peer of restored.manualPaused) { manualPaused.add(peer); bus.pause(peer); }
205
+ recoveryOperationId = restored.operationId;
206
+ recoveryPhase = "restored";
207
+ recoveryPeerSnapshot = restored.peers;
208
+ bus.setRecoveryHold(true);
209
+ }
210
+ const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
211
+ const armRecoveryLease = () => {
212
+ clearTimeout(recoveryLeaseTimer);
213
+ recoveryLeaseTimer = setTimeout(() => {
214
+ if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
215
+ log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
216
+ recoveryOperationId = undefined;
217
+ recoveryPhase = undefined;
218
+ recoveryPeerSnapshot = undefined;
219
+ bus.setRecoveryHold(false);
220
+ budget.setRecoveryHold(false);
221
+ removeRestartSnapshot(opts.stateDir);
222
+ writeStatus();
223
+ }, 10 * 60_000);
224
+ recoveryLeaseTimer.unref?.();
225
+ };
149
226
  const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
150
227
  const chain = projectChain(opts.cwd);
151
228
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
@@ -180,6 +257,7 @@ export async function startDaemon(opts: DaemonOptions) {
180
257
  for (const c of consoles) if (c.data.tail) c.send(JSON.stringify({ t: "notice", line }));
181
258
  };
182
259
  const board = new Board(join(opts.stateDir, "hub.db"));
260
+ startupCleanup.push(() => board.close());
183
261
  const tasks = new Tasks({
184
262
  board,
185
263
  bus,
@@ -191,7 +269,6 @@ export async function startDaemon(opts: DaemonOptions) {
191
269
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
192
270
  });
193
271
  // ---- budget relay -------------------------------------------------------------------------------------------
194
- const manualPaused = new Set<PeerId>(); // `ahub pause`: the coordinator never lifts these
195
272
  const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
196
273
  const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
197
274
  const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
@@ -234,7 +311,9 @@ export async function startDaemon(opts: DaemonOptions) {
234
311
  },
235
312
  notify: (line) => notify(line),
236
313
  });
314
+ budget.setRecoveryHold(recoveryActive());
237
315
  const kimiTokens: { at: number; n: number }[] = [];
316
+ startupCleanup.push(() => budget.close());
238
317
  let kimiSessionTotal = 0;
239
318
  /** `total` is the session's running count: only what was added since the last update goes into the rolling window. */
240
319
  const onKimiTokens = (total: number) => {
@@ -267,6 +346,7 @@ export async function startDaemon(opts: DaemonOptions) {
267
346
  }, 5_000),
268
347
  ];
269
348
  for (const i of intervals) i.unref?.();
349
+ startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
270
350
 
271
351
  /** What the console stream and the log may show: a private envelope (PII task) keeps its body to its recipients. */
272
352
  const redact = (e: BusEvent): BusEvent => {
@@ -277,7 +357,7 @@ export async function startDaemon(opts: DaemonOptions) {
277
357
  return { ...e, env: { ...env, ...(task ? { refs: { task } } : {}), body: `[private${task ? `: task #${task}, see ahub task show ${task}` : ""}]` } };
278
358
  };
279
359
  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 });
360
+ const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
281
361
 
282
362
  /** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
283
363
  async function taskOp(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
@@ -325,6 +405,16 @@ export async function startDaemon(opts: DaemonOptions) {
325
405
  }
326
406
  throw new Error(`unknown task operation ${op}`);
327
407
  }
408
+ function recoveryTaskPreface(peer: PeerId): void {
409
+ if (recoveryPhase !== "restored") return;
410
+ const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !["approved"].includes(task.state));
411
+ if (!open.length) return;
412
+ const lines = open.map((task) => {
413
+ const view = tasks.publicView(task) as { title?: unknown; detail?: unknown; state?: unknown; owner?: unknown; reviewer?: unknown };
414
+ return `#${task.id} ${String(view.title ?? "[private]")} (${task.state}, owner ${task.owner ?? "none"}, reviewer ${task.reviewer ?? "none"})${view.detail && view.detail !== "[pii]" ? `\n${String(view.detail).slice(0, 1000)}` : ""}`;
415
+ });
416
+ bus.preface(peer, `Controlled restart restored your open task context. Check the board before acting:\n${lines.join("\n\n")}`);
417
+ }
328
418
  const permissions = new Map<string, { push: string; done: (optionId: string | undefined) => void }>();
329
419
  let stopping = false;
330
420
 
@@ -332,7 +422,51 @@ export async function startDaemon(opts: DaemonOptions) {
332
422
  const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
333
423
  return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
334
424
  };
425
+ const recoveryReady = () => {
426
+ if (!recoveryActive() || permissions.size !== 0 || starting.size !== 0 || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy")) return false;
427
+ if (!recoveryPeerSnapshot) return true;
428
+ const current = recoveryPeers();
429
+ return recoveryPeerSnapshot.every((saved) => {
430
+ const now = current[saved.id];
431
+ if (!now) return true; // a detached peer is checked by the coordinator before terminal close
432
+ if (saved.threadId && now.threadId && saved.threadId !== now.threadId) return false;
433
+ if (saved.sessionId && now.sessionId && saved.sessionId !== now.sessionId) return false;
434
+ return true;
435
+ });
436
+ };
437
+ const claudeSessionId = () => {
438
+ try {
439
+ const value = JSON.parse(readFileSync(join(opts.stateDir, "claude-session.json"), "utf8"));
440
+ if (value.instanceId !== instanceId) return undefined;
441
+ try {
442
+ const records = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
443
+ const current = Array.isArray(records) ? records.find((row) => row?.peer === "claude" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId) : undefined;
444
+ if (current?.launchId && value.launchId !== current.launchId) return undefined;
445
+ } catch { /* no managed terminal record: the instance fence is still enforced */ }
446
+ return typeof value.sessionId === "string" && value.sessionId ? value.sessionId : undefined;
447
+ } catch { return undefined; }
448
+ };
449
+ const recoveryPeers = (): Record<string, RestartPeerSnapshot> => Object.fromEntries([...bus.peers].map(([id, peer]) => {
450
+ const metadata = peer.recoveryMetadata?.() ?? {};
451
+ const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
452
+ if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
453
+ const sessionId = id === "claude" ? claudeSessionId() : metadata.sessionId;
454
+ if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
455
+ return [id, row];
456
+ }));
457
+ const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
458
+ const integrity = () => {
459
+ const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
460
+ const boardState = board.list().sort((a, b) => a.id - b.id);
461
+ const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
462
+ return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState) };
463
+ };
335
464
  const status = () => ({
465
+ projectId,
466
+ instanceId,
467
+ version: VERSION,
468
+ protocol: PROTOCOL,
469
+ stopping,
336
470
  pid: process.pid,
337
471
  cwd: opts.cwd,
338
472
  controlPort: server.port,
@@ -343,19 +477,18 @@ export async function startDaemon(opts: DaemonOptions) {
343
477
  ),
344
478
  ...(sidecar ? { switchyard: sidecar.status } : {}),
345
479
  tasks: board.counts(),
480
+ ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
346
481
  });
347
482
  const writeStatus = () => {
348
483
  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);
484
+ writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify(status(), null, 2)}\n`);
485
+ renameSync(`${file}.${instanceId}.tmp`, file);
351
486
  };
352
487
 
353
488
  bus.tap((e) => {
354
489
  e = redact(e);
355
- if (dashboard) {
356
- uiEvents.push({ seq: ++uiSequence, event: e });
357
- if (uiEvents.length > 200) uiEvents.shift();
358
- }
490
+ uiEvents.push({ seq: ++uiSequence, event: e });
491
+ if (uiEvents.length > 200) uiEvents.shift();
359
492
  if (e.t === "state") log(`state ${e.peer} -> ${e.state}`);
360
493
  else if (e.t === "undeliverable") log(`UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}`);
361
494
  else if (e.t === "overflow") log(`OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
@@ -364,6 +497,7 @@ export async function startDaemon(opts: DaemonOptions) {
364
497
  });
365
498
 
366
499
  async function onPermission(req: PermissionRequest): Promise<string | undefined> {
500
+ if (stopping) return undefined;
367
501
  if (opts.unattended) return req.options.find((o) => o.kind === "allow_once")?.optionId;
368
502
  const id = randomUUID().slice(0, 8);
369
503
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
@@ -388,6 +522,7 @@ export async function startDaemon(opts: DaemonOptions) {
388
522
  // One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
389
523
  const starting = new Map<string, Promise<Record<string, unknown>>>();
390
524
  function startPeer(peer: string, args: { model?: string; route?: string }): Promise<Record<string, unknown>> {
525
+ if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
391
526
  const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
392
527
  starting.set(peer, running);
393
528
  return running;
@@ -404,6 +539,7 @@ export async function startDaemon(opts: DaemonOptions) {
404
539
  const cmd = args.model ? [bin!, "--model", args.model, ...rest] : config.kimi_cmd;
405
540
  const kimi = new AcpPeer("kimi", {
406
541
  cmd,
542
+ ...(args.model ? { launchModel: args.model } : {}),
407
543
  cwd: opts.cwd,
408
544
  watchdogMs: config.watchdog_ms,
409
545
  onPermission,
@@ -412,6 +548,7 @@ export async function startDaemon(opts: DaemonOptions) {
412
548
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
413
549
  preamble: roleContract("kimi", config.roles),
414
550
  });
551
+ recoveryTaskPreface("kimi");
415
552
  await ensurePreface("kimi");
416
553
  bus.add(kimi);
417
554
  await kimi.start();
@@ -437,6 +574,7 @@ export async function startDaemon(opts: DaemonOptions) {
437
574
  watchdogMs: config.watchdog_ms,
438
575
  log,
439
576
  });
577
+ recoveryTaskPreface("codex");
440
578
  await ensurePreface("codex");
441
579
  bus.add(codex);
442
580
  await codex.start();
@@ -450,7 +588,7 @@ export async function startDaemon(opts: DaemonOptions) {
450
588
  // The sidecar serves the routes it was generated from: a changed routing.toml needs a new one.
451
589
  const routingKey = JSON.stringify([routing.targets, routing.routes]);
452
590
  if (sidecar && routingKey !== sidecarRouting) {
453
- sidecar.stop();
591
+ await sidecar.stop();
454
592
  sidecar = undefined;
455
593
  }
456
594
  if (route && opts.switchyardPort) {
@@ -483,6 +621,7 @@ export async function startDaemon(opts: DaemonOptions) {
483
621
  maxSteps: config.local.max_steps,
484
622
  log,
485
623
  });
624
+ recoveryTaskPreface("local");
486
625
  await ensurePreface("local");
487
626
  bus.add(local);
488
627
  await local.start();
@@ -507,6 +646,8 @@ export async function startDaemon(opts: DaemonOptions) {
507
646
  function uiSnapshot(after: number) {
508
647
  return {
509
648
  ok: true,
649
+ projectId,
650
+ instanceId,
510
651
  status: { peers: status().peers },
511
652
  tasks: board.list().map((task) => {
512
653
  const view = tasks.publicView(task);
@@ -566,9 +707,125 @@ export async function startDaemon(opts: DaemonOptions) {
566
707
  }
567
708
  }
568
709
 
710
+ const recoveryView = () => ({
711
+ operationId: recoveryOperationId,
712
+ phase: recoveryPhase,
713
+ ready: recoveryReady(),
714
+ pendingApprovals: permissions.size,
715
+ capabilities: { controlledRestart: true, snapshotSchemaVersion: 1, maxLeaseMs: 10 * 60_000 },
716
+ blockers: [
717
+ ...(starting.size ? ["peer startup in progress"] : []),
718
+ ...(!budget.recoverySettled ? ["budget transition in progress"] : []),
719
+ ...([...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => `${id} is busy`)),
720
+ ...(permissions.size ? ["pending approvals"] : []),
721
+ ],
722
+ integrity: { current: integrity(), ...(restored?.integrity ? { expected: restored.integrity } : {}) },
723
+ peers: Object.fromEntries([...(recoveryPeerSnapshot ?? []), ...Object.values(recoveryPeers()).filter((peer) => !(recoveryPeerSnapshot ?? []).some((saved) => saved.id === peer.id))].map((peer) => {
724
+ const now = recoveryPeers()[peer.id];
725
+ if (recoveryPhase === "restored" || recoveryPhase === "released") return [peer.id, now ?? { id: peer.id, state: "offline", queueIds: bus.queueIds(peer.id) }];
726
+ return [peer.id, now ? { ...peer, ...now, queueIds: now.queueIds } : peer];
727
+ })),
728
+ });
729
+ const recoveryError = (error: string) => ({ t: "recovery", ok: false, error });
730
+ async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
731
+ if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
732
+ if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
733
+ if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
734
+ if (typeof msg.operationId !== "string" || msg.operationId.length < 1 || msg.operationId.length > 128) return recoveryError("operationId is required");
735
+ const op = msg.operationId as string;
736
+ if (msg.op === "prepare") {
737
+ if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
738
+ if (recoveryPhase === "released" && recoveryOperationId !== op) {
739
+ recoveryOperationId = undefined;
740
+ recoveryPhase = undefined;
741
+ recoveryPeerSnapshot = undefined;
742
+ }
743
+ if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
744
+ recoveryOperationId = op;
745
+ if (!recoveryPhase) recoveryPhase = "preparing";
746
+ armRecoveryLease();
747
+ recoveryPeerSnapshot ??= Object.values(recoveryPeers());
748
+ budget.setRecoveryHold(true);
749
+ bus.setRecoveryHold(true);
750
+ await bus.fenceRecovery();
751
+ const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
752
+ if (!blocked.length && permissions.size === 0 && recoveryReady()) {
753
+ recoveryPeerSnapshot ??= Object.values(recoveryPeers());
754
+ recoveryPhase = "prepared";
755
+ }
756
+ writeStatus();
757
+ return { t: "recovery", ok: true, recovery: { ...recoveryView(), ...(blocked.length ? { blockedPeers: blocked } : {}), ...(permissions.size ? { blocked: "pending approvals" } : {}) } };
758
+ }
759
+ if (!recoveryOperationId || recoveryOperationId !== op) return msg.op === "inspect"
760
+ ? { t: "recovery", ok: true, recovery: recoveryView() }
761
+ : recoveryError("operation does not match this daemon");
762
+ if (msg.op === "inspect") return { t: "recovery", ok: true, recovery: recoveryView() };
763
+ if (msg.op === "abort") {
764
+ if (recoveryCommitted) return recoveryError("a committed recovery cannot be aborted");
765
+ if (recoveryPhase === "released") return { t: "recovery", ok: true, recovery: recoveryView() };
766
+ recoveryOperationId = undefined;
767
+ recoveryPhase = undefined;
768
+ recoveryPeerSnapshot = undefined;
769
+ clearTimeout(recoveryLeaseTimer);
770
+ bus.setRecoveryHold(false);
771
+ budget.setRecoveryHold(false);
772
+ writeStatus();
773
+ return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
774
+ }
775
+ if (msg.op === "commit") {
776
+ if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle and approvals are complete");
777
+ recoveryPhase = "prepared";
778
+ recoveryPeerSnapshot ??= Object.values(recoveryPeers());
779
+ const currentPeers = recoveryPeers();
780
+ const peers = (recoveryPeerSnapshot ?? Object.values(currentPeers)).map((saved) => ({
781
+ ...saved,
782
+ queueIds: bus.queueIds(saved.id),
783
+ }));
784
+ const snapshot: RestartSnapshot = {
785
+ schemaVersion: 1,
786
+ projectRoot: opts.cwd,
787
+ projectId,
788
+ sourceInstanceId: instanceId,
789
+ operationId: op,
790
+ committedAt: Date.now(),
791
+ bus: bus.snapshot(),
792
+ manualPaused: [...manualPaused],
793
+ peers,
794
+ integrity: integrity(),
795
+ };
796
+ try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
797
+ recoveryCommitted = true;
798
+ clearTimeout(recoveryLeaseTimer);
799
+ writeStatus();
800
+ // Keep phase prepared in status until the replacement daemon reports restored.
801
+ setTimeout(() => void stop().catch((error) => log(`recovery shutdown incomplete: ${(error as Error).message}`)), 0);
802
+ return { t: "recovery", ok: true, committed: true, recovery: { ...recoveryView(), phase: "prepared" } };
803
+ }
804
+ if (msg.op === "release") {
805
+ if (recoveryPhase === "released") return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
806
+ if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
807
+ await bus.fenceRecovery();
808
+ const current = recoveryPeers();
809
+ const missing = (recoveryPeerSnapshot ?? []).filter((saved) => saved.state !== "offline" && !manualPaused.has(saved.id) && !budget.record(saved.id) && (!current[saved.id] || current[saved.id]!.state === "offline"));
810
+ if (missing.length) return recoveryError(`required peers are not attached: ${missing.map((peer) => peer.id).join(", ")}`);
811
+ if (!recoveryReady()) return recoveryError("required peers or approvals are not ready");
812
+ if (!restored?.integrity || JSON.stringify(restored.integrity) !== JSON.stringify(integrity())) {
813
+ return recoveryError("queue, pause, task board or budget integrity changed during recovery");
814
+ }
815
+ archiveRestartSnapshot(opts.stateDir, op);
816
+ recoveryPhase = "released";
817
+ clearTimeout(recoveryLeaseTimer);
818
+ bus.setRecoveryHold(false);
819
+ budget.setRecoveryHold(false);
820
+ writeStatus();
821
+ return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
822
+ }
823
+ return recoveryError("unknown recovery operation");
824
+ }
825
+
569
826
  function onMessage(sock: Sock, msg: any): void {
570
827
  const c = sock.data;
571
- const reply = (body: Record<string, unknown>) => sock.send(JSON.stringify({ rid: msg.rid, ...body }));
828
+ const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
572
829
  if (!c.authed) {
573
830
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
574
831
  if (msg.v !== PROTOCOL) {
@@ -576,8 +833,12 @@ export async function startDaemon(opts: DaemonOptions) {
576
833
  log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
577
834
  return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
578
835
  }
836
+ if ((msg.projectId && msg.projectId !== projectId) || (msg.instanceId && msg.instanceId !== instanceId) ||
837
+ (msg.projectRoot && msg.projectRoot !== opts.cwd)) return sock.close(4404, "project or instance mismatch");
838
+ if (!["peer", "tools", "console"].includes(msg.role)) return sock.close(4403, "invalid client role");
839
+ if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
579
840
  c.authed = true;
580
- c.role = msg.role === "peer" || msg.role === "tools" ? msg.role : "console";
841
+ c.role = msg.role;
581
842
  if (c.role === "console") consoles.add(sock);
582
843
  else if (c.role === "tools") {
583
844
  // Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
@@ -586,6 +847,7 @@ export async function startDaemon(opts: DaemonOptions) {
586
847
  } else {
587
848
  c.peer = String(msg.peer ?? "claude");
588
849
  if (!PEER_ID.test(c.peer) || RESERVED_IDS.has(c.peer)) return sock.close(4403, "peer id is reserved or malformed");
850
+ if (!recoveryPeerAllowed(c.peer)) return sock.close(4403, "peer id is not part of the recovery roster");
589
851
  let peer = bus.peers.get(c.peer);
590
852
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
591
853
  if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
@@ -595,10 +857,16 @@ export async function startDaemon(opts: DaemonOptions) {
595
857
  if (sock.readyState === WebSocket.OPEN) ws.attach(sock);
596
858
  });
597
859
  }
598
- return void reply({ t: "welcome" });
860
+ return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
599
861
  }
862
+ if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
600
863
  switch (msg.t) {
864
+ case "recovery":
865
+ if (c.role !== "console") return void reply(recoveryError("recovery is a console command"));
866
+ void recoveryOp(msg).then(reply, () => reply(recoveryError("recovery operation failed")));
867
+ return;
601
868
  case "send": {
869
+ if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
602
870
  // A human at the console should not wait out the batch window; agents default to status.
603
871
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
604
872
  if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
@@ -625,8 +893,21 @@ export async function startDaemon(opts: DaemonOptions) {
625
893
  }
626
894
  case "status":
627
895
  return void reply({ t: "status", status: status() });
896
+ case "ui_snapshot":
897
+ if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
898
+ if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
899
+ return void reply(uiSnapshot(msg.after ?? 0));
900
+ case "ui_action":
901
+ if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
902
+ if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
903
+ if (msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before acting" });
904
+ if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
905
+ void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
906
+ return;
628
907
  case "start":
629
908
  if (c.role !== "console") return;
909
+ if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
910
+ if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
630
911
  startPeer(String(msg.peer), msg.args ?? {})
631
912
  .catch((e: Error) => ({ ok: false, error: e.message }))
632
913
  .then((r) => {
@@ -635,6 +916,7 @@ export async function startDaemon(opts: DaemonOptions) {
635
916
  });
636
917
  return;
637
918
  case "task":
919
+ if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
638
920
  taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
639
921
  (text) => reply({ t: "task", ok: true, text }),
640
922
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
@@ -643,6 +925,7 @@ export async function startDaemon(opts: DaemonOptions) {
643
925
  case "pause":
644
926
  case "resume": {
645
927
  if (c.role !== "console") return;
928
+ if (recoveryActive()) return void reply({ t: msg.t, ok: false, error: "recovery is holding mutations" });
646
929
  return void reply({ t: msg.t, ...holdPeer(msg.t, String(msg.peer)) });
647
930
  }
648
931
  case "ask":
@@ -681,6 +964,7 @@ export async function startDaemon(opts: DaemonOptions) {
681
964
  return;
682
965
  case "budget":
683
966
  if (c.role !== "console") return;
967
+ if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
684
968
  if (msg.resume) {
685
969
  if (!budget.override(String(msg.resume))) return void reply({ t: "budget", ok: false, error: `${msg.resume} is not paused by the budget coordinator` });
686
970
  } else if (msg.set) {
@@ -694,7 +978,11 @@ export async function startDaemon(opts: DaemonOptions) {
694
978
  if (c.role === "console") permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined);
695
979
  return;
696
980
  case "kill":
697
- if (c.role === "console") void stop();
981
+ if (c.role !== "console") return void reply({ ok: false, error: "kill is a console command" });
982
+ if (msg.instanceId !== undefined && msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before stopping" });
983
+ reply({ t: "stopping", ok: true, instanceId });
984
+ // Let the acknowledgement flush before closing the control listener.
985
+ setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
698
986
  return;
699
987
  default:
700
988
  // A newer CLI talking to an older hub must get an answer, not wait forever.
@@ -702,45 +990,34 @@ export async function startDaemon(opts: DaemonOptions) {
702
990
  }
703
991
  }
704
992
 
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
993
 
730
- async function stop(): Promise<void> {
731
- if (stopping) return;
994
+ let shutdown: Promise<void> | undefined;
995
+ function stop(): Promise<void> {
996
+ if (shutdown) return shutdown;
997
+ shutdown = stopOnce().catch((error) => { shutdown = undefined; throw error; });
998
+ return shutdown;
999
+ }
1000
+ async function stopOnce(): Promise<void> {
732
1001
  stopping = true;
733
1002
  log("hub stopping");
1003
+ writeStatus();
734
1004
  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
1005
  for (const i of intervals) clearInterval(i);
739
1006
  for (const done of checkpointWaits.values()) done(undefined);
1007
+ for (const p of permissions.values()) p.done(undefined);
1008
+ // A peer can still be inside memory recall or its native handshake when kill arrives.
1009
+ // Drain those starts before taking the final owned-process snapshot.
1010
+ await Promise.allSettled([...starting.values()]);
1011
+ const exits = await Promise.allSettled([...bus.peers.values()].map((p) => p.stop()).concat(sidecar ? [sidecar.stop()] : []));
1012
+ const failed = exits.find((r) => r.status === "rejected");
1013
+ if (failed?.status === "rejected") throw failed.reason;
740
1014
  budget.close();
741
1015
  board.close();
742
1016
  server.stop(true);
743
- for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
1017
+ try {
1018
+ const current = JSON.parse(readFileSync(join(opts.stateDir, "status.json"), "utf8"));
1019
+ if (current.instanceId === instanceId) for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(opts.stateDir, f), { force: true });
1020
+ } catch { /* another owner or no published state: never remove it */ }
744
1021
  onStop?.();
745
1022
  }
746
1023
  let onStop: (() => void) | undefined;
@@ -752,5 +1029,9 @@ export async function startDaemon(opts: DaemonOptions) {
752
1029
  writeFileSync(join(opts.stateDir, "hub.pid"), `${process.pid}\n`);
753
1030
  writeStatus();
754
1031
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
1032
+ ready = true;
755
1033
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
1034
+ } finally {
1035
+ if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
1036
+ }
756
1037
  }