@staix/agent-hub 0.4.0 → 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/budget.ts CHANGED
@@ -76,6 +76,9 @@ export class Budget {
76
76
  /** `ahub budget resume`: the owner overrode a pause; readings over the gate are ignored for that peer until then. */
77
77
  private readonly ignoreUntil = new Map<PeerId, number>();
78
78
  private closed = false;
79
+ private recoveryHeld = false;
80
+ private deferred = false;
81
+ private readonly deferredHard = new Set<PeerId>();
79
82
 
80
83
  constructor(
81
84
  dbPath: string,
@@ -88,11 +91,33 @@ export class Budget {
88
91
  reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
89
92
  }
90
93
 
94
+ /** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
95
+ setRecoveryHold(held: boolean): void {
96
+ this.recoveryHeld = held;
97
+ if (!held && this.deferred && !this.closed) {
98
+ this.deferred = false;
99
+ this.tick();
100
+ for (const peer of this.readings.keys()) {
101
+ const hard = this.deferredHard.delete(peer);
102
+ void this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
103
+ }
104
+ }
105
+ }
106
+
107
+ get recoverySettled(): boolean {
108
+ return this.pausing.size === 0 && this.handingOff.size === 0;
109
+ }
110
+
91
111
  record(peer: PeerId): PauseRecord | undefined {
92
112
  const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
93
113
  return r ? { peer: r.peer, since: r.since, resetsAt: r.resets_at, reason: r.reason, summary: r.summary, handedOff: !!r.handed_off, moved: JSON.parse(r.moved) } : undefined;
94
114
  }
95
115
 
116
+ /** Recovery integrity input: persisted pause records only, excluding live usage readings and private summaries. */
117
+ persistedPauseDigestRows(): { peer: PeerId; since: number; resetsAt: number; reason: string }[] {
118
+ return this.records().map(({ peer, since, resetsAt, reason }) => ({ peer, since, resetsAt, reason }));
119
+ }
120
+
96
121
  private records(): PauseRecord[] {
97
122
  return (this.db.query("SELECT peer FROM budget_pauses").all() as { peer: string }[]).map((r) => this.record(r.peer)!);
98
123
  }
@@ -122,6 +147,7 @@ export class Budget {
122
147
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
123
148
  this.readings.set(peer, mine);
124
149
  for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
150
+ if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
125
151
  this.evaluate(peer, hard).catch((e: Error) => this.closed || this.hooks.notify(`budget: evaluating ${peer} failed: ${e.message}`));
126
152
  }
127
153
 
@@ -141,6 +167,7 @@ export class Budget {
141
167
  }
142
168
 
143
169
  private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
170
+ if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
144
171
  const fresh = this.fresh(peer);
145
172
  const over = fresh.filter((r) => r.used >= this.cfg.gate);
146
173
  const open = this.record(peer);
@@ -160,6 +187,7 @@ export class Budget {
160
187
  const reason = `${worst.id} window at ${Math.round(worst.used * 100)}% (${worst.source})`;
161
188
  // Checkpoint first, pause second: a paused peer receives nothing, and a turn in flight is never cut by the coordinator.
162
189
  const summary = hard ? undefined : await this.hooks.requestCheckpoint(peer).catch(() => undefined);
190
+ if (this.recoveryHeld) { this.deferred = true; return; }
163
191
  if (this.closed) return; // the hub is shutting down: nothing is recorded, the next run sees the reading again
164
192
  this.db.query("INSERT OR REPLACE INTO budget_pauses (peer, since, resets_at, reason, summary) VALUES (?, ?, ?, ?, ?)").run(peer, this.now(), resetsAt, reason, summary ?? null);
165
193
  this.hooks.pause(peer);
@@ -171,10 +199,12 @@ export class Budget {
171
199
  }
172
200
 
173
201
  private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
174
- if (this.closed || this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
202
+ if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
203
+ if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
175
204
  this.handingOff.add(peer);
176
205
  try {
177
206
  await this.handOff(peer, summary);
207
+ if (this.recoveryHeld) { this.deferred = true; return; }
178
208
  } finally {
179
209
  this.handingOff.delete(peer);
180
210
  }
@@ -202,6 +232,7 @@ export class Budget {
202
232
 
203
233
  /** Called on a timer. Resumes every peer whose window has reset. */
204
234
  tick(): void {
235
+ if (this.recoveryHeld) { this.deferred = true; return; }
205
236
  for (const r of this.records()) {
206
237
  if (this.now() >= r.resetsAt + 60_000) {
207
238
  if (this.hooks.attached(r.peer)) this.resume(r.peer, "the window has reset"); // else: wait, the notice needs somebody to receive it
package/src/hub/bus.ts CHANGED
@@ -17,6 +17,17 @@ export interface BusOptions {
17
17
  /** Optional: rewrite a delivery before it goes out (M6 digest condensation). Must return its input on any failure. */
18
18
  condense?: (envs: Envelope[]) => Promise<Envelope[]>;
19
19
  }
20
+
21
+ /** Serializable delivery state used by the controlled restart coordinator. Bodies stay in the private daemon file. */
22
+ export interface BusSnapshot {
23
+ schemaVersion: 1;
24
+ queues: Record<string, Envelope[]>;
25
+ prefaces: Record<string, Envelope>;
26
+ seen: Envelope[];
27
+ attempts: Record<string, number>;
28
+ withdrawn: string[];
29
+ }
30
+
20
31
  export const DEFAULT_BUS: BusOptions = { retryMs: 1000, batchMax: 3, batchMs: 15_000, queueCap: 200 };
21
32
 
22
33
  const SEEN_CAP = 2048;
@@ -42,6 +53,9 @@ export class Bus {
42
53
  /** What each peer was last handed, next to what it stands for: an adapter that reports a failure later hands back the former. */
43
54
  private readonly lastDelivery = new Map<PeerId, { out: Envelope[]; originals: Envelope[] }>();
44
55
  private readonly attempts = new Map<string, number>(); // `${peer}:${envelope id}` -> failed deliveries
56
+ private recoveryHeld = false;
57
+ private steering = 0;
58
+ private condensing = 0;
45
59
 
46
60
  constructor(opts: Partial<BusOptions> = {}) {
47
61
  this.opts = { ...DEFAULT_BUS, ...opts };
@@ -66,6 +80,57 @@ export class Bus {
66
80
  };
67
81
  }
68
82
 
83
+ /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
84
+ setRecoveryHold(held: boolean): void {
85
+ this.recoveryHeld = held;
86
+ if (!held) for (const id of this.peers.keys()) void this.drain(id);
87
+ }
88
+
89
+ get isRecoveryHeld(): boolean {
90
+ return this.recoveryHeld;
91
+ }
92
+
93
+ /** Wait until a hold has fenced condensation, steering and queue drains already in flight. */
94
+ async fenceRecovery(): Promise<void> {
95
+ this.recoveryHeld = true;
96
+ while (this.draining.size || this.steering || this.condensing) await Bun.sleep(0);
97
+ }
98
+
99
+ /** A bounded, JSON-safe representation of queued work and retry/dedupe state. */
100
+ snapshot(): BusSnapshot {
101
+ return {
102
+ schemaVersion: 1,
103
+ queues: Object.fromEntries([...this.queues].map(([id, queue]) => [id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) }))])),
104
+ prefaces: Object.fromEntries([...this.prefaces].map(([id, e]) => [id, { ...e, ...(e.to ? { to: [...e.to] } : {}) }])),
105
+ seen: [...this.seen.values()].map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })),
106
+ attempts: Object.fromEntries(this.attempts),
107
+ withdrawn: [...this.withdrawn],
108
+ };
109
+ }
110
+
111
+ /** Restore state before peers attach. Queues remain held until the coordinator calls setRecoveryHold(false). */
112
+ restore(snapshot: BusSnapshot): void {
113
+ if (snapshot.schemaVersion !== 1) throw new Error("unsupported bus recovery snapshot");
114
+ this.queues.clear();
115
+ for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
116
+ this.prefaces.clear();
117
+ for (const [id, e] of Object.entries(snapshot.prefaces ?? {})) this.prefaces.set(id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
118
+ this.seen.clear();
119
+ for (const e of snapshot.seen ?? []) this.seen.set(e.id, { ...e, ...(e.to ? { to: [...e.to] } : {}) });
120
+ this.attempts.clear();
121
+ for (const [key, n] of Object.entries(snapshot.attempts ?? {})) if (Number.isInteger(n) && n > 0) this.attempts.set(key, n);
122
+ this.withdrawn.clear();
123
+ for (const id of snapshot.withdrawn ?? []) this.withdrawn.add(id);
124
+ }
125
+
126
+ queueIds(id: PeerId): string[] {
127
+ return (this.queues.get(id) ?? []).map((e) => e.id);
128
+ }
129
+
130
+ attemptState(): Record<string, number> {
131
+ return Object.fromEntries(this.attempts);
132
+ }
133
+
69
134
  tap(fn: (e: BusEvent) => void): () => void {
70
135
  this.taps.add(fn);
71
136
  return () => this.taps.delete(fn);
@@ -90,6 +155,13 @@ export class Bus {
90
155
 
91
156
  /** Context the hub wants the peer to see once: it rides in front of the next delivery instead of costing a turn of its own. */
92
157
  preface(id: PeerId, body: string): void {
158
+ const existing = this.prefaces.get(id);
159
+ if (existing) {
160
+ // Recovery and session recall may both contribute context. Keep the original id/hop so a queued
161
+ // preface remains deduplicated and append the new, separately generated context.
162
+ this.prefaces.set(id, { ...existing, body: `${existing.body}\n\n${body}` });
163
+ return;
164
+ }
93
165
  this.prefaces.set(id, newEnvelope(HUB, body, { to: [id], kind: "presence" }));
94
166
  }
95
167
 
@@ -107,9 +179,10 @@ export class Bus {
107
179
  const targets = (env.to ?? [...this.peers.keys()]).filter((id) => id !== env.from && this.peers.has(id));
108
180
  for (const id of targets) {
109
181
  const peer = this.peers.get(id)!;
110
- if (env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
182
+ if (!this.recoveryHeld && env.priority === "important" && peer.steer && this.stateOf(id) === "busy") {
111
183
  // Not queued while the steer is in flight, or an idle transition would deliver it a second time.
112
- peer.steer([env]).catch(() => this.enqueue(id, env, true));
184
+ this.steering++;
185
+ peer.steer([env]).catch(() => this.enqueue(id, env, true)).finally(() => { this.steering--; });
113
186
  } else this.enqueue(id, env);
114
187
  }
115
188
  return targets;
@@ -165,7 +238,7 @@ export class Bus {
165
238
  try {
166
239
  const peer = this.peers.get(id)!;
167
240
  const queue = this.queues.get(id)!;
168
- while (queue.length && this.stateOf(id) === "idle") {
241
+ while (!this.recoveryHeld && queue.length && this.stateOf(id) === "idle") {
169
242
  const wait = this.wait(queue);
170
243
  if (wait > 0) {
171
244
  this.arm(id, wait);
@@ -178,8 +251,10 @@ export class Bus {
178
251
  // What goes out may be condensed; what comes back on failure is always the originals. An important envelope
179
252
  // is why the queue became ready, so it never waits on a model call.
180
253
  const mayCondense = this.opts.condense && !delivery.some((e) => e.priority === "important");
254
+ this.condensing += mayCondense ? 1 : 0;
181
255
  const out = mayCondense ? await this.opts.condense!(delivery).catch(() => delivery) : delivery;
182
- if (mayCondense && this.stateOf(id) !== "idle") {
256
+ this.condensing -= mayCondense ? 1 : 0;
257
+ if (this.recoveryHeld || (mayCondense && this.stateOf(id) !== "idle")) {
183
258
  // The peer got busy while the delivery was prepared. Nothing was attempted: back to the head of the queue,
184
259
  // without counting against the envelopes.
185
260
  if (preface) this.prefaces.set(id, preface);
@@ -1,5 +1,16 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
 
3
+ /**
4
+ * Recovery authority belongs to the short-lived ahub wrapper/launcher. Native
5
+ * agents and their long-lived children must never inherit it, or their later
6
+ * lifecycle calls could bypass the machine recovery lock.
7
+ */
8
+ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
9
+ const env = { ...source };
10
+ delete env.AGENTHUB_RECOVERY_OPERATION;
11
+ return env;
12
+ }
13
+
3
14
  /**
4
15
  * Stop a child owned by this hub and confirm that it exited. A timeout is an
5
16
  * incomplete shutdown, even after SIGKILL, because callers must not reuse a
@@ -7,7 +7,7 @@ export function stateDirFor(cwd: string): string {
7
7
  }
8
8
 
9
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap. The plugin is installed apart from the daemon, so they can drift. */
10
- export const PROTOCOL = 7; // project/instance identity and console-only dashboard forwarding
10
+ export const PROTOCOL = 8; // controlled daemon restart/recovery RPC and project/instance identity
11
11
 
12
12
  export interface Hello {
13
13
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
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";
@@ -28,6 +28,7 @@ import { BasePeer, DEFAULT_WATCHDOG_MS } from "./peers.ts";
28
28
  import { MemoryClient, workerUrl } from "../memory/client.ts";
29
29
  import { VERSION } from "../version.ts";
30
30
  import { projectChain, recallFor } from "../memory/recall.ts";
31
+ import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
31
32
 
32
33
  export interface HubConfig {
33
34
  watchdog_ms: number;
@@ -134,6 +135,10 @@ class WsPeer extends BasePeer {
134
135
  async stop(): Promise<void> {
135
136
  this.sock?.close();
136
137
  }
138
+
139
+ recoveryMetadata(): Record<string, unknown> {
140
+ return { launch: { kind: "claude-channel", peer: this.id } };
141
+ }
137
142
  }
138
143
 
139
144
  export async function startDaemon(opts: DaemonOptions) {
@@ -181,6 +186,43 @@ export async function startDaemon(opts: DaemonOptions) {
181
186
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
182
187
  let inference: Inference | undefined;
183
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
+ };
184
226
  const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
185
227
  const chain = projectChain(opts.cwd);
186
228
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
@@ -227,7 +269,6 @@ export async function startDaemon(opts: DaemonOptions) {
227
269
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
228
270
  });
229
271
  // ---- budget relay -------------------------------------------------------------------------------------------
230
- const manualPaused = new Set<PeerId>(); // `ahub pause`: the coordinator never lifts these
231
272
  const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
232
273
  const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
233
274
  const budget = new Budget(join(opts.stateDir, "hub.db"), config.budget, {
@@ -270,6 +311,7 @@ export async function startDaemon(opts: DaemonOptions) {
270
311
  },
271
312
  notify: (line) => notify(line),
272
313
  });
314
+ budget.setRecoveryHold(recoveryActive());
273
315
  const kimiTokens: { at: number; n: number }[] = [];
274
316
  startupCleanup.push(() => budget.close());
275
317
  let kimiSessionTotal = 0;
@@ -363,6 +405,16 @@ export async function startDaemon(opts: DaemonOptions) {
363
405
  }
364
406
  throw new Error(`unknown task operation ${op}`);
365
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
+ }
366
418
  const permissions = new Map<string, { push: string; done: (optionId: string | undefined) => void }>();
367
419
  let stopping = false;
368
420
 
@@ -370,6 +422,45 @@ export async function startDaemon(opts: DaemonOptions) {
370
422
  const r = budget.record(id); // one read per peer: status.json is rewritten on every bus event
371
423
  return r ? { paused: `budget: ${r.reason}, resets ${new Date(r.resetsAt).toLocaleTimeString()}` } : {};
372
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
+ };
373
464
  const status = () => ({
374
465
  projectId,
375
466
  instanceId,
@@ -386,6 +477,7 @@ export async function startDaemon(opts: DaemonOptions) {
386
477
  ),
387
478
  ...(sidecar ? { switchyard: sidecar.status } : {}),
388
479
  tasks: board.counts(),
480
+ ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
389
481
  });
390
482
  const writeStatus = () => {
391
483
  const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
@@ -447,6 +539,7 @@ export async function startDaemon(opts: DaemonOptions) {
447
539
  const cmd = args.model ? [bin!, "--model", args.model, ...rest] : config.kimi_cmd;
448
540
  const kimi = new AcpPeer("kimi", {
449
541
  cmd,
542
+ ...(args.model ? { launchModel: args.model } : {}),
450
543
  cwd: opts.cwd,
451
544
  watchdogMs: config.watchdog_ms,
452
545
  onPermission,
@@ -455,6 +548,7 @@ export async function startDaemon(opts: DaemonOptions) {
455
548
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
456
549
  preamble: roleContract("kimi", config.roles),
457
550
  });
551
+ recoveryTaskPreface("kimi");
458
552
  await ensurePreface("kimi");
459
553
  bus.add(kimi);
460
554
  await kimi.start();
@@ -480,6 +574,7 @@ export async function startDaemon(opts: DaemonOptions) {
480
574
  watchdogMs: config.watchdog_ms,
481
575
  log,
482
576
  });
577
+ recoveryTaskPreface("codex");
483
578
  await ensurePreface("codex");
484
579
  bus.add(codex);
485
580
  await codex.start();
@@ -526,6 +621,7 @@ export async function startDaemon(opts: DaemonOptions) {
526
621
  maxSteps: config.local.max_steps,
527
622
  log,
528
623
  });
624
+ recoveryTaskPreface("local");
529
625
  await ensurePreface("local");
530
626
  bus.add(local);
531
627
  await local.start();
@@ -611,6 +707,122 @@ export async function startDaemon(opts: DaemonOptions) {
611
707
  }
612
708
  }
613
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
+
614
826
  function onMessage(sock: Sock, msg: any): void {
615
827
  const c = sock.data;
616
828
  const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
@@ -635,6 +847,7 @@ export async function startDaemon(opts: DaemonOptions) {
635
847
  } else {
636
848
  c.peer = String(msg.peer ?? "claude");
637
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");
638
851
  let peer = bus.peers.get(c.peer);
639
852
  if (!peer) bus.add((peer = new WsPeer(c.peer)));
640
853
  if (!(peer instanceof WsPeer)) return sock.close(4409, "peer id is taken by a hub-managed adapter");
@@ -648,7 +861,12 @@ export async function startDaemon(opts: DaemonOptions) {
648
861
  }
649
862
  if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
650
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;
651
868
  case "send": {
869
+ if (recoveryActive()) return void reply({ t: "sent", ok: false, error: "recovery is holding new deliveries" });
652
870
  // A human at the console should not wait out the batch window; agents default to status.
653
871
  const { priority, body } = parseMarker(String(msg.body ?? ""), c.peer ? "status" : "important");
654
872
  if (!body) return void reply({ t: "sent", ok: false, error: "empty body" });
@@ -681,12 +899,15 @@ export async function startDaemon(opts: DaemonOptions) {
681
899
  return void reply(uiSnapshot(msg.after ?? 0));
682
900
  case "ui_action":
683
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" });
684
903
  if (msg.instanceId !== instanceId) return void reply({ ok: false, error: "hub restarted; refresh before acting" });
685
904
  if (!msg.action || typeof msg.action !== "object" || Array.isArray(msg.action)) return void reply({ ok: false, error: "invalid dashboard action" });
686
905
  void uiAction(msg.action).then((result) => reply(result as Record<string, unknown>), () => reply({ ok: false, error: "dashboard action failed; check its inputs" }));
687
906
  return;
688
907
  case "start":
689
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" });
690
911
  startPeer(String(msg.peer), msg.args ?? {})
691
912
  .catch((e: Error) => ({ ok: false, error: e.message }))
692
913
  .then((r) => {
@@ -695,6 +916,7 @@ export async function startDaemon(opts: DaemonOptions) {
695
916
  });
696
917
  return;
697
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" });
698
920
  taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
699
921
  (text) => reply({ t: "task", ok: true, text }),
700
922
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
@@ -703,6 +925,7 @@ export async function startDaemon(opts: DaemonOptions) {
703
925
  case "pause":
704
926
  case "resume": {
705
927
  if (c.role !== "console") return;
928
+ if (recoveryActive()) return void reply({ t: msg.t, ok: false, error: "recovery is holding mutations" });
706
929
  return void reply({ t: msg.t, ...holdPeer(msg.t, String(msg.peer)) });
707
930
  }
708
931
  case "ask":
@@ -741,6 +964,7 @@ export async function startDaemon(opts: DaemonOptions) {
741
964
  return;
742
965
  case "budget":
743
966
  if (c.role !== "console") return;
967
+ if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
744
968
  if (msg.resume) {
745
969
  if (!budget.override(String(msg.resume))) return void reply({ t: "budget", ok: false, error: `${msg.resume} is not paused by the budget coordinator` });
746
970
  } else if (msg.set) {