@staix/agent-hub 0.12.34 → 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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/budget.md +3 -1
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +5 -1
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +4 -1
  9. package/docs/gsd-collaboration-analysis.md +5 -1
  10. package/docs/operations.md +67 -13
  11. package/docs/quickstart.md +8 -2
  12. package/docs/sdlc.md +59 -34
  13. package/docs/security.md +3 -3
  14. package/docs/specs/2026-09-19-agent-hub-design.md +161 -6
  15. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  16. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  17. package/docs/verified.json +21 -16
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +15438 -14863
  21. package/src/adapters/acp.ts +3 -1
  22. package/src/adapters/claude-channel.ts +59 -29
  23. package/src/adapters/codex-appserver.ts +1 -1
  24. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  25. package/src/adapters/definitions/native-cli-entry-jcode.ts +4 -1
  26. package/src/adapters/definitions/native-cli-entry-kimi.ts +16 -11
  27. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  28. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  29. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  30. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  31. package/src/adapters/definitions/native-recovery.ts +2 -2
  32. package/src/adapters/definitions/qwen-native.ts +2 -2
  33. package/src/adapters/local-worker.ts +3 -2
  34. package/src/adapters/pi.ts +3 -1
  35. package/src/adapters/registry-types.ts +3 -0
  36. package/src/adapters/registry.ts +24 -8
  37. package/src/cli/identity.ts +3 -3
  38. package/src/cli/launch.ts +29 -4
  39. package/src/cli/main.ts +101 -28
  40. package/src/cli/preview.ts +16 -5
  41. package/src/cli/recovery-guidance.ts +39 -22
  42. package/src/cli/terminal-recovery.ts +68 -16
  43. package/src/cli/upgrade-restore-state.ts +16 -7
  44. package/src/cli/upgrade-runtime.ts +67 -39
  45. package/src/cli/upgrade.ts +43 -27
  46. package/src/hub/budget.ts +55 -3
  47. package/src/hub/bus.ts +1 -1
  48. package/src/hub/conductor.ts +16 -10
  49. package/src/hub/config-apply.ts +13 -2
  50. package/src/hub/config-reload.ts +11 -1
  51. package/src/hub/config-trust.ts +1 -1
  52. package/src/hub/control-client.ts +1 -1
  53. package/src/hub/crash.ts +14 -5
  54. package/src/hub/daemon.ts +296 -125
  55. package/src/hub/execution-budget.ts +3 -3
  56. package/src/hub/hub-tools.ts +17 -6
  57. package/src/hub/instance-budget.ts +73 -0
  58. package/src/hub/instance-config.ts +47 -0
  59. package/src/hub/instance-execution.ts +50 -0
  60. package/src/hub/instances.ts +100 -0
  61. package/src/hub/lifecycle-schema.ts +7 -3
  62. package/src/hub/permission-mode.ts +7 -7
  63. package/src/hub/roster.ts +7 -6
  64. package/src/hub/routing.ts +82 -18
  65. package/src/hub/sdlc-queue.ts +2 -2
  66. package/src/hub/sdlc.ts +84 -38
  67. package/src/hub/settings.ts +34 -6
  68. package/src/hub/snapshots.ts +40 -9
  69. package/src/hub/start-mode.ts +7 -4
  70. package/src/hub/status-roster.ts +8 -0
  71. package/src/hub/tasks.ts +53 -23
  72. package/src/hub/text-projection.ts +7 -3
  73. package/src/hub/workflow.ts +30 -52
  74. package/src/pi/launch.ts +4 -3
  75. package/src/ui/index.html +1 -1
  76. package/templates/AGENTS.block.md +2 -1
@@ -1,4 +1,5 @@
1
1
  import { definitionOf } from "../adapters/registry.ts";
2
+ import { readInstanceConfig } from "../hub/instance-config.ts";
2
3
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
3
4
  import { checkedSessionPath } from "./recovery-session-file.ts";
4
5
  import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
@@ -14,6 +15,13 @@ import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type Recov
14
15
  import type { TerminalBinding } from "./terminal-recovery.ts";
15
16
  import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
16
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
+
17
25
  export interface RecoveryPeer {
18
26
  id: string;
19
27
  state: string;
@@ -288,7 +296,8 @@ export function nextChoices(receipt: RecoveryOperation, runner?: number | "unkno
288
296
  const stuck = resumeBlocked(op, live);
289
297
  const selected = op.phase === "blocked" ? op.blockedReattachment : undefined;
290
298
  const repair = selected && op.projects.find(project => project.id === selected.projectId)?.reattachments?.[selected.peer];
291
- 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));
292
301
  return [
293
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)` : ""}` }]),
294
303
  ...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
@@ -323,12 +332,12 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
323
332
  return Object.fromEntries(await Promise.all(open.map(async (p) => [p.project.id, await inspect(p.project).catch(() => undefined)] as const)));
324
333
  }
325
334
 
326
- function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string): boolean {
335
+ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string, options: { authority?: "current" | "historical" } = {}): boolean {
327
336
  const evidence = op.missingRecordedSession;
328
337
  if (!evidence || evidence.projectId !== progress.id || evidence.peer !== peer || (!progress.repairedSessions?.[evidence.peer] && !Object.hasOwn(progress.reattachments ?? {}, peer))) return false;
329
338
  const planned = op.plan.projects.find(project => project.project.id === progress.id);
330
339
  if (!planned) return false;
331
- 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; }
332
341
  }
333
342
  /**
334
343
  * Peers of a project whose restoration failed and for which a fresh session is a real choice: not Pi (a restored hub
@@ -337,8 +346,8 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
337
346
  */
338
347
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
339
348
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
340
- return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
341
- .filter((peer) => definitionOf(peer)?.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));
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));
342
351
  }
343
352
 
344
353
  /** Read-only scope classification; it confers no delegation authority. */
@@ -364,7 +373,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
364
373
  ? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
365
374
  : (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
366
375
  const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
367
- return !!binding && current?.[definitionOf(peer)?.session.idField ?? "sessionId"] === binding.sessionId;
376
+ return !!binding && current?.[projectDefinition(peer, planned?.project.root)?.session.idField ?? "sessionId"] === binding.sessionId;
368
377
  }
369
378
 
370
379
  /** Registry reads for planning must not create a registry or run migrations. */
@@ -414,7 +423,10 @@ function abandoned(op: RecoveryOperation): boolean {
414
423
  return true;
415
424
  }
416
425
 
417
- 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);
418
430
  if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
419
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");
420
432
  const { fingerprint, ...reviewed } = op.plan;
@@ -425,13 +437,13 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
425
437
  if (progress.repairedSessions !== undefined) {
426
438
  if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
427
439
  for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
428
- const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
440
+ const restored = decode(progress.terminals[`restored:${peer}`]);
429
441
  const bindings = [progress.reattachments?.[peer as RecoveryNativePeer]?.binding,
430
442
  restored?.state === "done" || restored?.state === "waiting" ? restored.binding : undefined,
431
443
  progress.terminals[`retired:${peer}`]];
432
444
  if (protectedSession !== true || !bindings.some(binding => {
433
445
  if (binding === undefined) return false;
434
- const receipt = decodeRestoreReceipt({ state: "done", binding });
446
+ const receipt = decode({ state: "done", binding });
435
447
  return receipt?.state === "done" && receipt.binding.peer === peer && receipt.binding.projectRoot === op.plan.projects[index]?.project.root;
436
448
  })) throw new Error("repaired session provenance does not match its retained peer and project");
437
449
  }
@@ -440,14 +452,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
440
452
  if (choice.repaired === undefined) continue;
441
453
  const planned = op.plan.projects[index]!;
442
454
  if (choice.repaired !== true || !progress.repairedSessions?.[peer as RecoveryNativePeer] ||
443
- 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");
444
456
  }
445
457
  if (progress.reattachments === undefined) continue;
446
458
  if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
447
459
  for (const [peer, marker] of Object.entries(progress.reattachments)) {
448
- if (definitionOf(peer)?.session.launchRecord !== true || !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)) ||
449
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");
450
- const receipt = decodeRestoreReceipt({ state: "done", binding: marker.binding });
462
+ const receipt = decode({ state: "done", binding: marker.binding });
451
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");
452
464
  }
453
465
  }
@@ -456,7 +468,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
456
468
  if (!object(evidence) || Object.keys(evidence).some(key => !["projectId", "peer", "sessionId", "kind", "checkedPath"].includes(key)) ||
457
469
  typeof evidence.projectId !== "string" || typeof evidence.peer !== "string" || typeof evidence.sessionId !== "string" ||
458
470
  evidence.checkedPath !== undefined && (typeof evidence.checkedPath !== "string" || !evidence.checkedPath) ||
459
- !["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");
460
472
  }
461
473
  if (op.freshOverride !== undefined) {
462
474
  const override = op.freshOverride, evidence = override.evidence;
@@ -467,14 +479,14 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
467
479
  }
468
480
  if (op.blockedReattachment !== undefined) {
469
481
  const selected = op.blockedReattachment;
470
- if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || definitionOf(String(selected.peer))?.session.launchRecord !== true ||
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) ||
471
483
  !op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
472
484
  }
473
485
  }
474
486
 
475
487
  export interface RecoveryDriver {
476
488
  stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
477
- inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
489
+ inspect(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection>;
478
490
  prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
479
491
  abort(project: Project, operation: string, instance: string): Promise<void>;
480
492
  commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
@@ -766,7 +778,11 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
766
778
 
767
779
  /** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
768
780
  export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
769
- 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
+ };
770
786
  const choices = nextChoices(op, runnerPid, live), next = choices.map(choice => choice.text);
771
787
  return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
772
788
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
@@ -795,7 +811,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
795
811
  let op = readOperation<RecoveryOperation>(id, home);
796
812
  validateReceipt(id, op);
797
813
  const selection = () => {
798
- if (definitionOf(peer)?.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");
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");
799
815
  const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
800
816
  const planned = op.plan.projects.find(project => project.project.id === progress?.id);
801
817
  const native = peer as RecoveryNativePeer;
@@ -820,7 +836,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
820
836
  };
821
837
  await target();
822
838
  attempted = true;
823
- await driver.restore(planned, progress, op, definitionOf(native)?.transport === "claude-channel" ? "claude" : "native", save, native);
839
+ await driver.restore(planned, progress, op, projectDefinition(native, planned.project.root)?.transport === "claude-channel" ? "claude" : "native", save, native);
824
840
  // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
825
841
  // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
826
842
  const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
@@ -853,14 +869,14 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
853
869
  /** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
854
870
  export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
855
871
  let op = readOperation<RecoveryOperation>(id, home);
856
- validateReceipt(id, op);
872
+ validateReceipt(id, op, true);
857
873
  acquireRecoveryLock(id, home);
858
874
  const releaseRunner = claimRunner(id, home);
859
875
  try {
860
876
  // Re-read after the exclusive runner claim so cancellation cannot act on a
861
877
  // stale pending/prepared receipt after another runner advanced it.
862
878
  op = readOperation<RecoveryOperation>(id, home);
863
- validateReceipt(id, op);
879
+ validateReceipt(id, op, true);
864
880
  if (op.phase === "completed" || op.phase === "cancelled") {
865
881
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
866
882
  return;
@@ -868,7 +884,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
868
884
  if (abandoned(op)) writeOperation(id, op, home);
869
885
  const { fingerprint, ...body } = op.plan;
870
886
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
871
- const live = await liveProjects(op, driver.inspect);
887
+ const live = await liveProjects(op, project => driver.inspect(project, undefined, "cleanup"));
872
888
  const refusal = abortRefusal(op, live);
873
889
  if (refusal) throw new Error(`abort refused: ${refusal}; ${nextActionsText(op, live)}`);
874
890
  // Only this operation's own uncommitted holds are aborted; a lapsed hold, a replacement daemon or another
@@ -893,7 +909,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
893
909
  */
894
910
  export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
895
911
  let op: RecoveryOperation;
896
- try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
912
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
897
913
  catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
898
914
  if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
899
915
  // A finished operation owns no lock: never take one only to refuse.
@@ -901,7 +917,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
901
917
  acquireRecoveryLock(id, home);
902
918
  const releaseRunner = claimRunner(id, home);
903
919
  try {
904
- try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
920
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
905
921
  catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
906
922
  if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
907
923
  if (op.phase === "completed" || op.phase === "cancelled") {
@@ -912,11 +928,11 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
912
928
  if (abandoned(op)) writeOperation(id, op, home);
913
929
  const at = driver.now();
914
930
  // Read once, as status and the runner do: every choice and refusal below is decided on it.
915
- const live = await liveProjects(op, driver.inspect);
931
+ const live = await liveProjects(op, project => driver.inspect(project, undefined, "stop" in choice ? "cleanup" : undefined));
916
932
  const next = nextActionsText(op, live);
917
933
  if ("fresh" in choice) {
918
934
  // `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
919
- if (definitionOf(choice.fresh)?.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.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}`);
920
936
  if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
921
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}`);
922
938
  // The new session is launched by resume, so it helps only where resume can get past what is live.
@@ -997,7 +1013,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
997
1013
  else if (act === "stopped" || act === "target stopped") {
998
1014
  const outcome = await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
999
1015
  if (outcome && outcome.continuity) (disposition.continuity ??= {})[planned.project.id] = outcome.continuity;
1000
- 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`);
1001
1017
  }
1002
1018
  disposition.projects[planned.project.id] ??= act;
1003
1019
  op.updatedAt = driver.now(); writeOperation(id, op, home);
@@ -1005,7 +1021,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
1005
1021
  } catch (error) {
1006
1022
  if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
1007
1023
  op.phase = "blocked"; op.updatedAt = driver.now();
1008
- 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")))}`;
1009
1025
  writeOperation(id, op, home);
1010
1026
  throw error;
1011
1027
  }
package/src/hub/budget.ts CHANGED
@@ -46,6 +46,8 @@ export interface BudgetConfig {
46
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 };
47
47
 
48
48
  export interface BudgetHooks {
49
+ /** Stable/idempotent for this Budget lifetime. All lifecycle hooks receive this canonical account id. */
50
+ account?(peer: PeerId): PeerId;
49
51
  pause(peer: PeerId): void;
50
52
  /** Must leave a manual `ahub pause` in place. */
51
53
  resume(peer: PeerId): void;
@@ -101,6 +103,43 @@ export class Budget {
101
103
  this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
102
104
  this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
103
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();
104
143
  }
105
144
 
106
145
  /** Freeze pause/resume and handoff transitions while a controlled restart is fenced. Readings continue to update. */
@@ -121,6 +160,10 @@ export class Budget {
121
160
  }
122
161
 
123
162
  record(peer: PeerId): PauseRecord | undefined {
163
+ return this.readRecord(this.account(peer));
164
+ }
165
+
166
+ private readRecord(peer: PeerId): PauseRecord | undefined {
124
167
  const r = this.db.query("SELECT * FROM budget_pauses WHERE peer = ?").get(peer) as Record<string, any> | null;
125
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;
126
169
  }
@@ -157,13 +200,15 @@ export class Budget {
157
200
  report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
158
201
  // Execution-budget instrumentation is independent of native quota accounting.
159
202
  // Explicit quota exemption or absent budget metadata exempts registered kinds; external ids keep accounting.
160
- const adapter = definitionOf(peer);
161
- if (peer === "user" || peer === "hub" || adapter && (!adapter.budget || adapter.budget.quotaExempt === true) || this.closed) return;
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;
162
207
  // An override ignores soft readings, not a provider that has actually refused another turn.
163
208
  if (hard && this.ignoreUntil.delete(peer)) {
164
209
  try { this.hooks.notify(`budget: ${peer} provider refused again; budget override ended`); } catch { /* a notice cannot prevent the hard pause */ }
165
210
  }
166
- 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 */ }
167
212
  const mine = this.readings.get(peer) ?? new Map<string, Reading>();
168
213
  this.readings.set(peer, mine);
169
214
  for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
@@ -173,12 +218,14 @@ export class Budget {
173
218
 
174
219
  /** Readings that still say something: not stale, and not about a window that has reset since they were taken. */
175
220
  private fresh(peer: PeerId): Reading[] {
221
+ peer = this.account(peer);
176
222
  const staleMs = this.cfg.stale_min * 60_000;
177
223
  return [...(this.readings.get(peer)?.values() ?? [])].filter((r) => this.now() - r.at <= staleMs && !(r.resetsAt && r.resetsAt <= this.now()));
178
224
  }
179
225
 
180
226
  /** The owner's override: lift the pause now and leave the peer alone until the window that caused it has reset. */
181
227
  override(peer: PeerId): boolean {
228
+ peer = this.account(peer);
182
229
  const open = this.record(peer);
183
230
  if (!open) return false;
184
231
  this.ignoreUntil.set(peer, open.resetsAt);
@@ -190,6 +237,7 @@ export class Budget {
190
237
  }
191
238
 
192
239
  private async evaluate(peer: PeerId, hard: boolean): Promise<void> {
240
+ peer = this.account(peer);
193
241
  if (this.recoveryHeld) { this.deferred = true; if (hard) this.deferredHard.add(peer); return; }
194
242
  const fresh = this.fresh(peer);
195
243
  const over = fresh.filter((r) => r.used >= this.cfg.gate);
@@ -252,6 +300,7 @@ export class Budget {
252
300
  }
253
301
 
254
302
  private async finishHandoff(peer: PeerId, summary: string | undefined): Promise<void> {
303
+ peer = this.account(peer);
255
304
  if (this.closed || this.recoveryHeld) { this.deferred = true; return; }
256
305
  if (this.handingOff.has(peer) || !this.hooks.canHandOff(peer)) return; // retried by the next tick
257
306
  this.handingOff.add(peer);
@@ -264,6 +313,7 @@ export class Budget {
264
313
  }
265
314
 
266
315
  private async handOff(peer: PeerId, summary: string | undefined): Promise<void> {
316
+ peer = this.account(peer);
267
317
  const context = summary ?? (await this.hooks.platformContext(peer).catch(() => undefined));
268
318
  // Wait or hand off (issue #36): a handoff costs the next peer the whole context, so a short wait can be cheaper.
269
319
  const resetsAt = this.record(peer)?.resetsAt;
@@ -298,6 +348,7 @@ export class Budget {
298
348
 
299
349
  /** `hub_checkpoint` arrived from a peer. Resolved by the daemon's requestCheckpoint wait; kept on the record for a restart. */
300
350
  checkpointed(peer: PeerId, summary: string): void {
351
+ peer = this.account(peer);
301
352
  if (this.closed) return; // a hub_checkpoint that lands during shutdown
302
353
  this.db.query("UPDATE budget_pauses SET summary = ? WHERE peer = ?").run(summary, peer);
303
354
  }
@@ -314,6 +365,7 @@ export class Budget {
314
365
  }
315
366
 
316
367
  private resume(peer: PeerId, why: string): void {
368
+ peer = this.account(peer);
317
369
  const record = this.record(peer);
318
370
  if (!record) return;
319
371
  this.db.query("DELETE FROM budget_pauses WHERE peer = ?").run(peer);
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,4 +1,4 @@
1
- import { definitionOf, SUPPORTED_PEERS } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import type { Task } from "./board.ts";
4
4
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
@@ -104,19 +104,21 @@ export class ConductorHolds {
104
104
  close(): void { this.db.close(); }
105
105
  }
106
106
 
107
- type StartablePeer = typeof SUPPORTED_PEERS[number];
108
-
109
- 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 };
110
108
  /**
111
109
  * What a conductor's start of `peer` is (#269): the peer's own start mode, which a person sets. A `mode` argument may
112
110
  * only repeat it. Preview commands come from the same launch planner the CLI uses, never caller-supplied shell text.
113
111
  */
114
- 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 {
115
113
  const refuse = (text: string) => new (preEffect ? PreEffectToolRefusal : Error)(text);
116
114
  if (mode !== undefined && mode !== "headless" && mode !== "tui") throw refuse("mode must be headless or tui");
117
- if (!(typeof peer === "string" && definitionOf(peer)?.start.conductorStartable)) throw refuse("only claude, codex, kimi, pi and local, plus opt-in qwen/jcode, may be started by the conductor");
118
- const selected = peer as StartablePeer;
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`);
119
120
  const own = startMode(selected);
121
+ if (extra && own !== "headless") throw refuse(`${peer}: extra instances require headless mode in I2`);
120
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`);
121
123
  return own === "tui" ? { peer: peer as "claude" | "codex" | "pi", mode: "tui", command: preview(peer as "claude" | "codex" | "pi") } : { peer: selected, mode: "headless" };
122
124
  }
@@ -164,9 +166,11 @@ export interface ConductorHooks {
164
166
  escalate(actor: string, id: number): Promise<unknown>;
165
167
  preview(peer: "claude" | "codex" | "pi"): string;
166
168
  /** The mode a start of this peer takes: a person's setting. */
167
- startMode(peer: StartablePeer): StartMode;
169
+ startMode(peer: string): StartMode;
168
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. */
169
- 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;
170
174
  known(peer: string): boolean;
171
175
  pause(peer: string): void;
172
176
  validateRelease?(peer: string): Promise<void>;
@@ -231,7 +235,9 @@ export class Conductor {
231
235
  return updated ? publicConductorTask(updated, this.hooks.publicView) : { id: task.id };
232
236
  }
233
237
  if (tool === "hub_peer_start") {
234
- 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}`);
235
241
  const result = await this.hooks.start(start.peer);
236
242
  const opened = result && typeof result === "object" && typeof (result as { opened?: unknown }).opened === "string" ? { opened: (result as { opened: string }).opened } : {};
237
243
  emit({ peer: start.peer }); return { ...start, ...opened };
@@ -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 }
@@ -108,6 +110,7 @@ const schema: Record<string, Check> = {
108
110
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
109
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,
110
112
  permission_modes: v => { permissionDefaults(v); }, protect_instruction_files: bool,
113
+ instances: v => { validateInstances(v); },
111
114
  batch_max: number(1, 100_000, true), batch_ms: number(0, 2_147_483_647), queue_cap: number(1, 1_000_000, true),
112
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,
113
116
  short_ttl_days: number(1, 3650, true), sync_interval_min: number(0, 1440, true), suggest_edges: bool }),
@@ -138,7 +141,14 @@ const schema: Record<string, Check> = {
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;
@@ -149,6 +159,7 @@ export function validateEffectiveConfig(value: unknown): asserts value is HubCon
149
159
  }
150
160
 
151
161
  const restart: Record<string, string> = {
162
+ instances: "Native instance identity, worktree, ports and account bindings are fixed at daemon startup",
152
163
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.restartReasons ?? {})),
153
164
  memory: "Open memory consumers, selected profile, client and sync timer must be quiesced together",
154
165
  omniroute: "Credentials and gateway clients are bound to relay ownership", mlx: "Model runtime ownership and device resources belong to this daemon",
@@ -174,7 +185,7 @@ export function configApply(key: string): ApplyRule {
174
185
  if (shape?.fields) { if (!Object.hasOwn(shape.fields, part)) return fail(key, "unknown configuration key"); check = shape.fields[part]; }
175
186
  else if (shape?.item) { if (shape.keys && !shape.keys.includes(part)) return fail(key, "unknown configuration key"); check = shape.item; }
176
187
  else if (index === 0 && (special[top]?.includes(part) || top === "checks" && /^[a-z][a-z0-9_]{0,47}$/.test(part))) break;
177
- 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;
178
189
  else return fail(key, "unknown configuration key");
179
190
  }
180
191
  }
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { validateRawConfig, type ConfigLayer } from "./config-apply.ts";
6
6
  import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
7
7
  import { CLASSES } from "./board.ts";
8
+ import { validateInstances } from "./instances.ts";
8
9
 
9
10
  export const CONFIG_SOURCE_FILES = ["config.json", "config.local.json", "routing.toml", "routing.local.toml"] as const;
10
11
  export type ConfigSourceName = typeof CONFIG_SOURCE_FILES[number];
@@ -90,6 +91,15 @@ function validateRoutingRaw(value: unknown): void {
90
91
  /** Strict reloads never use routing's last-good fallback. Startup can retain its existing fallback explicitly. */
91
92
  export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: Routing): ConfigSources {
92
93
  const layers: ConfigLayer[] = [], trustLayers: ConfigSources["trustLayers"] = [];
94
+ let localRaw: Record<string, unknown> = {};
95
+ try { localRaw = JSON.parse(texts["config.local.json"] ?? "{}"); }
96
+ catch { if (!fallbackRouting) throw new Error(".agenthub/config.local.json: invalid JSON object"); }
97
+ let declared;
98
+ try { declared = validateInstances(localRaw?.instances); }
99
+ catch (error) {
100
+ if (!fallbackRouting) throw new Error(`.agenthub/config.local.json: ${(error as Error).message}`);
101
+ declared = validateInstances(undefined);
102
+ }
93
103
  for (const name of CONFIG_SOURCE_FILES) {
94
104
  const text = texts[name];
95
105
  if (text === null) continue;
@@ -104,7 +114,7 @@ export function parseConfigSources(texts: ConfigSourceTexts, fallbackRouting?: R
104
114
  }
105
115
  if (name === "config.json" || name === "config.local.json") {
106
116
  if (!fallbackRouting) {
107
- try { validateRawConfig(raw); }
117
+ try { validateRawConfig(raw, declared); }
108
118
  catch (error) { throw new Error(`.agenthub/${name}: ${(error as Error).message}`); }
109
119
  }
110
120
  layers.push({ name, raw });
@@ -29,7 +29,7 @@ export const MACHINE_LOCAL = [
29
29
  "terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
30
30
  ] as const;
31
31
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
32
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection"] as const;
32
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "text_projection", "instances"] as const;
33
33
 
34
34
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
35
35
  export function configRepositoryPresent(cwd: string): boolean {
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
8
8
 
9
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
10
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub"] as const;
11
+ export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances"] as const;
12
12
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
13
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
14
 
package/src/hub/crash.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definitionOf } from "../adapters/registry.ts";
1
+ import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
2
  import { chmodSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { JournalDelivery } from "./delivery-journal.ts";
@@ -56,17 +56,26 @@ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
56
56
  * What can come back after a crash. `resume` holds the start arguments for peers the hub launches itself (Kimi through
57
57
  * ACP `session/load`, Pi through its session file, the local worker afresh); the others say what the user has to do.
58
58
  */
59
- export function crashPlan(records: SessionRecord[]): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
59
+ export function crashPlan(records: SessionRecord[], instances?: KindMap): { peer: string; resume?: Record<string, string>; how: string; fresh?: Record<string, string>; tui?: true }[] {
60
60
  return records.map(({ peer, meta }) => {
61
61
  const launch = (meta.launch && typeof meta.launch === "object" ? meta.launch : {}) as Record<string, unknown>;
62
62
  const model = str(launch.model);
63
- const adapter = definitionOf(peer);
63
+ const adapter = definitionOf(peer, instances);
64
+ const extra = adapter !== undefined && definitionOf(peer) === undefined;
65
+ const command = extra ? `ahub ${adapter.cli.command} --as ${peer}` : `ahub ${peer}`;
64
66
  if (adapter?.session.resumeById) {
65
67
  const sessionId = adapter.session.crashIdentity?.(meta).sessionId;
66
- if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ahub ${peer}` };
68
+ if (!sessionId) return { peer, how: `${peer}: saved ACP session identity unavailable; start it deliberately with ${command}` };
67
69
  return { peer, resume: { sessionId, ...(model ? { model } : {}) }, how: `${peer}: the recorded conversation can be loaded again (ACP session/load)` };
68
70
  }
69
- return { peer, ...(adapter?.session.crashPlan?.(meta) ?? { how: `${peer}: reconnects by itself if its client is still running` }) };
71
+ const native = adapter?.session.crashPlan?.(meta);
72
+ if (!native) return { peer, how: `${peer}: reconnects by itself if its client is still running` };
73
+ if (!extra) return { peer, ...native };
74
+ const prefix = `${adapter.kind}:`;
75
+ let how = native.how.startsWith(prefix) ? `${peer}:${native.how.slice(prefix.length)}` : `${peer}: ${native.how}`;
76
+ // Manual launch guidance selects this instance. Recorded headless resume prose can contain an opaque id, never a command.
77
+ if (!native.resume || native.tui) how = how.replace(`ahub ${adapter.cli.command}`, command);
78
+ return { peer, ...native, how };
70
79
  });
71
80
  }
72
81