@staix/agent-hub 0.12.33 → 0.12.35

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 (126) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +9 -3
  4. package/docs/agent-notes/budget.md +7 -3
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +7 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +2 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +80 -16
  12. package/docs/quickstart.md +10 -2
  13. package/docs/sdlc.md +59 -34
  14. package/docs/security.md +7 -4
  15. package/docs/smoke.md +1 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
  17. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  18. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  19. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  20. package/docs/verified.json +35 -19
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +23567 -16606
  24. package/src/adapters/acp.ts +3 -1
  25. package/src/adapters/claude-channel.ts +59 -29
  26. package/src/adapters/codex-appserver.ts +82 -12
  27. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  28. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  29. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  30. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  31. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  32. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  33. package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
  34. package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
  35. package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
  36. package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
  37. package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
  39. package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
  40. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  41. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  42. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  43. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  44. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  45. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  46. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  47. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  48. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  49. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  50. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  51. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  52. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  53. package/src/adapters/definitions/native-config-policy.ts +35 -0
  54. package/src/adapters/definitions/native-context.ts +35 -0
  55. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  56. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  57. package/src/adapters/definitions/native-display.ts +18 -0
  58. package/src/adapters/definitions/native-factories.ts +48 -0
  59. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  60. package/src/adapters/definitions/native-quota.ts +29 -0
  61. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  62. package/src/adapters/definitions/native-recovery.ts +88 -0
  63. package/src/adapters/definitions/qwen-native.ts +2 -2
  64. package/src/adapters/definitions/qwen.ts +8 -4
  65. package/src/adapters/local-worker.ts +3 -2
  66. package/src/adapters/pi.ts +3 -1
  67. package/src/adapters/registry-types.ts +100 -10
  68. package/src/adapters/registry.ts +127 -11
  69. package/src/cli/console-state.ts +5 -5
  70. package/src/cli/identity.ts +8 -7
  71. package/src/cli/jcode-mcp.ts +2 -58
  72. package/src/cli/launch.ts +41 -102
  73. package/src/cli/main.ts +126 -290
  74. package/src/cli/output.ts +3 -2
  75. package/src/cli/preview.ts +26 -126
  76. package/src/cli/recovery-guidance.ts +43 -22
  77. package/src/cli/setup.ts +2 -80
  78. package/src/cli/shell-quote.ts +3 -0
  79. package/src/cli/terminal-recovery.ts +81 -22
  80. package/src/cli/upgrade-interactive.ts +4 -2
  81. package/src/cli/upgrade-restore-state.ts +17 -7
  82. package/src/cli/upgrade-runtime.ts +126 -147
  83. package/src/cli/upgrade.ts +44 -27
  84. package/src/hub/budget.ts +59 -31
  85. package/src/hub/bus.ts +1 -1
  86. package/src/hub/conductor.ts +19 -9
  87. package/src/hub/config-apply.ts +22 -15
  88. package/src/hub/config-reload.ts +18 -3
  89. package/src/hub/config-trust.ts +1 -1
  90. package/src/hub/context-window.ts +3 -27
  91. package/src/hub/control-client.ts +1 -1
  92. package/src/hub/crash.ts +15 -39
  93. package/src/hub/daemon.ts +506 -341
  94. package/src/hub/execution-budget.ts +3 -2
  95. package/src/hub/hub-tools.ts +19 -8
  96. package/src/hub/instance-budget.ts +73 -0
  97. package/src/hub/instance-config.ts +47 -0
  98. package/src/hub/instance-execution.ts +50 -0
  99. package/src/hub/instances.ts +100 -0
  100. package/src/hub/lifecycle-schema.ts +7 -3
  101. package/src/hub/peer-catalog.ts +4 -13
  102. package/src/hub/permission-mode.ts +14 -17
  103. package/src/hub/recovery-continuity.ts +6 -5
  104. package/src/hub/report.ts +9 -8
  105. package/src/hub/roster.ts +9 -9
  106. package/src/hub/routing.ts +94 -19
  107. package/src/hub/sdlc-queue.ts +2 -2
  108. package/src/hub/sdlc.ts +84 -38
  109. package/src/hub/settings.ts +39 -10
  110. package/src/hub/settled-roles.ts +1 -1
  111. package/src/hub/snapshots.ts +40 -9
  112. package/src/hub/start-mode.ts +13 -8
  113. package/src/hub/status-roster.ts +8 -0
  114. package/src/hub/tasks.ts +60 -27
  115. package/src/hub/text-projection.ts +7 -3
  116. package/src/hub/tooling.ts +4 -60
  117. package/src/hub/ui.ts +3 -1
  118. package/src/hub/workflow.ts +30 -52
  119. package/src/memory/recall.ts +4 -5
  120. package/src/models/route/signals.ts +3 -1
  121. package/src/pi/launch.ts +5 -3
  122. package/src/ui/index.html +4 -3
  123. package/src/ui/office-model.ts +3 -1
  124. package/src/ui/peer-palette.ts +16 -6
  125. package/templates/AGENTS.block.md +2 -1
  126. package/templates/routing.toml +2 -2
@@ -1,3 +1,5 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
2
+ import { readInstanceConfig } from "../hub/instance-config.ts";
1
3
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
2
4
  import { checkedSessionPath } from "./recovery-session-file.ts";
3
5
  import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
@@ -13,6 +15,13 @@ import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type Recov
13
15
  import type { TerminalBinding } from "./terminal-recovery.ts";
14
16
  import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
15
17
 
18
+ const projectDefinition = (peer: string, root?: string) => {
19
+ const builtin = definitionOf(peer);
20
+ if (builtin || !root) return builtin;
21
+ try { return definitionOf(peer, readInstanceConfig(root).instances); } catch { return undefined; }
22
+ };
23
+
24
+
16
25
  export interface RecoveryPeer {
17
26
  id: string;
18
27
  state: string;
@@ -287,7 +296,8 @@ export function nextChoices(receipt: RecoveryOperation, runner?: number | "unkno
287
296
  const stuck = resumeBlocked(op, live);
288
297
  const selected = op.phase === "blocked" ? op.blockedReattachment : undefined;
289
298
  const repair = selected && op.projects.find(project => project.id === selected.projectId)?.reattachments?.[selected.peer];
290
- const reattach = selected && repair && (repair.state === "ready" || repairWaiting(op, selected.projectId, selected.peer));
299
+ const reattach = selected && repair && projectDefinition(selected.peer, op.plan.projects.find(project => project.project.id === selected.projectId)?.project.root)?.session.launchRecord === true &&
300
+ (repair.state === "ready" || repairWaiting(op, selected.projectId, selected.peer));
291
301
  return [
292
302
  ...(stuck ? [] : [{ kind: "resume" as const, text: `${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}` }]),
293
303
  ...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
@@ -322,12 +332,12 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
322
332
  return Object.fromEntries(await Promise.all(open.map(async (p) => [p.project.id, await inspect(p.project).catch(() => undefined)] as const)));
323
333
  }
324
334
 
325
- function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string): boolean {
335
+ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string, options: { authority?: "current" | "historical" } = {}): boolean {
326
336
  const evidence = op.missingRecordedSession;
327
337
  if (!evidence || evidence.projectId !== progress.id || evidence.peer !== peer || (!progress.repairedSessions?.[evidence.peer] && !Object.hasOwn(progress.reattachments ?? {}, peer))) return false;
328
338
  const planned = op.plan.projects.find(project => project.project.id === progress.id);
329
339
  if (!planned) return false;
330
- try { return resolveAcceptedBinding(planned, progress, evidence.peer)?.sessionId === evidence.sessionId; } catch { return false; }
340
+ try { return resolveAcceptedBinding(planned, progress, evidence.peer, options)?.sessionId === evidence.sessionId; } catch { return false; }
331
341
  }
332
342
  /**
333
343
  * Peers of a project whose restoration failed and for which a fresh session is a real choice: not Pi (a restored hub
@@ -336,8 +346,8 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
336
346
  */
337
347
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
338
348
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
339
- return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
340
- .filter((peer) => peer !== "pi" && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
349
+ return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && (() => { try { return canChooseFresh(value); } catch { return false; } })()).map(([key]) => key.slice("restored:".length))
350
+ .filter((peer) => projectDefinition(peer, planned?.project.root)?.session.personFresh === true && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
341
351
  }
342
352
 
343
353
  /** Read-only scope classification; it confers no delegation authority. */
@@ -363,7 +373,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
363
373
  ? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
364
374
  : (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
365
375
  const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
366
- return !!binding && (peer === "codex" ? current?.threadId : current?.sessionId) === binding.sessionId;
376
+ return !!binding && current?.[projectDefinition(peer, planned?.project.root)?.session.idField ?? "sessionId"] === binding.sessionId;
367
377
  }
368
378
 
369
379
  /** Registry reads for planning must not create a registry or run migrations. */
@@ -413,7 +423,10 @@ function abandoned(op: RecoveryOperation): boolean {
413
423
  return true;
414
424
  }
415
425
 
416
- function validateReceipt(id: string, op: RecoveryOperation): void {
426
+ function validateReceipt(id: string, op: RecoveryOperation, historical = false): void {
427
+ // Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
428
+ const options = historical ? { authority: "historical" as const } : {};
429
+ const decode = (value: unknown) => decodeRestoreReceipt(value, options);
417
430
  if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
418
431
  if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
419
432
  const { fingerprint, ...reviewed } = op.plan;
@@ -424,13 +437,13 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
424
437
  if (progress.repairedSessions !== undefined) {
425
438
  if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
426
439
  for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
427
- const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
440
+ const restored = decode(progress.terminals[`restored:${peer}`]);
428
441
  const bindings = [progress.reattachments?.[peer as RecoveryNativePeer]?.binding,
429
442
  restored?.state === "done" || restored?.state === "waiting" ? restored.binding : undefined,
430
443
  progress.terminals[`retired:${peer}`]];
431
444
  if (protectedSession !== true || !bindings.some(binding => {
432
445
  if (binding === undefined) return false;
433
- const receipt = decodeRestoreReceipt({ state: "done", binding });
446
+ const receipt = decode({ state: "done", binding });
434
447
  return receipt?.state === "done" && receipt.binding.peer === peer && receipt.binding.projectRoot === op.plan.projects[index]?.project.root;
435
448
  })) throw new Error("repaired session provenance does not match its retained peer and project");
436
449
  }
@@ -439,14 +452,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
439
452
  if (choice.repaired === undefined) continue;
440
453
  const planned = op.plan.projects[index]!;
441
454
  if (choice.repaired !== true || !progress.repairedSessions?.[peer as RecoveryNativePeer] ||
442
- resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
455
+ resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer, options)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
443
456
  }
444
457
  if (progress.reattachments === undefined) continue;
445
458
  if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
446
459
  for (const [peer, marker] of Object.entries(progress.reattachments)) {
447
- if (!["codex", "pi", "claude"].includes(peer) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
460
+ if ((!historical && projectDefinition(peer, op.plan.projects[index]?.project.root)?.session.launchRecord !== true) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
448
461
  !["ready", "attempted"].includes(String(marker.state)) || marker.cause !== "detached after restore" || typeof marker.at !== "number" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid reattachment marker");
449
- const receipt = decodeRestoreReceipt({ state: "done", binding: marker.binding });
462
+ const receipt = decode({ state: "done", binding: marker.binding });
450
463
  if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== op.plan.projects[index]?.project.root) throw new Error("reattachment binding does not match its peer and project");
451
464
  }
452
465
  }
@@ -455,7 +468,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
455
468
  if (!object(evidence) || Object.keys(evidence).some(key => !["projectId", "peer", "sessionId", "kind", "checkedPath"].includes(key)) ||
456
469
  typeof evidence.projectId !== "string" || typeof evidence.peer !== "string" || typeof evidence.sessionId !== "string" ||
457
470
  evidence.checkedPath !== undefined && (typeof evidence.checkedPath !== "string" || !evidence.checkedPath) ||
458
- !["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer))) throw new Error("invalid recorded-session missing evidence");
471
+ !["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer, options))) throw new Error("invalid recorded-session missing evidence");
459
472
  }
460
473
  if (op.freshOverride !== undefined) {
461
474
  const override = op.freshOverride, evidence = override.evidence;
@@ -466,14 +479,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
466
479
  }
467
480
  if (op.blockedReattachment !== undefined) {
468
481
  const selected = op.blockedReattachment;
469
- if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || !["codex", "pi", "claude"].includes(String(selected.peer)) ||
482
+ if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || typeof selected.peer !== "string" || (!historical && projectDefinition(selected.peer, op.plan.projects.find(project => project.project.id === selected.projectId)?.project.root)?.session.launchRecord !== true) ||
470
483
  !op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
471
484
  }
472
485
  }
473
486
 
474
487
  export interface RecoveryDriver {
475
488
  stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
476
- inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
489
+ inspect(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection>;
477
490
  prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
478
491
  abort(project: Project, operation: string, instance: string): Promise<void>;
479
492
  commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
@@ -765,7 +778,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
765
778
 
766
779
  /** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
767
780
  export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
768
- const effect = (key: string, value: unknown) => key.startsWith("restored:") ? restoreReceiptStatus(value) : value === "pending" || value === "failed" ? value : "done";
781
+ const effect = (key: string, value: unknown) => {
782
+ if (!key.startsWith("restored:")) return value === "pending" || value === "failed" ? value : "done";
783
+ const receipt = decodeRestoreReceipt(value, { authority: "historical" });
784
+ return !receipt ? "unstarted" : receipt.state === "waiting" ? `waiting:${receipt.origin}` : receipt.state;
785
+ };
769
786
  const choices = nextChoices(op, runnerPid, live), next = choices.map(choice => choice.text);
770
787
  return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
771
788
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
@@ -794,7 +811,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
794
811
  let op = readOperation<RecoveryOperation>(id, home);
795
812
  validateReceipt(id, op);
796
813
  const selection = () => {
797
- if (!["codex", "pi", "claude"].includes(peer) || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
814
+ if (projectDefinition(peer, op.plan.projects.find(project => project.project.id === op.blockedReattachment?.projectId)?.project.root)?.session.launchRecord !== true || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
798
815
  const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
799
816
  const planned = op.plan.projects.find(project => project.project.id === progress?.id);
800
817
  const native = peer as RecoveryNativePeer;
@@ -819,7 +836,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
819
836
  };
820
837
  await target();
821
838
  attempted = true;
822
- await driver.restore(planned, progress, op, native === "claude" ? "claude" : "native", save, native);
839
+ await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport === "claude-channel" ? "claude" : "native", save, native);
823
840
  // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
824
841
  // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
825
842
  const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
@@ -852,14 +869,14 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
852
869
  /** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
853
870
  export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
854
871
  let op = readOperation<RecoveryOperation>(id, home);
855
- validateReceipt(id, op);
872
+ validateReceipt(id, op, true);
856
873
  acquireRecoveryLock(id, home);
857
874
  const releaseRunner = claimRunner(id, home);
858
875
  try {
859
876
  // Re-read after the exclusive runner claim so cancellation cannot act on a
860
877
  // stale pending/prepared receipt after another runner advanced it.
861
878
  op = readOperation<RecoveryOperation>(id, home);
862
- validateReceipt(id, op);
879
+ validateReceipt(id, op, true);
863
880
  if (op.phase === "completed" || op.phase === "cancelled") {
864
881
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
865
882
  return;
@@ -867,7 +884,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
867
884
  if (abandoned(op)) writeOperation(id, op, home);
868
885
  const { fingerprint, ...body } = op.plan;
869
886
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
870
- const live = await liveProjects(op, driver.inspect);
887
+ const live = await liveProjects(op, project => driver.inspect(project, undefined, "cleanup"));
871
888
  const refusal = abortRefusal(op, live);
872
889
  if (refusal) throw new Error(`abort refused: ${refusal}; ${nextActionsText(op, live)}`);
873
890
  // Only this operation's own uncommitted holds are aborted; a lapsed hold, a replacement daemon or another
@@ -892,7 +909,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
892
909
  */
893
910
  export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
894
911
  let op: RecoveryOperation;
895
- try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
912
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
896
913
  catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
897
914
  if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
898
915
  // A finished operation owns no lock: never take one only to refuse.
@@ -900,7 +917,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
900
917
  acquireRecoveryLock(id, home);
901
918
  const releaseRunner = claimRunner(id, home);
902
919
  try {
903
- try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
920
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
904
921
  catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
905
922
  if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
906
923
  if (op.phase === "completed" || op.phase === "cancelled") {
@@ -911,11 +928,11 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
911
928
  if (abandoned(op)) writeOperation(id, op, home);
912
929
  const at = driver.now();
913
930
  // Read once, as status and the runner do: every choice and refusal below is decided on it.
914
- const live = await liveProjects(op, driver.inspect);
931
+ const live = await liveProjects(op, project => driver.inspect(project, undefined, "stop" in choice ? "cleanup" : undefined));
915
932
  const next = nextActionsText(op, live);
916
933
  if ("fresh" in choice) {
917
934
  // `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
918
- if (choice.fresh === "pi") throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP))).join("; ")}; ${next}`);
935
+ if (op.plan.projects.some(planned => projectDefinition(choice.fresh, planned.project.root)?.session.personFresh === false)) throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.filter(planned => projectDefinition(choice.fresh, planned.project.root)?.session.personFresh === false).map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP), choice.fresh)).join("; ")}; ${next}`);
919
936
  if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
920
937
  if (!targetReadsWaivers(op)) throw new Error(`--fresh-session is not available: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session; ${next}`);
921
938
  // The new session is launched by resume, so it helps only where resume can get past what is live.
@@ -996,7 +1013,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
996
1013
  else if (act === "stopped" || act === "target stopped") {
997
1014
  const outcome = await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
998
1015
  if (outcome && outcome.continuity) (disposition.continuity ??= {})[planned.project.id] = outcome.continuity;
999
- if ((await driver.inspect(planned.project)).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
1016
+ if ((await driver.inspect(planned.project, undefined, "cleanup")).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
1000
1017
  }
1001
1018
  disposition.projects[planned.project.id] ??= act;
1002
1019
  op.updatedAt = driver.now(); writeOperation(id, op, home);
@@ -1004,7 +1021,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
1004
1021
  } catch (error) {
1005
1022
  if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
1006
1023
  op.phase = "blocked"; op.updatedAt = driver.now();
1007
- op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
1024
+ op.error = `stop-and-archive stopped partway and keeps the lock: ${error instanceof Error ? error.message : "disposition failed"} (a committed source stops by itself); ${nextActionsText(op, await liveProjects(op, project => driver.inspect(project, undefined, "cleanup")))}`;
1008
1025
  writeOperation(id, op, home);
1009
1026
  throw error;
1010
1027
  }
package/src/hub/budget.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import type { PeerId } from "./envelope.ts";
3
4
  import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
@@ -45,6 +46,8 @@ export interface BudgetConfig {
45
46
  export const DEFAULT_BUDGET: BudgetConfig = { gate: 0.9, stale_min: 30, poll_min: 10, checkpoint_timeout_s: 90, kimi_tokens_5h: 0, wait_max_min: 0 };
46
47
 
47
48
  export interface BudgetHooks {
49
+ /** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
50
+ account?(peer: PeerId): PeerId;
48
51
  pause(peer: PeerId): void;
49
52
  /** Must leave a manual `ahub pause` in place. */
50
53
  resume(peer: PeerId): void;
@@ -70,8 +73,6 @@ const WAIT_NOTE = "; keeps its work:";
70
73
  const WAITED_NOTE = "; waited, then the reset moved to";
71
74
  const waitedNote = (min: number, max: number) => `${WAITED_NOTE} ${min} min, beyond wait_max_min ${max}`;
72
75
  const FALLBACK_WINDOW_MINS = 300;
73
- /** Peers the coordinator never pauses: the local worker has no quota. */
74
- const EXEMPT = new Set<PeerId>(["local", "user", "hub"]);
75
76
 
76
77
  /**
77
78
  * Budget relay. Readings come from the adapters (Codex rate limits, Claude's status line, Kimi tokens) or from
@@ -102,6 +103,43 @@ export class Budget {
102
103
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
103
104
  this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
104
105
  reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
106
+ try { this.normalizeStoredPauses(); } catch (error) { this.db.close(); throw error; }
107
+ }
108
+
109
+ private account(peer: PeerId): PeerId {
110
+ if (!this.hooks.account) return peer;
111
+ const account = this.hooks.account(peer);
112
+ if (typeof account !== "string" || !account.trim() || this.hooks.account(account) !== account) throw new Error("budget account resolver must return a canonical idempotent id");
113
+ return account;
114
+ }
115
+
116
+ /** A newly declared alias must not erase an older custom peer's persisted pause. */
117
+ private normalizeStoredPauses(): void {
118
+ if (!this.hooks.account) return; // identity/default startup leaves existing storage byte-for-byte alone
119
+ this.db.transaction(() => {
120
+ const rows = (this.db.query("SELECT peer FROM budget_pauses ORDER BY peer").all() as { peer: string }[]).map(row => this.readRecord(row.peer)!);
121
+ const groups = new Map<PeerId, PauseRecord[]>();
122
+ for (const row of rows) {
123
+ const account = this.account(row.peer), group = groups.get(account) ?? [];
124
+ group.push(row); groups.set(account, group);
125
+ }
126
+ for (const [peer, group] of groups) {
127
+ if (group.length === 1 && group[0]!.peer === peer) continue;
128
+ // Conservative collision reduction: retain the longest pause, earliest start and every distinct move.
129
+ // Any unfinished handoff stays unfinished so the daemon's account fanout retries remaining open work.
130
+ const summaries = group.filter(row => row.summary !== null).map(row => `[${row.peer}]\n${row.summary}`);
131
+ const moved = [...new Map(group.flatMap(row => row.moved).map(move => [JSON.stringify(move), move])).values()];
132
+ const first = group[0]!;
133
+ const record = group.length === 1 ? { ...first, peer } : {
134
+ peer, since: Math.min(...group.map(row => row.since)), resetsAt: Math.max(...group.map(row => row.resetsAt)),
135
+ reason: [...new Set(group.map(row => row.reason))].join(" | "), summary: summaries.length ? summaries.join("\n\n") : null,
136
+ handedOff: group.every(row => row.handedOff), moved,
137
+ };
138
+ for (const row of group) this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(row.peer);
139
+ this.db.query("INSERT INTO budget_pauses (peer, since, resets_at, reason, summary, handed_off, moved) VALUES (?, ?, ?, ?, ?, ?, ?)")
140
+ .run(record.peer, record.since, record.resetsAt, record.reason, record.summary, record.handedOff ? 1 : 0, JSON.stringify(record.moved));
141
+ }
142
+ }).immediate();
105
143
  }
106
144
 
107
145
  /** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
@@ -122,6 +160,10 @@ export class Budget {
122
160
  }
123
161
 
124
162
  record(peer: PeerId): PauseRecord | undefined {
163
+ return this.readRecord(this.account(peer));
164
+ }
165
+
166
+ private readRecord(peer: PeerId): PauseRecord | undefined {
125
167
  const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
126
168
  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;
127
169
  }
@@ -156,12 +198,17 @@ export class Budget {
156
198
  /** `hard`: the peer cannot run another turn (its provider refused one), so there is nobody to ask for a checkpoint. */
157
199
  /** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
158
200
  report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
159
- if (EXEMPT.has(peer) || this.closed) return;
201
+ // Execution-budget instrumentation is independent of native quota accounting.
202
+ // Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
203
+ const reporter = peer;
204
+ peer = this.account(peer);
205
+ const adapter = definitionOf(reporter) ?? definitionOf(peer);
206
+ if (reporter === "user" || reporter === "hub" || peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
160
207
  // An override ignores soft readings, not a provider that has actually refused another turn.
161
208
  if (hard && this.ignoreUntil.delete(peer)) {
162
209
  try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
163
210
  }
164
- try { this.hooks.reading?.(peer, windows, hard, at); } catch { /* telemetry never costs a reading */ }
211
+ try { this.hooks.reading?.(reporter, windows, hard, at); } catch { /* telemetry never costs a reading */ }
165
212
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
166
213
  this.readings.set(peer, mine);
167
214
  for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
@@ -171,12 +218,14 @@ export class Budget {
171
218
 
172
219
  /** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
173
220
  private fresh(peer: PeerId): Reading[] {
221
+ peer = this.account(peer);
174
222
  const staleMs = this.cfg.stale_min * 60_000;
175
223
  return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
176
224
  }
177
225
 
178
226
  /** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
179
227
  override(peer: PeerId): boolean {
228
+ peer = this.account(peer);
180
229
  const open = this.record(peer);
181
230
  if (!open) return false;
182
231
  this.ignoreUntil.set(peer, open.resetsAt);
@@ -188,6 +237,7 @@ export class Budget {
188
237
  }
189
238
 
190
239
  private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
240
+ peer = this.account(peer);
191
241
  if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
192
242
  const fresh = this.fresh(peer);
193
243
  const over = fresh.filter((r) => r.used >= this.cfg.gate);
@@ -250,6 +300,7 @@ export class Budget {
250
300
  }
251
301
 
252
302
  private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
303
+ peer = this.account(peer);
253
304
  if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
254
305
  if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
255
306
  this.handingOff.add(peer);
@@ -262,6 +313,7 @@ export class Budget {
262
313
  }
263
314
 
264
315
  private async handOff(peer: PeerId, summary: string | undefined): Promise<void> {
316
+ peer = this.account(peer);
265
317
  const context = summary ?? (await this.hooks.platformContext(peer).catch(() => undefined));
266
318
  // Wait or hand off (issue #36): a handoff costs the next peer the whole context, so a short wait can be cheaper.
267
319
  const resetsAt = this.record(peer)?.resetsAt;
@@ -296,6 +348,7 @@ export class Budget {
296
348
 
297
349
  /** `hub_checkpoint` arrived from a peer. Resolved by the daemon's requestCheckpoint wait; kept on the record for a restart. */
298
350
  checkpointed(peer: PeerId, summary: string): void {
351
+ peer = this.account(peer);
299
352
  if (this.closed) return; // a hub_checkpoint that lands during shutdown
300
353
  this.db.query("UPDATE budget_pauses SET summary = ? WHERE peer = ?").run(summary, peer);
301
354
  }
@@ -312,6 +365,7 @@ export class Budget {
312
365
  }
313
366
 
314
367
  private resume(peer: PeerId, why: string): void {
368
+ peer = this.account(peer);
315
369
  const record = this.record(peer);
316
370
  if (!record) return;
317
371
  this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
@@ -356,30 +410,4 @@ export class Budget {
356
410
  }
357
411
  }
358
412
 
359
- /** Codex `rateLimits` snapshot (account/rateLimits/read, account/rateLimits/updated) -> windows. */
360
- export function codexWindows(rateLimits: any): UsageWindow[] {
361
- const out: UsageWindow[] = [];
362
- // The slot decides (primary = short window, secondary = long one) and the duration overrides it when present:
363
- // readings are keyed by window id, so two windows must never both come out as "5h".
364
- for (const [w, slot] of [[rateLimits?.primary, "5h"], [rateLimits?.secondary, "week"]] as const) {
365
- if (!w || typeof w.usedPercent !== "number") continue;
366
- const mins = typeof w.windowDurationMins === "number" ? w.windowDurationMins : undefined;
367
- const id = mins === undefined ? slot : mins > 600 ? "week" : "5h";
368
- if (out.some((o) => o.id === id)) continue; // both slots claim the same window: keep the first, never overwrite
369
- out.push({ id, used: w.usedPercent / 100, ...(w.resetsAt ? { resetsAt: w.resetsAt * 1000 } : {}), ...(mins ? { windowMins: mins } : {}), source: "codex rateLimits" });
370
- }
371
- if (rateLimits?.rateLimitReachedType && !out.some((w) => w.used >= 1)) out.push({ id: "5h", used: 1, source: `codex ${rateLimits.rateLimitReachedType}` });
372
- return out;
373
- }
374
-
375
- /** Claude Code status line `rate_limits` -> windows. */
376
- export function claudeWindows(rateLimits: any): UsageWindow[] {
377
- const out: UsageWindow[] = [];
378
- for (const [key, id, mins] of [["five_hour", "5h", 300], ["seven_day", "week", 10_080]] as const) {
379
- const w = rateLimits?.[key];
380
- if (!w || typeof w.used_percentage !== "number") continue;
381
- const resets = Number(w.resets_at);
382
- out.push({ id, used: w.used_percentage / 100, ...(resets ? { resetsAt: resets < 1e12 ? resets * 1000 : resets } : {}), windowMins: mins, source: "claude status line" });
383
- }
384
- return out;
385
- }
413
+ export { codexWindows, claudeWindows } from "../adapters/definitions/native-quota.ts";
package/src/hub/bus.ts CHANGED
@@ -974,7 +974,7 @@ export class Bus {
974
974
  }
975
975
 
976
976
  /**
977
- * What one push delivery would take (the preface, then `take`'s batch), handed to a pull-only peer and recorded as
977
+ * What one push delivery would take (the preface, then `take`'s batch), explicitly pulled by its attached session and recorded as
978
978
  * completed: the tool result that returns it is the readback, so nothing waits for settlement and nothing is ever
979
979
  * `accepted` (issue #205). It is checkpointed like a push, so queued rows of the same envelopes (an operator retry)
980
980
  * are grouped into it, and registered for `reply_to` like a push. Undefined while held. A failed journal write puts
@@ -1,10 +1,11 @@
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import type { Task } from "./board.ts";
3
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
4
5
  import { HUB, USER } from "./envelope.ts";
5
6
  import { OWNERSHIP_EVENTS, type TaskAssignmentRole } from "./tasks.ts";
6
7
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
7
- import { isStartablePeer, type StartablePeer, type StartMode } from "./start-mode.ts";
8
+ import type { StartMode } from "./start-mode.ts";
8
9
  import type { HubEvent } from "./events.ts";
9
10
  import type { SupervisionFeed } from "./supervision.ts";
10
11
  import type { Budget } from "./budget.ts";
@@ -103,18 +104,23 @@ export class ConductorHolds {
103
104
  close(): void { this.db.close(); }
104
105
  }
105
106
 
106
- export type ConductorStart = { peer: StartablePeer; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
107
+ export type ConductorStart = { peer: string; mode: "headless" } | { peer: "claude" | "codex" | "pi"; mode: "tui"; command: string };
107
108
  /**
108
109
  * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
109
110
  * only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
110
111
  */
111
- export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: StartablePeer) => StartMode): ConductorStart {
112
+ export function conductorStart(peer: unknown, mode: unknown, preview: (peer: "claude" | "codex" | "pi") => string, preEffect: boolean, startMode: (peer: string) => StartMode, instances?: KindMap): ConductorStart {
112
113
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
113
114
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
114
- if (!isStartablePeer(peer)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
115
- const own = startMode(peer);
115
+ const adapter = typeof peer === "string" ? definitionOf(peer, instances) : undefined;
116
+ if (typeof peer !== "string" || !adapter?.start.conductorStartable) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
117
+ const selected = peer;
118
+ const extra = adapter.kind !== selected;
119
+ if (extra && (!adapter.start.create || !adapter.start.forms.includes("headless") || !["env", "acp-session"].includes(adapter.identity.mcp))) throw refuse(`${peer}: this native transport does not support extra headless instances`);
120
+ const own = startMode(selected);
121
+ if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
116
122
  if (mode !== undefined && mode !== own) throw refuse(`${peer} starts ${own} here; a person sets a start mode (ahub settings), the conductor cannot choose one`);
117
- return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer, mode: "headless" };
123
+ return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
118
124
  }
119
125
 
120
126
  const numberOrNull = (v: unknown): number | null => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
@@ -160,9 +166,11 @@ export interface ConductorHooks {
160
166
  escalate(actor: string, id: number): Promise<unknown>;
161
167
  preview(peer: "claude" | "codex" | "pi"): string;
162
168
  /** The mode a start of this peer takes: a person's setting. */
163
- startMode(peer: StartablePeer): StartMode;
169
+ startMode(peer: string): StartMode;
164
170
  /** Starts the peer in that mode: headless at once, or its fixed command in a terminal; throws with the command to run by hand when no terminal can be opened. */
165
- start(peer: StartablePeer): Promise<unknown>;
171
+ start(peer: string): Promise<unknown>;
172
+ /** Explicit project declarations only; an undeclared suffix never acquires a native kind. */
173
+ instances?(): KindMap;
166
174
  known(peer: string): boolean;
167
175
  pause(peer: string): void;
168
176
  validateRelease?(peer: string): Promise<void>;
@@ -227,7 +235,9 @@ export class Conductor {
227
235
  return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
228
236
  }
229
237
  if (tool === "hub_peer_start") {
230
- const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode);
238
+ const instances = this.hooks.instances?.();
239
+ const start = conductorStart(args.peer, args.mode, this.hooks.preview, true, this.hooks.startMode, instances);
240
+ if (definitionOf(start.peer, instances)?.kind !== start.peer && !this.hooks.known(start.peer)) throw new PreEffectToolRefusal(`unknown peer: ${start.peer}`);
231
241
  const result = await this.hooks.start(start.peer);
232
242
  const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
233
243
  emit({ peer: start.peer }); return { ...start, ...opened };
@@ -1,6 +1,6 @@
1
1
  import type { HubConfig } from "./daemon.ts";
2
2
  import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked } from "./config-trust.ts";
3
- import { SUPPORTED_PEERS, PLANNER_PEERS } from "./peer-catalog.ts";
3
+ import { ADAPTERS, SUPPORTED_PEERS, PLANNER_PEERS, START_MODE_PEERS } from "../adapters/registry.ts";
4
4
  import { CLASSES } from "./board.ts";
5
5
  import { permissionDefaults } from "./permission-mode.ts";
6
6
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
@@ -8,6 +8,8 @@ import { catalogAssignments } from "./roles.ts";
8
8
  import { resolveProfile } from "./profiles.ts";
9
9
  import { taskSweepConfig } from "./task-sweep.ts";
10
10
  import { projectionConfig } from "./text-projection.ts";
11
+ import { validateInstances, type InstancesConfig } from "./instances.ts";
12
+ import { peerRosterConfig } from "./roster.ts";
11
13
 
12
14
  export type ApplyClass = "live" | "next use" | "restart";
13
15
  export interface ApplyRule { applies: ApplyClass; reason?: string }
@@ -103,9 +105,12 @@ const block = (schema: Record<string, Check>): Check => {
103
105
  };
104
106
  const peerMap = (check: Check) => map(check, SUPPORTED_PEERS);
105
107
  const timerSeconds = number(1, 2_147_483_647 / 1000), timerMinutes = number(1, 2_147_483_647 / 60_000);
108
+ const nativeValidators = { bool, text, strings, argv, number, words, block };
106
109
  const schema: Record<string, Check> = {
110
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
107
111
  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
112
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
113
+ instances: v => { validateInstances(v); },
109
114
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
110
115
  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
116
  short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
@@ -119,11 +124,9 @@ const schema: Record<string, Check> = {
119
124
  context: block({ gate: number(0, 1), stale_min: timerMinutes }),
120
125
  inference: block({ enabled: bool, digest_min_items: number(1, 100_000, true), digest_min_chars: number(1, 10_000_000, true), triage: bool }),
121
126
  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
127
  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
128
  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)) }),
129
+ approvals: block({ timeout_s: timerSeconds, notify: bool }), tasks: block({ release_after_min: number(0), wip_limits: map(number(0, 32, true)), escalate_after: number(1, 10, true) }),
127
130
  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
131
  checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
129
132
  if (value === undefined) continue;
@@ -132,36 +135,40 @@ const schema: Record<string, Check> = {
132
135
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
133
136
  } },
134
137
  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); },
138
+ peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
136
139
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
137
140
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
138
141
  review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true) }), recovery: block({ auto_resume_after_crash: bool }),
139
142
  capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
140
143
  };
141
- export function validateRawConfig(raw: unknown): asserts raw is Record<string, unknown> { block(schema)(raw, "config"); }
144
+ export function validateRawConfig(raw: unknown, declarations?: InstancesConfig): asserts raw is Record<string, unknown> {
145
+ const value = object(raw, "config");
146
+ const instances = validateInstances(value.instances ?? declarations);
147
+ if (!Object.keys(instances).length) return block(schema)(raw, "config");
148
+ block({ ...schema, permission_modes: v => { permissionDefaults(v, instances); },
149
+ roster: v => { peerRosterConfig(v, instances); }, peers: v => { peerStartConfig(v, instances); },
150
+ })(raw, "config");
151
+ }
142
152
  export function validateEffectiveConfig(value: unknown): asserts value is HubConfig {
143
153
  const raw = { ...object(value, "config") };
144
154
  delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
145
155
  validateRawConfig(raw);
146
156
  const profile = resolveProfile(raw.profile, raw.profile_overrides);
147
157
  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");
158
+ for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
149
159
  }
150
160
 
151
161
  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",
162
+ instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
163
+ ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
155
164
  memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
156
165
  omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
157
166
  checks: "Bounded runner and completion-check contracts are constructed together",
158
167
  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
168
  profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
162
169
  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
170
  };
164
- const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "approvals.notify"]);
171
+ const live = new Set(["permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "approvals.notify"]);
165
172
  const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
166
173
  /** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
167
174
  export function configApply(key: string): ApplyRule {
@@ -178,11 +185,11 @@ export function configApply(key: string): ApplyRule {
178
185
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
179
186
  else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
180
187
  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;
188
+ else if (["permission_modes", "catalog_roles", "profile_overrides", "text_projection", "peers", "instances"].includes(top)) break;
182
189
  else return fail(key, "unknown configuration key");
183
190
  }
184
191
  }
185
- if (top === "routing" && !key.startsWith("routing.pi") && key !== "routing") {
192
+ if (top === "routing" && !ADAPTERS.some(adapter => Object.keys(adapter.configuration?.restartReasons ?? {}).some(prefix => prefix.startsWith("routing.") && key.startsWith(prefix))) && key !== "routing") {
186
193
  return { applies: ["classes", "stay_switch", "signals", "constraints", "hub_routes", "max_switch_prefill_tokens"].includes(key.split(".")[1]!) ? "live" : "next use" };
187
194
  }
188
195
  if (nextMemory.has(key)) return { applies: "next use" };