@staix/agent-hub 0.12.31 → 0.12.33

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +14 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +3 -0
  5. package/docs/agent-notes/daemon.md +13 -8
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +141 -25
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +18 -9
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  20. package/docs/verified.json +24 -16
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1296 -26
  24. package/src/adapters/acp.ts +5 -6
  25. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  26. package/src/adapters/definitions/kimi-native.ts +4 -0
  27. package/src/adapters/definitions/qwen-native.ts +24 -0
  28. package/src/adapters/definitions/qwen.ts +71 -0
  29. package/src/adapters/pi.ts +6 -2
  30. package/src/adapters/registry-types.ts +174 -0
  31. package/src/adapters/registry.ts +36 -0
  32. package/src/cli/console-state.ts +74 -5
  33. package/src/cli/console.ts +35 -2
  34. package/src/cli/help.ts +3 -1
  35. package/src/cli/identity.ts +13 -3
  36. package/src/cli/launch.ts +1 -6
  37. package/src/cli/main.ts +200 -39
  38. package/src/cli/output.ts +2 -0
  39. package/src/cli/preview.ts +8 -5
  40. package/src/cli/recovery-guidance.ts +137 -0
  41. package/src/cli/recovery-presentation-options.ts +8 -0
  42. package/src/cli/recovery-session-file.ts +18 -0
  43. package/src/cli/reset.ts +13 -1
  44. package/src/cli/terminal-recovery.ts +48 -0
  45. package/src/cli/upgrade-interactive.ts +129 -75
  46. package/src/cli/upgrade-labels.ts +30 -0
  47. package/src/cli/upgrade-presentation-data.ts +41 -0
  48. package/src/cli/upgrade-presentation-flags.ts +31 -0
  49. package/src/cli/upgrade-presentation.ts +227 -0
  50. package/src/cli/upgrade-restore-state.ts +6 -2
  51. package/src/cli/upgrade-runtime.ts +360 -199
  52. package/src/cli/upgrade.ts +278 -25
  53. package/src/hub/bus.ts +144 -14
  54. package/src/hub/cohorts.ts +2 -2
  55. package/src/hub/config-apply.ts +242 -0
  56. package/src/hub/config-reload.ts +120 -0
  57. package/src/hub/config-trust.ts +11 -2
  58. package/src/hub/control-client.ts +1 -1
  59. package/src/hub/crash.ts +8 -6
  60. package/src/hub/daemon.ts +577 -179
  61. package/src/hub/deferred-hub.ts +60 -0
  62. package/src/hub/delivery-journal.ts +6 -0
  63. package/src/hub/events.ts +2 -2
  64. package/src/hub/manager.ts +1 -1
  65. package/src/hub/peer-catalog.ts +10 -13
  66. package/src/hub/peers.ts +12 -2
  67. package/src/hub/permission-mode.ts +5 -3
  68. package/src/hub/recovery-continuity.ts +152 -0
  69. package/src/hub/recovery-store.ts +10 -4
  70. package/src/hub/restart.ts +12 -1
  71. package/src/hub/sdlc.ts +5 -1
  72. package/src/hub/settings.ts +50 -36
  73. package/src/hub/settled-roles.ts +35 -0
  74. package/src/hub/supervision.ts +3 -2
  75. package/src/hub/tasks.ts +29 -9
  76. package/src/hub/ui.ts +1 -1
  77. package/src/memory/brief.ts +6 -5
  78. package/src/pi/session.ts +23 -4
  79. package/src/ui/index.html +23 -7
package/src/hub/bus.ts CHANGED
@@ -2,6 +2,7 @@ import { appendNote, DIGEST, HUB, keepNotes, MAX_HOP, mergePrefaces, newEnvelope
2
2
  import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
3
3
  import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
4
4
  import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
5
+ import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
5
6
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
6
7
 
7
8
  export type { DeliveryReceipt } from "./peers.ts";
@@ -64,6 +65,8 @@ export interface BusSnapshot {
64
65
  attempts: Record<string, number>;
65
66
  withdrawn: string[];
66
67
  manualPaused?: string[];
68
+ /** Hub notices accepted during recovery, durable but outside the carried delivery queues. */
69
+ deferredHub?: Envelope[];
67
70
  journal?: ReturnType<DeliveryJournal["snapshot"]>;
68
71
  }
69
72
 
@@ -142,6 +145,12 @@ export class Bus {
142
145
  /** Synchronous native failure observation, before queue callbacks can finish turn metrics. */
143
146
  onDeliveryFailed?: (peer: PeerId) => void;
144
147
  private recoveryHeld = false;
148
+ private deferHub = false;
149
+ private readonly deferredHub = new Map<string, Envelope>();
150
+ private flushingHub = false;
151
+ private replayingHub = false;
152
+ private deferredEvents: BusEvent[] = [];
153
+ private deferredNudges: (() => void)[] = [];
145
154
  private steering = 0;
146
155
  private condensing = 0;
147
156
  private closed = false;
@@ -162,6 +171,17 @@ export class Bus {
162
171
  }
163
172
  }
164
173
 
174
+ /** Apply future batching and admission limits without disturbing queued work or its existing deadline. */
175
+ configureScheduling(config: Pick<BusOptions, "batchMax" | "batchMs" | "queueCap">): void {
176
+ const { batchMax, batchMs, queueCap } = config;
177
+ if (!Number.isSafeInteger(batchMax) || batchMax < 1 || batchMax > 2_147_483_647 ||
178
+ !Number.isFinite(batchMs) || batchMs < 0 || batchMs > 2_147_483_647 ||
179
+ !Number.isSafeInteger(queueCap) || queueCap < 1 || queueCap > 2_147_483_647) {
180
+ throw new Error("invalid bus scheduling configuration");
181
+ }
182
+ Object.assign(this.opts, { batchMax, batchMs, queueCap });
183
+ }
184
+
165
185
  add(peer: PeerAdapter): void {
166
186
  this.peers.set(peer.id, peer);
167
187
  if (!this.queues.has(peer.id)) this.queues.set(peer.id, []);
@@ -262,10 +282,61 @@ export class Bus {
262
282
  }
263
283
 
264
284
  /** Stop new deliveries while a coordinator takes a stable snapshot. Existing adapter turns are left alone. */
265
- setRecoveryHold(held: boolean): void {
266
- this.recoveryHeld = held;
267
- if (!held) this.onQueues?.(); // restored holds need classification even when no peer is attached
268
- if (!held) for (const id of this.peers.keys()) void this.drain(id);
285
+ setRecoveryHold(held: boolean, deferHub = false): void {
286
+ if (this.replayingHub) throw new RecoveryDeferredHubError("recovery notice release is already in progress");
287
+ if (held) {
288
+ this.recoveryHeld = true;
289
+ this.deferHub = deferHub;
290
+ // An older target cannot carry this additive field: materialize before commit's expected integrity.
291
+ if (!deferHub) this.flushDeferredHub();
292
+ return;
293
+ }
294
+ // Persist each deferred -> queued transition while deliveries are still fenced. A failed checkpoint
295
+ // leaves the hold and the original pending head intact; a crash after it resumes from the journal.
296
+ this.flushDeferredHub();
297
+ this.recoveryHeld = false;
298
+ this.onQueues?.();
299
+ for (const id of this.peers.keys()) void this.drain(id);
300
+ }
301
+
302
+ private flushDeferredHub(): void {
303
+ if (this.flushingHub) return;
304
+ if (this.storageError) this.persist();
305
+ this.replayingHub = true;
306
+ try { while (this.deferredHub.size) {
307
+ const env = this.deferredHub.values().next().value!;
308
+ const beforeDeferred = [...this.deferredHub], beforeSeen = [...this.seen], beforeWithdrawn = [...this.withdrawn];
309
+ const affected = new Map(this.audience(env).map(peer => {
310
+ const queue = this.queues.get(peer);
311
+ return [peer, queue ? { queue, contents: [...queue] } : undefined] as const;
312
+ }));
313
+ const outcomes = this.pendingOutcomes.length;
314
+ this.flushingHub = true;
315
+ this.deferredEvents = []; this.deferredNudges = [];
316
+ try {
317
+ this.deferredHub.delete(env.id);
318
+ this.publishNow(env);
319
+ } catch (error) {
320
+ // Only publication state is transactional. A live peer's preface, pauses, attempts and
321
+ // recovery mode are independent state, not something a failed checkpoint may reload.
322
+ this.deferredHub.clear(); for (const [id, pending] of beforeDeferred) this.deferredHub.set(id, pending);
323
+ this.seen.clear(); for (const [id, seen] of beforeSeen) this.seen.set(id, seen);
324
+ this.withdrawn.clear(); for (const id of beforeWithdrawn) this.withdrawn.add(id);
325
+ for (const [peer, queue] of affected) {
326
+ if (queue) { queue.queue.splice(0, queue.queue.length, ...queue.contents); this.queues.set(peer, queue.queue); }
327
+ else this.queues.delete(peer);
328
+ }
329
+ this.pendingOutcomes.length = outcomes;
330
+ this.deferredEvents = []; this.deferredNudges = [];
331
+ throw new RecoveryDeferredHubError("deferred hub notice checkpoint failed; recovery remains held; restore delivery storage, then retry release or abort");
332
+ } finally { this.flushingHub = false; }
333
+ // Publication observers run only after the durable transition. Reentrant notices join the FIFO tail.
334
+ const events = this.deferredEvents; this.deferredEvents = [];
335
+ for (const event of events) this.emit(event);
336
+ const nudges = this.deferredNudges; this.deferredNudges = [];
337
+ for (const nudge of nudges) nudge();
338
+ this.onQueues?.();
339
+ } } finally { this.replayingHub = false; }
269
340
  }
270
341
 
271
342
  get isRecoveryHeld(): boolean {
@@ -285,7 +356,7 @@ export class Bus {
285
356
  let delay = 1;
286
357
  while (this.draining.size || this.steering || this.condensing) {
287
358
  if (Date.now() >= deadline) {
288
- this.recoveryHeld = heldBefore;
359
+ if (!heldBefore) this.setRecoveryHold(false);
289
360
  throw new Error(`recovery fence timed out after ${timeoutMs} ms with ${this.draining.size + this.steering + this.condensing} deliveries still in flight; retry the operation`);
290
361
  }
291
362
  await Bun.sleep(delay);
@@ -303,6 +374,7 @@ export class Bus {
303
374
  attempts: Object.fromEntries(this.attempts),
304
375
  withdrawn: [...this.withdrawn],
305
376
  manualPaused: [...this.manualPaused],
377
+ ...(this.deferredHub.size ? { deferredHub: structuredClone([...this.deferredHub.values()]) } : {}),
306
378
  };
307
379
  if (includeJournal && this.journal) out.journal = this.journal.snapshot();
308
380
  return out;
@@ -330,6 +402,10 @@ export class Bus {
330
402
  }
331
403
 
332
404
  private loadSnapshot(snapshot: BusSnapshot): void {
405
+ const deferred = validateDeferredHub(snapshot.deferredHub, undefined, new Set([...snapshot.seen.map(e => e.id), ...Object.values(snapshot.queues).flat().map(e => e.id)]));
406
+ if (deferred.length) { this.recoveryHeld = true; this.deferHub = true; }
407
+ this.deferredHub.clear();
408
+ for (const env of deferred) this.deferredHub.set(env.id, structuredClone(env));
333
409
  this.queues.clear();
334
410
  for (const [id, queue] of Object.entries(snapshot.queues ?? {})) this.queues.set(id, queue.map((e) => ({ ...e, ...(e.to ? { to: [...e.to] } : {}) })));
335
411
  this.prefaces.clear();
@@ -521,7 +597,7 @@ export class Bus {
521
597
 
522
598
  queuedBacklogIds(id: PeerId): Set<number> {
523
599
  const ids = new Set<number>();
524
- const retained = [...(this.queues.get(id) ?? []), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
600
+ const retained = [...this.pendingEnvelopes(id), ...(this.journal?.unresolved(id).filter(row => row.state !== "queued").flatMap(row => row.originals) ?? [])];
525
601
  for (const env of retained) {
526
602
  const backlog = env.refs?.backlog;
527
603
  if (env.from !== HUB || env.kind !== "task" || !Array.isArray(backlog) || backlog.length > 64) continue;
@@ -591,8 +667,52 @@ export class Bus {
591
667
  this.prefaces.set(id, !now ? { ...env, body: uniqueMemoryLines(env.body) } : now.id === env.id ? { ...now, body: uniqueMemoryLines(now.body) } : { ...env, body: keepNotes(mergePrefaces(env.body, now.body), NOTES_KEPT) });
592
668
  }
593
669
 
594
- /** Returns the peers the envelope was queued for. */
670
+ /** Stopped-runtime reset only: audit every deferred recipient without publishing or releasing work. */
671
+ discardDeferredHub(reason: string): string[] {
672
+ if (this.peers.size || this.flushingHub || this.replayingHub || !this.journal || this.storageError) throw new Error("deferred reset requires a stopped, readable delivery journal");
673
+ const pending = [...this.deferredHub];
674
+ const settled: string[] = [];
675
+ try {
676
+ this.journal.transaction(() => {
677
+ for (const [, env] of pending) for (const peer of new Set(env.to ?? [])) {
678
+ const id = `q:${peer}:${env.id}`;
679
+ this.journal!.createDelivery({ id, peer, state: "queued", createdAt: env.ts, originals: [env], out: [env] });
680
+ const row = this.journal!.get(id)!;
681
+ this.journal!.resolve(id, row.revision, "discard", reason, row.revision, "user");
682
+ settled.push(id);
683
+ }
684
+ this.deferredHub.clear();
685
+ this.persist();
686
+ });
687
+ } catch (error) {
688
+ this.deferredHub.clear(); for (const [id, env] of pending) this.deferredHub.set(id, env);
689
+ throw error;
690
+ }
691
+ return settled;
692
+ }
693
+
694
+ /** Accepted pending work, including notices withheld from the recovery integrity view. */
695
+ pendingEnvelopes(peer: PeerId): Envelope[] {
696
+ return [...(this.queues.get(peer) ?? []), ...[...this.deferredHub.values()].filter(e => e.to?.includes(peer))];
697
+ }
698
+
699
+ /** Returns the peers the envelope was accepted for, including durable deferred notices. */
595
700
  publish(env: Envelope): PeerId[] {
701
+ if (this.storageError) throw new Error("delivery journal unavailable");
702
+ if (this.seen.has(env.id) || this.deferredHub.has(env.id)) return [];
703
+ if (this.recoveryHeld && (this.deferHub || this.replayingHub) && env.from === HUB && env.priority !== "fyi" && env.hop <= MAX_HOP) {
704
+ if (this.deferredHub.size >= MAX_DEFERRED_HUB) throw new RecoveryDeferredHubError();
705
+ const targets = this.audience(env);
706
+ const copy = structuredClone({ ...env, to: targets });
707
+ this.deferredHub.set(copy.id, copy);
708
+ try { this.persist(); }
709
+ catch (error) { this.deferredHub.delete(copy.id); throw error; }
710
+ return targets;
711
+ }
712
+ return this.publishNow(env);
713
+ }
714
+
715
+ private publishNow(env: Envelope): PeerId[] {
596
716
  if (this.storageError) throw new Error("delivery journal unavailable");
597
717
  if (this.seen.has(env.id)) return [];
598
718
  this.seen.set(env.id, env);
@@ -627,7 +747,7 @@ export class Bus {
627
747
  } } finally { this.suppressDrain = false; }
628
748
  if (this.journal) {
629
749
  this.persist();
630
- this.onQueues?.();
750
+ if (!this.flushingHub) this.onQueues?.();
631
751
  for (const id of targets) {
632
752
  const peer = this.peers.get(id);
633
753
  if (!this.held(id) && env.priority === "important" && peer?.steer && this.stateOf(id) === "busy") void this.steerQueued(id, env);
@@ -670,7 +790,7 @@ export class Bus {
670
790
 
671
791
  /** A recently published envelope, for resolving `reply_to`. */
672
792
  get(id: string): Envelope | undefined {
673
- return this.seen.get(id);
793
+ return this.seen.get(id) ?? this.deferredHub.get(id);
674
794
  }
675
795
 
676
796
  /** The envelopes most recently handed to a peer, used to distinguish a received request from an unread queue. */
@@ -685,10 +805,10 @@ export class Bus {
685
805
  */
686
806
  withdraw(envelopeId: string): boolean {
687
807
  this.markWithdrawn(envelopeId);
688
- let removed = false;
808
+ let removed = this.deferredHub.delete(envelopeId);
689
809
  for (const queue of this.queues.values()) {
690
810
  const i = queue.findIndex((e) => e.id === envelopeId);
691
- if (i !== -1) removed = queue.splice(i, 1).length > 0;
811
+ if (i !== -1) { queue.splice(i, 1); removed = true; }
692
812
  }
693
813
  if (removed) this.onQueues?.();
694
814
  this.persist();
@@ -698,8 +818,15 @@ export class Bus {
698
818
  /** Revoke pending feed copies only; accepted/dispatching journal records and native turns are untouched. */
699
819
  revokeSupervision(peer: PeerId): number {
700
820
  const queue = this.queues.get(peer);
701
- if (!queue && !this.preparing.has(peer)) return 0;
702
821
  let removed = 0;
822
+ for (const [id, env] of this.deferredHub) {
823
+ if (!env.refs?.supervision || !env.to?.includes(peer)) continue;
824
+ const remaining = env.to.filter(target => target !== peer);
825
+ if (remaining.length) this.deferredHub.set(id, { ...env, to: remaining });
826
+ else this.deferredHub.delete(id);
827
+ removed++;
828
+ }
829
+ if (!queue && !this.preparing.has(peer)) { if (removed) this.persist(); return removed; }
703
830
  const discarded = new Set<string>();
704
831
  for (let i = (queue?.length ?? 0) - 1; i >= 0; i--) {
705
832
  const env = queue![i]!;
@@ -785,9 +912,11 @@ export class Bus {
785
912
  }
786
913
  if (queuedEnv.priority === "important" && this.peers.get(id)?.pullOnly) {
787
914
  // Advisory only (issue #398): the envelope stays queued for `pull`; the hook must never affect it.
788
- try { this.opts.onImportantQueued?.(id, { queued: queue.length, important: queue.filter((e) => e.priority === "important").length }); } catch { /* a nudge never stops an enqueue */ }
915
+ const counts = { queued: queue.length, important: queue.filter((e) => e.priority === "important").length };
916
+ const nudge = () => { try { this.opts.onImportantQueued?.(id, counts); } catch { /* a nudge never stops an enqueue */ } };
917
+ if (this.flushingHub) this.deferredNudges.push(nudge); else nudge();
789
918
  }
790
- this.onQueues?.();
919
+ if (!this.flushingHub) this.onQueues?.();
791
920
  if (!this.suppressDrain) this.persist();
792
921
  if (!this.suppressDrain) void this.drain(id);
793
922
  }
@@ -1049,6 +1178,7 @@ export class Bus {
1049
1178
  }
1050
1179
 
1051
1180
  private emit(e: BusEvent): void {
1181
+ if (this.flushingHub) { this.deferredEvents.push(e); return; }
1052
1182
  for (const tap of this.taps) tap(e);
1053
1183
  }
1054
1184
  }
@@ -102,7 +102,7 @@ export class Cohorts {
102
102
  * in, records owner changes (each bumps the revision and voids that member's intent), and lifts a silent cohort that
103
103
  * an owner without verified facts joins. Returns what happened, for the notices.
104
104
  */
105
- join(task: Task, others: Task[], gen: (t: Task) => number, handed: (t: Task) => number = () => Date.now()): { cohort: Cohort; formed: boolean; lifted: boolean } | undefined {
105
+ join(task: Task, others: Task[], gen: (t: Task) => number, handed: (t: Task) => number = () => Date.now(), allowSilence = true): { cohort: Cohort; formed: boolean; lifted: boolean } | undefined {
106
106
  this.gc();
107
107
  const all = [task, ...others].filter((t) => t.owner);
108
108
  const found = [...new Set(all.map((t) => this.of(t.id)).filter((c): c is Cohort => !!c))];
@@ -133,7 +133,7 @@ export class Cohorts {
133
133
  }
134
134
  if (changed) cohort.revision++;
135
135
  const owners = [...new Set([...cohort.members.values()].map((m) => m.owner))];
136
- if (formed) cohort.silent = this.d.silence(owners);
136
+ if (formed) cohort.silent = allowSilence && this.d.silence(owners);
137
137
  else if (cohort.silent && changed && !this.d.silence(owners)) cohort.silent = false;
138
138
  // Lifted: a silent cohort (or a silent one merged into this) is no longer silent, and its members must hear it.
139
139
  return { cohort, formed, lifted: wasSilent && !cohort.silent };
@@ -0,0 +1,242 @@
1
+ import type { HubConfig } from "./daemon.ts";
2
+ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
3
+ import { SUPPORTED_PEERS, PLANNER_PEERS } from "./peer-catalog.ts";
4
+ import { CLASSES } from "./board.ts";
5
+ import { permissionDefaults } from "./permission-mode.ts";
6
+ import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
7
+ import { catalogAssignments } from "./roles.ts";
8
+ import { resolveProfile } from "./profiles.ts";
9
+ import { taskSweepConfig } from "./task-sweep.ts";
10
+ import { projectionConfig } from "./text-projection.ts";
11
+
12
+ export type ApplyClass = "live" | "next use" | "restart";
13
+ export interface ApplyRule { applies: ApplyClass; reason?: string }
14
+ export interface ConfigChange extends ApplyRule { key: string; before: unknown; after: unknown }
15
+ export interface ConfigLayer { name: "config.json" | "config.local.json"; raw: Record<string, unknown> }
16
+ export interface ReloadGuard { key: string; path: readonly string[]; risk: "safe" | "raises"; floor?: string | boolean | number; store: "runtime" | "config" | "routing" }
17
+ export interface ConfigAuthorizationInput {
18
+ key: string;
19
+ before: unknown;
20
+ after: unknown;
21
+ source: "config" | "routing";
22
+ tracked: boolean | undefined;
23
+ authority: "ordinary" | "settings" | "terminal";
24
+ /** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
25
+ confirmed?: boolean;
26
+ /** Root proved the destination equals an unchanged, effective startup declaration in this origin. */
27
+ acceptedFallback?: boolean;
28
+ /** The prospective settled planner/conductor holder sets change, even if the raw role list shrinks. */
29
+ settledHolderChange?: boolean;
30
+ guard?: ReloadGuard;
31
+ }
32
+ export interface ConfigAuthorizationDecision { key: string; allowed: boolean; guarded: boolean; reason: string }
33
+
34
+ /** Pure per-leaf authority. Schema validation and named-key selection precede this decision. */
35
+ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
36
+ const { key, before, after } = input;
37
+ const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
38
+ if (same(before, after)) return result(true, false, "unchanged");
39
+ const supplied = input.guard;
40
+ const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
41
+ const machine = input.source === "config" && ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
42
+ const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
43
+ const subset = (smaller: unknown, larger: unknown) => strings(larger) && (smaller === undefined || strings(smaller) && smaller.every(item => larger.includes(item)));
44
+ const numeric = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
45
+ let tightening = false;
46
+ if (guard?.floor !== undefined && same(after, guard.floor)) tightening = true;
47
+ if (key.startsWith("tasks.wip_limits.") && numeric(before) && numeric(after) && after <= before) tightening = true;
48
+ if (key === "routing.constraints.pii" && before === "off" && after === "local_only") tightening = true;
49
+ if (key === "routing.signals.pii_screen" && before === "off" && after === "local") tightening = true;
50
+ if (key === "routing.signals.pii_patterns" && strings(after) && (before === undefined || strings(before) && before.every(pattern => after.includes(pattern)))) tightening = true;
51
+ if (key.startsWith("roles.") && subset(after, before)) tightening = true;
52
+ if (key.startsWith("capabilities.") && strings(after) && (before === undefined || subset(after, before))) tightening = true;
53
+ if (["limits.sender_per_min", "limits.pair_per_min", "limits.important_per_hour"].includes(key)
54
+ && numeric(before) && numeric(after) && after > 0 && (before === 0 || after <= before)) tightening = true;
55
+ if (key === "limits.repeat_window_s" && numeric(before) && numeric(after) && after >= before) tightening = true;
56
+ if (key === "conductor.feed") {
57
+ const levels = ["off", "own", "all"];
58
+ if (typeof before === "string" && typeof after === "string" && levels.includes(before) && levels.includes(after) && levels.indexOf(after) < levels.indexOf(before)) tightening = true;
59
+ }
60
+ if (["inference.enabled", "inference.triage", "review.adaptive"].includes(key) && before === true && after === false) tightening = true;
61
+ if (key === "review.min_reviews" && numeric(before) && numeric(after) && after >= before) tightening = true;
62
+ if (tightening && !input.settledHolderChange) return result(true, false, "semantic tightening");
63
+ if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
64
+ if (input.acceptedFallback && !machine) {
65
+ const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
66
+ return result(person && input.confirmed === true, true, "restoring an unchanged effective startup value needs this key to be explicitly confirmed");
67
+ }
68
+ // Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
69
+ if (input.tracked !== false) return result(false, true, input.tracked ? "guarded source is tracked; retain the running value" : "guarded source trust is unknown; retain the running value");
70
+ if (input.authority === "ordinary") return result(false, true, "guarded change needs a selected setting or a confirmed terminal reload");
71
+ if (input.authority === "settings" && !guard) return result(false, true, "nonregistered policy requires a confirmed terminal reload");
72
+ if (input.confirmed !== true) return result(false, true, "guarded change needs explicit confirmation for this key");
73
+ return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
74
+ }
75
+ type TrustLayer = ConfigLayer | { name: "routing.toml" | "routing.local.toml"; raw: Record<string, unknown> };
76
+ type Authority = "ordinary" | "settings" | "terminal";
77
+ type Check = (value: unknown, key: string) => void;
78
+ const fail = (key: string, reason = "invalid value"): never => { throw new Error(`${key}: ${reason}`); };
79
+ const object = (value: unknown, key: string): Record<string, unknown> => {
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail(key, "must be an object");
81
+ return value as Record<string, unknown>;
82
+ };
83
+ const bool: Check = (v, k) => { if (typeof v !== "boolean") fail(k, "must be a boolean"); };
84
+ const text: Check = (v, k) => { if (typeof v !== "string" || v.length > 16_384 || /[\x00-\x1f\x7f]/.test(v)) fail(k, "must be bounded text"); };
85
+ const number = (min = 0, max = Number.MAX_SAFE_INTEGER, integer = false): Check => (v, k) => {
86
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isSafeInteger(v)) fail(k, "number outside bounds");
87
+ };
88
+ const words = (values: readonly string[]): Check => (v, k) => { if (typeof v !== "string" || !values.includes(v)) fail(k, "unknown choice"); };
89
+ const strings: Check = (v, k) => { if (!Array.isArray(v) || v.length > 1024) return fail(k, "must be a bounded string list"); for (const x of v) text(x, k); };
90
+ const argv: Check = (v, k) => { strings(v, k); if (!(v as string[]).length || (v as string[]).some(x => !x)) fail(k, "must be a nonempty argv"); };
91
+ const shapes = new WeakMap<Check, { fields?: Record<string, Check>; item?: Check; keys?: readonly string[] }>();
92
+ const map = (check: Check, keys?: readonly string[]): Check => {
93
+ const fn: Check = (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
94
+ if (["__proto__", "constructor", "prototype"].includes(key) || keys && !keys.includes(key)) fail(`${k}.${key}`, "unknown key"); check(value, `${k}.${key}`);
95
+ } };
96
+ shapes.set(fn, { item: check, keys }); return fn;
97
+ };
98
+ const block = (schema: Record<string, Check>): Check => {
99
+ const fn: Check = (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
100
+ if (!Object.hasOwn(schema, key)) fail(`${k}.${key}`, "unknown key"); if (value !== undefined) schema[key]!(value, `${k}.${key}`);
101
+ } };
102
+ shapes.set(fn, { fields: schema }); return fn;
103
+ };
104
+ const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
105
+ const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
106
+ const schema: Record<string, Check> = {
107
+ watchdog_ms: number(1, 2_147_483_647), kimi_cmd: argv, qwen_cmd: argv, jcode_cmd: argv, codex_bin: text, jcode_home: text, jcode_provider_profile: text,
108
+ permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
109
+ batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
110
+ memory: block({ enabled: bool, worker_url: text, inject_tokens: number(0, 1_000_000, true), brief_items: number(0, 10_000, true), native: bool,
111
+ short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
112
+ roster: peerMap(bool), roles: map(strings), planner: words(PLANNER_PEERS), profile: words(["dev", "research", "writing", "general"]),
113
+ profile_overrides: (v, k) => { map(() => {})(v, k); const fields = object(v, k); for (const name of ["classes", "role_text", "catalog_roles"]) if (fields[name] !== undefined) map(() => {})(fields[name], `${k}.${name}`); resolveProfile("dev", v); },
114
+ catalog_roles: (v, k) => { map(() => {})(v, k); catalogAssignments(v); },
115
+ pii: block({ paths: strings }), data: block({ readonly: strings }),
116
+ text_projection: v => { projectionConfig(v); },
117
+ conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
118
+ budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
119
+ context: block({ gate: number(0, 1), stale_min: timerMinutes }),
120
+ inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
121
+ omniroute: block({ urls: strings, api_key_file: text, access_hosts: strings, cf_client_id_file: text, cf_client_secret_file: text }),
122
+ pi: block({ enabled: bool, auto_start: bool, cmd: argv, backend: words(["auto", "dgx", "mlx"]), dgx_coding: text, dgx_fast: text, max_steps: number(1, 1_000_000, true) }),
123
+ mlx: block({ enabled: bool, provider: words(["legacy", "ollama"]), host: text, runtimeDir: text, modelPath: text, bin: text, port: number(1, 65535, true), model: text, sourceModel: text,
124
+ contextWindow: number(1), maxInputTokens: number(1), maxTokens: number(1), maxConcurrency: number(1, 1024, true) }),
125
+ local: block({ deny: strings, bash_network: (v, k) => { if (v !== "direct") bool(v, k); }, network_allow: strings, max_steps: number(1, 1_000_000, true), read_allow: strings }),
126
+ approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)) }),
127
+ task_sweep: (v, k) => { block({ enabled: bool, auto_reassign: bool, interval_s: timerSeconds, unaccepted_min: timerMinutes, idle_min: timerMinutes, review_min: timerMinutes, ready_min: timerMinutes, ladder_min: timerMinutes })(v, k); taskSweepConfig(v); },
128
+ checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
129
+ if (value === undefined) continue;
130
+ if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
131
+ else if (key === "runner") words(["container"])(value, `${k}.${key}`);
132
+ else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
133
+ } },
134
+ snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
135
+ peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), ["pi", "codex", "kimi"])(v, k); peerStartConfig(v); },
136
+ terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
137
+ limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
138
+ review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
139
+ capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
140
+ };
141
+ export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> { block(schema)(raw, "config"); }
142
+ export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
143
+ const raw = { ...object(value, "config") };
144
+ delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
145
+ validateRawConfig(raw);
146
+ const profile = resolveProfile(raw.profile, raw.profile_overrides);
147
+ for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
148
+ if (object(raw.mlx, "mlx").enabled === false && object(raw.pi, "pi").backend === "mlx") fail("pi.backend", "conflicts with disabled mlx");
149
+ }
150
+
151
+ const restart: Record<string, string> = {
152
+ "pi.dgx_coding": "Published Pi relay model catalogue requires a new daemon relay",
153
+ "pi.dgx_fast": "Published Pi relay model catalogue requires a new daemon relay",
154
+ "routing.pi": "Pi relay context and model publication are bound to the running relay",
155
+ memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
156
+ omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
157
+ checks: "Bounded runner and completion-check contracts are constructed together",
158
+ pii: "Existing native sessions retain their startup privacy boundary", data: "Existing native sessions retain their startup read-only policy",
159
+ "local.deny": "Existing native sessions retain their sandbox deny policy", "local.read_allow": "Existing native sessions retain their sandbox read grants",
160
+ "local.bash_network": "Existing sandbox and egress proxy contracts require a new instance", "local.network_allow": "Existing egress proxy ownership requires a new instance",
161
+ profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
162
+ text_projection: "Converter and cache ownership are bound at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
163
+ };
164
+ const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
165
+ const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
166
+ /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
167
+ export function configApply(key: string): ApplyRule {
168
+ const top = key.split(".")[0]!;
169
+ if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
170
+ if (top !== "routing" && top !== "permission" && key.includes(".")) {
171
+ const special: Record<string, readonly string[]> = {
172
+ task_sweep: ["enabled", "auto_reassign", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
173
+ checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
174
+ };
175
+ let check = schema[top];
176
+ for (const [index, part] of key.split(".").slice(1).entries()) {
177
+ const shape = check && shapes.get(check);
178
+ if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
179
+ else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
180
+ else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
181
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers"].includes(top)) break;
182
+ else return fail(key, "unknown configuration key");
183
+ }
184
+ }
185
+ if (top === "routing" && !key.startsWith("routing.pi") && key !== "routing") {
186
+ return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
187
+ }
188
+ if (nextMemory.has(key)) return { applies: "next use" };
189
+ for (const [path, reason] of Object.entries(restart)) if (key === path || key.startsWith(`${path}.`)) return { applies: "restart", reason };
190
+ if ([...live].some(path => key === path || key.startsWith(`${path}.`))) return { applies: "live" };
191
+ return { applies: "next use" };
192
+ }
193
+ const canonical = (v: unknown): unknown => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical((v as Record<string, unknown>)[k])])) : v;
194
+ const same = (a: unknown, b: unknown): boolean => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
195
+ const at = (raw: unknown, path: readonly string[]): unknown => path.reduce<unknown>((v, k) => v && typeof v === "object" && Object.hasOwn(v, k) ? (v as Record<string, unknown>)[k] : undefined, raw);
196
+ export function configChanges(running: HubConfig, next: HubConfig): ConfigChange[] {
197
+ const changes: ConfigChange[] = [];
198
+ const walk = (before: unknown, after: unknown, key: string) => {
199
+ if (same(before, after)) return;
200
+ if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
201
+ for (const child of new Set([...Object.keys(before), ...Object.keys(after)])) walk(at(before, [child]), at(after, [child]), key ? `${key}.${child}` : child);
202
+ } else changes.push({ key, before, after, ...configApply(key) });
203
+ };
204
+ const clean = (v: HubConfig) => Object.fromEntries(Object.entries(v).filter(([key]) => !["ignored", "retired", "permission_default_sources"].includes(key)));
205
+ walk(clean(running), clean(next), ""); return changes.sort((a, b) => a.key.localeCompare(b.key));
206
+ }
207
+
208
+ /** Legacy all-or-nothing authority helper; daemon reload uses authorizeConfigLeaf for scoped publication. */
209
+ export function assertReloadTrust(cwd: string, layers: readonly TrustLayer[], previousLayers: readonly TrustLayer[], guardedRules: readonly ReloadGuard[], authority: Authority, defaults?: object): void {
210
+ const complete = [...layers, ...previousLayers.filter(prior => !layers.some(layer => layer.name === prior.name)).map(prior => ({ ...prior, raw: {} }))];
211
+ for (const layer of complete) {
212
+ const prior = previousLayers.find(p => p.name === layer.name)?.raw;
213
+ const routing = layer.name.endsWith(".toml");
214
+ const paths = new Map<string, { path: readonly string[]; floor?: unknown; machine: boolean; registered?: boolean }>();
215
+ if (!routing) for (const key of ALL_MACHINE_LOCAL) {
216
+ const path = key.split("."), value = at(layer.raw, path), old = at(prior, path);
217
+ const children = new Set([...(value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value) : []), ...(old && typeof old === "object" && !Array.isArray(old) ? Object.keys(old) : [])]);
218
+ if (children.size) for (const child of children) paths.set(`${key}.${child}`, { path: [...path, child], machine: true });
219
+ else paths.set(key, { path, machine: true });
220
+ }
221
+ for (const rule of guardedRules) if (rule.risk === "raises" && rule.store === (routing ? "routing" : "config")) {
222
+ const key = rule.path.join(".");
223
+ paths.set(key, { path: rule.path, floor: rule.floor, registered: true, machine: !routing && ALL_MACHINE_LOCAL.some(p => key === p || key.startsWith(`${p}.`)) });
224
+ }
225
+ for (const [key, guard] of paths) {
226
+ const value = at(layer.raw, guard.path), old = at(prior, guard.path);
227
+ if (same(value, old)) continue;
228
+ const inherit = guard.registered && value === undefined && old !== undefined;
229
+ if (!inherit && guard.machine && defaults && same(value, at(defaults, guard.path))) continue;
230
+ // Removal can expose a lower layer: registry inherit is guarded just like a direct raise.
231
+ if (!guard.machine && value !== undefined && same(value, guard.floor)) continue;
232
+ // Empty machine-local declarations choose no program/path and are stripped at startup.
233
+ if (!inherit && guard.machine && (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length)) continue;
234
+ let tracked = configTracked(cwd, layer.name);
235
+ if (!guard.machine && tracked === undefined && !configRepositoryPresent(cwd)) tracked = false;
236
+ if (tracked !== false) fail(`${layer.name}:${key}`, tracked ? "guarded change is tracked" : "guarded source trust is unknown");
237
+ if (guard.machine && authority === "settings" && !guard.registered) fail(`${layer.name}:${key}`, "non-editable machine policy requires terminal authority");
238
+ if (value !== undefined && guard.floor !== undefined && same(value, guard.floor)) continue;
239
+ if (authority === "ordinary") fail(`${layer.name}:${key}`, "guarded change requires settings or terminal authority");
240
+ }
241
+ }
242
+ }