@staix/agent-hub 0.12.31 → 0.12.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +14 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +3 -0
  5. package/docs/agent-notes/daemon.md +13 -8
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +141 -25
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +18 -9
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  20. package/docs/verified.json +24 -16
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1296 -26
  24. package/src/adapters/acp.ts +5 -6
  25. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  26. package/src/adapters/definitions/kimi-native.ts +4 -0
  27. package/src/adapters/definitions/qwen-native.ts +24 -0
  28. package/src/adapters/definitions/qwen.ts +71 -0
  29. package/src/adapters/pi.ts +6 -2
  30. package/src/adapters/registry-types.ts +174 -0
  31. package/src/adapters/registry.ts +36 -0
  32. package/src/cli/console-state.ts +74 -5
  33. package/src/cli/console.ts +35 -2
  34. package/src/cli/help.ts +3 -1
  35. package/src/cli/identity.ts +13 -3
  36. package/src/cli/launch.ts +1 -6
  37. package/src/cli/main.ts +200 -39
  38. package/src/cli/output.ts +2 -0
  39. package/src/cli/preview.ts +8 -5
  40. package/src/cli/recovery-guidance.ts +137 -0
  41. package/src/cli/recovery-presentation-options.ts +8 -0
  42. package/src/cli/recovery-session-file.ts +18 -0
  43. package/src/cli/reset.ts +13 -1
  44. package/src/cli/terminal-recovery.ts +48 -0
  45. package/src/cli/upgrade-interactive.ts +129 -75
  46. package/src/cli/upgrade-labels.ts +30 -0
  47. package/src/cli/upgrade-presentation-data.ts +41 -0
  48. package/src/cli/upgrade-presentation-flags.ts +31 -0
  49. package/src/cli/upgrade-presentation.ts +227 -0
  50. package/src/cli/upgrade-restore-state.ts +6 -2
  51. package/src/cli/upgrade-runtime.ts +360 -199
  52. package/src/cli/upgrade.ts +278 -25
  53. package/src/hub/bus.ts +144 -14
  54. package/src/hub/cohorts.ts +2 -2
  55. package/src/hub/config-apply.ts +242 -0
  56. package/src/hub/config-reload.ts +120 -0
  57. package/src/hub/config-trust.ts +11 -2
  58. package/src/hub/control-client.ts +1 -1
  59. package/src/hub/crash.ts +8 -6
  60. package/src/hub/daemon.ts +577 -179
  61. package/src/hub/deferred-hub.ts +60 -0
  62. package/src/hub/delivery-journal.ts +6 -0
  63. package/src/hub/events.ts +2 -2
  64. package/src/hub/manager.ts +1 -1
  65. package/src/hub/peer-catalog.ts +10 -13
  66. package/src/hub/peers.ts +12 -2
  67. package/src/hub/permission-mode.ts +5 -3
  68. package/src/hub/recovery-continuity.ts +152 -0
  69. package/src/hub/recovery-store.ts +10 -4
  70. package/src/hub/restart.ts +12 -1
  71. package/src/hub/sdlc.ts +5 -1
  72. package/src/hub/settings.ts +50 -36
  73. package/src/hub/settled-roles.ts +35 -0
  74. package/src/hub/supervision.ts +3 -2
  75. package/src/hub/tasks.ts +29 -9
  76. package/src/hub/ui.ts +1 -1
  77. package/src/memory/brief.ts +6 -5
  78. package/src/pi/session.ts +23 -4
  79. package/src/ui/index.html +23 -7
package/src/hub/daemon.ts CHANGED
@@ -19,22 +19,27 @@ import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from
19
19
  import { ActivityRing } from "./activity.ts";
20
20
  import { JcodePeer } from "../adapters/jcode.ts";
21
21
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
22
- import { loadSdlc } from "./sdlc.ts";
22
+ import { loadSdlc, sdlcGh } from "./sdlc.ts";
23
23
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
24
24
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
25
+ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
25
26
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
26
- import { buildKimiLaunch, buildQwenLaunch } from "../cli/launch.ts";
27
+ import { buildKimiLaunch } from "../cli/launch.ts";
28
+ import { definitionOf } from "../adapters/registry.ts";
27
29
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
28
30
  import type { ToolObservation } from "../models/route/signals.ts";
29
31
  import { createHash, randomBytes, randomUUID } from "node:crypto";
30
- import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
32
+ import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, watchFile, unwatchFile, writeFileSync } from "node:fs";
31
33
  import { join, relative, resolve } from "node:path";
32
34
  import { homedir } from "node:os";
33
35
  import { spawn } from "node:child_process";
34
36
  import { childEnv } from "./child-process.ts";
35
37
  import { runCheck } from "./checks.ts";
36
38
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
37
- import { stripUntrusted } from "./config-trust.ts";
39
+ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
40
+ import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig } from "./config-apply.ts";
41
+ import { resolveSettledRoles } from "./settled-roles.ts";
42
+ import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
38
43
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
39
44
  import { ExecutionBudget } from "./execution-budget.ts";
40
45
  import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
@@ -47,7 +52,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
47
52
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
48
53
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
49
54
  import { processSignature } from "../pi/process-signature.ts";
50
- import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
55
+ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
51
56
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
52
57
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
53
58
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
@@ -73,8 +78,8 @@ import { launcherPreview } from "../cli/preview.ts";
73
78
  import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
74
79
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
75
80
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
76
- import { currentRouting, detectSignals, OVERLAY_FILE, parseRouting, routingText, type Routing } from "./routing.ts";
77
- import { checkSettingValue, ignoredLine, pendingUndo, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue } from "./settings.ts";
81
+ import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
82
+ import { SETTINGS, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
78
83
  import { Bus } from "./bus.ts";
79
84
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
80
85
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -95,6 +100,8 @@ import type { JournalDelivery } from "./delivery-journal.ts";
95
100
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
96
101
  import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
97
102
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
103
+ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
104
+ import { shellQuote } from "../cli/terminal-recovery.ts";
98
105
 
99
106
  import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
100
107
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
@@ -235,16 +242,18 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
235
242
 
236
243
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
237
244
  const CONFIG_FILES = ["config.json", "config.local.json"] as const;
245
+ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
238
246
  const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", "local", "pi", "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal"];
239
247
 
240
248
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
241
- export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
249
+ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
242
250
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
243
251
  const conductor = { ...DEFAULT_CONFIG.conductor };
244
252
  let planner: string | undefined;
245
253
  for (const name of CONFIG_FILES) {
246
254
  let file: Record<string, unknown>;
247
- try { file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
255
+ if (layers && !layers.some(layer => layer.name === name)) continue;
256
+ try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
248
257
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
249
258
  if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
250
259
  if (file.planner !== undefined) {
@@ -279,13 +288,14 @@ function syncMinutes(v: unknown): number {
279
288
  return v;
280
289
  }
281
290
 
282
- export function loadConfig(cwd: string): HubConfig {
291
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
283
292
  const ignored: string[] = [];
284
293
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
285
294
  const files = CONFIG_FILES.flatMap((name) => {
286
295
  let file: Record<string, any>; // parsed JSON, checked field by field below
296
+ if (layers && !layers.some(layer => layer.name === name)) return [];
287
297
  try {
288
- file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
298
+ file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
289
299
  } catch (error) {
290
300
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
291
301
  throw error;
@@ -340,7 +350,7 @@ export function loadConfig(cwd: string): HubConfig {
340
350
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
341
351
  }
342
352
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
343
- const conductorPolicy = loadConductorPolicy(cwd);
353
+ const conductorPolicy = loadConductorPolicy(cwd, layers);
344
354
  if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
345
355
  if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
346
356
  if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
@@ -563,7 +573,9 @@ export async function startDaemon(opts: DaemonOptions) {
563
573
  try {
564
574
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
565
575
  const lifecycleConfig = loadSdlc(opts.cwd);
566
- const config = { ...loadedConfig, roster: peerRosterConfig(loadedConfig.roster), permission_modes: permissionDefaults(loadedConfig.permission_modes), roles: { ...loadedConfig.roles }, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
576
+ // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
577
+ const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
578
+ const config = { ...ownedConfig, roster: peerRosterConfig(loadedConfig.roster), permission_modes: permissionDefaults(loadedConfig.permission_modes), roles: { ...loadedConfig.roles }, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
567
579
  let rosterChanging: string | undefined;
568
580
  let kimiSessionMode: StartMode | undefined;
569
581
  let kimiAdmissionId: string | undefined;
@@ -579,6 +591,22 @@ export async function startDaemon(opts: DaemonOptions) {
579
591
  const logFile = join(opts.stateDir, "hub.log");
580
592
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
581
593
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
594
+ let runningRouting = currentRouting(opts.cwd, log);
595
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
596
+ const startupSources = structuredClone(acceptedSources.trustLayers);
597
+ const embeddedConfig = structuredClone(config);
598
+ let desiredConfig = structuredClone(config);
599
+ const startupRouting = structuredClone(runningRouting);
600
+ let desiredRouting = structuredClone(runningRouting);
601
+ let configRevision = 0;
602
+ let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
603
+ let pendingRestart: { key: string; reason: string }[] = [];
604
+ let lastReloadFailure: string | undefined;
605
+ let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
606
+ let configWatchPending = false;
607
+ const configWatchers: { path: string; listener: (current: import("node:fs").Stats, previous: import("node:fs").Stats) => void }[] = [];
608
+ const stopConfigWatch = () => { configWatchPending = false; clearTimeout(reloadDebounce); for (const item of configWatchers.splice(0)) unwatchFile(item.path, item.listener); };
609
+ const activeRouting = () => runningRouting;
582
610
  // #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
583
611
  // place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
584
612
  // the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
@@ -586,23 +614,7 @@ export async function startDaemon(opts: DaemonOptions) {
586
614
  let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
587
615
  let plannerLogged = "";
588
616
  const settlePlanner = () => {
589
- const roles = structuredClone(policyRoles);
590
- const pinned = (PLANNER_PEERS as readonly string[]).includes(config.planner ?? "") ? config.planner! : undefined;
591
- let source: typeof plannerSource;
592
- if (pinned) {
593
- for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
594
- roles[pinned] = ["planner", ...(roles[pinned] ?? [])];
595
- source = "setting";
596
- } else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && onRoster(peer))) source = "roles";
597
- else {
598
- const auto = PLANNER_PEERS.find(onRoster);
599
- if (auto) { // the off-roster holders give the role up while the automatic planner holds it
600
- for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
601
- roles[auto] = [...(roles[auto] ?? []), "planner"];
602
- }
603
- source = auto ? "automatic" : "none";
604
- }
605
- conductorPeer(roles);
617
+ const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
606
618
  for (const key of Object.keys(config.roles)) delete config.roles[key];
607
619
  Object.assign(config.roles, roles);
608
620
  plannerSource = source;
@@ -631,16 +643,16 @@ export async function startDaemon(opts: DaemonOptions) {
631
643
  try { configured = configuredRoles(peer); } catch { note = "catalog selection is incompatible or unavailable"; }
632
644
  const prepared = preparedSessionRoles.get(peer);
633
645
  const active = bus.peers.get(peer)?.state !== "offline" && bus.peers.has(peer);
646
+ let desired = configured;
647
+ try { desired = selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) }, catalog).map(role => role.id); }
648
+ catch { note = "catalog selection is incompatible or unavailable"; }
634
649
  const value = active ? prepared ?? null : configured;
635
- return { value, values: compatibleRoles(peer), ...(active && JSON.stringify(prepared ?? null) !== JSON.stringify(configured) ? { pending: configured } : {}), ...(note ? { note } : active && !prepared ? { note: "Current session guidance is unconfirmed; configuration is not receipt of model consumption." } : {}) };
636
- }
637
- function validateRoleSetting(scratch: string, peer: string) {
638
- const candidate = loadConfig(scratch);
639
- selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(candidate.catalog_roles) }, catalog);
640
- return candidate;
650
+ return { value, values: compatibleRoles(peer), ...(JSON.stringify(value) !== JSON.stringify(desired) ? { pending: desired } : {}), ...(note ? { note } : active && !prepared ? { note: "Current session guidance is unconfirmed; configuration is not receipt of model consumption." } : {}) };
641
651
  }
652
+ const prepareRoleSelection = (peer: string) => { roleAssignments[peer] = catalogAssignments(config.catalog_roles)[peer] ?? resolvedProfile.catalog_roles[peer] ?? []; };
642
653
  function sessionRoleContract(peer: string): string {
643
654
  try {
655
+ prepareRoleSelection(peer);
644
656
  const selected = selectedRoles(peer, config.roles, roleAssignments, catalog);
645
657
  preparedSessionRoles.set(peer, selected.map(role => role.id));
646
658
  preparedSessionRoleIdentities.set(peer, selected.map(roleIdentity));
@@ -746,8 +758,11 @@ export async function startDaemon(opts: DaemonOptions) {
746
758
 
747
759
  // A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
748
760
  const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
761
+ let piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
762
+ // A disposed operation's exact session takes precedence over a stale crash record for this whole startup.
763
+ const archivedPiContinuity = piContinuity.state !== "absent";
749
764
  if (config.mlx.enabled === false) {
750
- const launches = [...(restored?.peers ?? []).filter(peer => peer.id === "pi").map(peer => peer.launch), ...(crashed?.peers ?? []).filter(peer => peer.peer === "pi").map(peer => peer.meta.launch)];
765
+ const launches = [...(restored?.peers ?? []).filter(peer => peer.id === "pi").map(peer => peer.launch), ...(crashed?.peers ?? []).filter(peer => peer.peer === "pi" && !archivedPiContinuity).map(peer => peer.meta.launch)];
751
766
  for (const launch of launches) {
752
767
  const problem = mlxLaunchProblem(config, (launch ?? {}) as { backend?: unknown; model?: unknown });
753
768
  if (problem) throw new Error(`recorded Pi recovery: ${problem}`);
@@ -756,7 +771,7 @@ export async function startDaemon(opts: DaemonOptions) {
756
771
  // A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
757
772
  // a record left now would make the next ordinary start look like one.
758
773
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
759
- const autoResume = config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
774
+ const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
760
775
  const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
761
776
  const startedAt = Date.now();
762
777
  /** What crash recovery did or asks the user to do, for `ahub status`. */
@@ -790,8 +805,8 @@ export async function startDaemon(opts: DaemonOptions) {
790
805
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
791
806
  // together. Cached until the board changes: it is asked per recipient of every message.
792
807
  let turnFreeNow: boolean | undefined;
793
- const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
794
- const turnFree = (): boolean => ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= coordination === "turn-free" && !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
808
+ const turns = new Map<PeerId, { capture?: boolean; id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
809
+ const turnFree = (): boolean => (config.coordination === "turn-free" || tasks.cohorts.list().some(cohort => cohort.silent)) && ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
795
810
  /**
796
811
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
797
812
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
@@ -836,10 +851,10 @@ export async function startDaemon(opts: DaemonOptions) {
836
851
  const hubStartedAt = Date.now();
837
852
  // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
838
853
  let relevantNotice: (peer: PeerId, env: Envelope) => boolean = () => true;
839
- const staleOff = config.experiments?.stale_notices === "deliver";
840
- if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
854
+ const staleOff = () => config.experiments?.stale_notices === "deliver";
855
+ if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
841
856
  const requestedPeerStops = new Set<string>();
842
- const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
857
+ const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff() || relevantNotice(peer, env), silence,
843
858
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
844
859
  startupCleanup.push(() => bus.closeJournal());
845
860
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -851,7 +866,7 @@ export async function startDaemon(opts: DaemonOptions) {
851
866
  let recoveryCommitted = false;
852
867
  let recoveryPeerSnapshot: RestartPeerSnapshot[] | undefined;
853
868
  let recoveryLeaseTimer: ReturnType<typeof setTimeout> | undefined;
854
- const recoveryActive = () => !!recoveryOperationId && recoveryPhase !== "released";
869
+ const recoveryActive = () => !!recoveryOperationId && (recoveryPhase !== "released" || bus.isRecoveryHeld);
855
870
  if (restored) {
856
871
  bus.restore(restored.bus, restored.operationId);
857
872
  for (const hold of conductorHolds.list()) bus.pause(hold.peer);
@@ -860,22 +875,25 @@ export async function startDaemon(opts: DaemonOptions) {
860
875
  recoveryOperationId = restored.operationId;
861
876
  recoveryPhase = "restored";
862
877
  recoveryPeerSnapshot = restored.peers;
863
- bus.setRecoveryHold(true);
878
+ bus.setRecoveryHold(true, true);
879
+ } else {
880
+ // A prior release may have committed its queue checkpoint before this daemon was stopped.
881
+ bus.setRecoveryHold(false);
864
882
  }
865
883
  const recoveryPeerAllowed = (id: PeerId) => !recoveryActive() || !recoveryPeerSnapshot || recoveryPeerSnapshot.some((peer) => peer.id === id);
866
- const armRecoveryLease = () => {
884
+ const armRecoveryLease = (delayMs = 10 * 60_000): void => {
867
885
  clearTimeout(recoveryLeaseTimer);
868
886
  recoveryLeaseTimer = setTimeout(() => {
869
887
  if (!recoveryOperationId || recoveryCommitted || (recoveryPhase !== "preparing" && recoveryPhase !== "prepared")) return;
888
+ try { bus.setRecoveryHold(false); } catch (error) { log("recovery lease release deferred; delivery storage must be restored before abort"); armRecoveryLease(1000); return; }
870
889
  log(`recovery lease expired for ${recoveryOperationId}; hold aborted`);
871
890
  recoveryOperationId = undefined;
872
891
  recoveryPhase = undefined;
873
892
  recoveryPeerSnapshot = undefined;
874
- bus.setRecoveryHold(false);
875
893
  budget.setRecoveryHold(false);
876
894
  removeRestartSnapshot(opts.stateDir);
877
895
  writeStatus();
878
- }, 10 * 60_000);
896
+ }, delayMs);
879
897
  recoveryLeaseTimer.unref?.();
880
898
  };
881
899
  const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
@@ -895,7 +913,7 @@ export async function startDaemon(opts: DaemonOptions) {
895
913
  memoryNotice(`hub native memory disabled: ${reason}`);
896
914
  };
897
915
  if (config.memory.native) {
898
- try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => currentRouting(opts.cwd, log), () => config.pii?.paths ?? [], (key) => {
916
+ try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
899
917
  const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
900
918
  return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
901
919
  }) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
@@ -920,7 +938,7 @@ export async function startDaemon(opts: DaemonOptions) {
920
938
  * Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
921
939
  * policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
922
940
  */
923
- const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, currentRouting(opts.cwd, log), opts.cwd, config.pii?.paths ?? []);
941
+ const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
924
942
  let candidates: CandidateStore | undefined;
925
943
  if (memoryStore) {
926
944
  try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
@@ -1031,7 +1049,7 @@ export async function startDaemon(opts: DaemonOptions) {
1031
1049
  const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
1032
1050
  let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
1033
1051
  let sidecarRouting = "";
1034
- inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => currentRouting(opts.cwd, log).local.fixed_model, log });
1052
+ inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => activeRouting().local.fixed_model, log });
1035
1053
 
1036
1054
  let dashboard: ReturnType<typeof startDashboard> | undefined;
1037
1055
  const uiEvents: { seq: number; event: BusEvent }[] = [];
@@ -1053,7 +1071,7 @@ export async function startDaemon(opts: DaemonOptions) {
1053
1071
  conductor: () => conductorPeer(config.roles) ?? undefined,
1054
1072
  scope: () => config.conductor?.feed ?? "own",
1055
1073
  tasks: () => board.list(), publicTitle: (task) => tasks.publicTitle(task), isPrivate: (task) => tasks.isPii(task),
1056
- approvalAgeMs: (config.conductor?.approval_wait_s ?? 30) * 1000,
1074
+ approvalAgeMs: () => (config.conductor?.approval_wait_s ?? 30) * 1000,
1057
1075
  readRound: (peer) => conductorHolds.readRound(peer), writeRound: (peer, signature) => conductorHolds.writeRound(peer, signature),
1058
1076
  emit: (notice) => {
1059
1077
  if (stopping || recoveryActive()) return false;
@@ -1064,7 +1082,7 @@ export async function startDaemon(opts: DaemonOptions) {
1064
1082
  const refreshConductorPolicy = () => {
1065
1083
  if (opts.config) return; // explicit embedding/test configuration remains its authority
1066
1084
  const before = conductorPeer(config.roles);
1067
- const policy = loadConductorPolicy(opts.cwd);
1085
+ const policy = { roles: policyRoles, planner: config.planner, conductor: config.conductor };
1068
1086
  policyRoles = structuredClone(policy.roles);
1069
1087
  config.planner = policy.planner;
1070
1088
  config.conductor = policy.conductor;
@@ -1186,17 +1204,17 @@ export async function startDaemon(opts: DaemonOptions) {
1186
1204
  catalog,
1187
1205
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1188
1206
  projectedReview: task => reviewProjection(task),
1189
- sdlc: { config: () => lifecycleConfig },
1207
+ sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1190
1208
  board,
1191
1209
  bus,
1192
1210
  executionBudget,
1193
- routing: () => currentRouting(opts.cwd, log), // routing.toml is edited while the hub runs: re-read on change, last good parse kept
1211
+ routing: () => activeRouting(), // only the complete validated reload can publish routing changes
1194
1212
  cwd: opts.cwd,
1195
1213
  project: chain.at(-1)!,
1196
1214
  ...(config.memory.enabled ? { memory } : {}),
1197
1215
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1198
1216
  memoryCheck: checkMemory,
1199
- ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, config.memory.brief_items,
1217
+ ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1200
1218
  memoryStore ? (t) => { checkMemory(); return screenMemory(memoryStore!.related(memoryProject, taskNodes(t), Number.MAX_SAFE_INTEGER)).slice(0, config.memory.brief_items).map((v) => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })); } : undefined,
1201
1219
  (peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
1202
1220
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
@@ -1231,7 +1249,7 @@ export async function startDaemon(opts: DaemonOptions) {
1231
1249
  },
1232
1250
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
1233
1251
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
1234
- piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => currentRouting(opts.cwd, log).local.fixed_model }),
1252
+ piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
1235
1253
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1236
1254
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
1237
1255
  review: config.review,
@@ -1243,6 +1261,7 @@ export async function startDaemon(opts: DaemonOptions) {
1243
1261
  stallNotice: stall => { supervision.taskStalled(stall); },
1244
1262
  roles: config.roles,
1245
1263
  turnFree,
1264
+ coordination: () => config.coordination === "turn-free",
1246
1265
  capable: (peer) => capable.has(peer),
1247
1266
  idle: (peer) => idle(peer),
1248
1267
  treeHash: (paths, windows) => facts.tree(paths, windows),
@@ -1605,9 +1624,9 @@ export async function startDaemon(opts: DaemonOptions) {
1605
1624
  event({ type: "supervision_turn", peer, turn: receipt.id, ...(receipt.tokens === undefined ? {} : { tokens: receipt.tokens }), ms: Math.max(0, Date.now() - receipt.at) });
1606
1625
  };
1607
1626
  // Per-turn snapshots (issue #33): a git tree at both turn boundaries, recorded in hub.db for `ahub turns` and `ahub undo`.
1608
- const repo = config.snapshots.enabled ? factsRepo : undefined;
1609
- const turnLog = repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
1610
- if (turnLog) startupCleanup.push(() => turnLog.close());
1627
+ const repo = factsRepo;
1628
+ let turnLog = config.snapshots.enabled && repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
1629
+ startupCleanup.push(() => turnLog?.close());
1611
1630
  /** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
1612
1631
  const holdsPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some((t) => t.owner === peer && !isClosedTaskState(t.state) && t.state !== "in_review" && tasks.isPii(t));
1613
1632
  const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
@@ -1631,7 +1650,8 @@ export async function startDaemon(opts: DaemonOptions) {
1631
1650
  * runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
1632
1651
  * in snapshots.ts.
1633
1652
  */
1634
- const snap = (what: string): { tree?: string; ms: number } => {
1653
+ const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
1654
+ if (!capture) return { ms: 0 };
1635
1655
  const t0 = performance.now();
1636
1656
  let tree: string | undefined;
1637
1657
  try {
@@ -1647,7 +1667,7 @@ export async function startDaemon(opts: DaemonOptions) {
1647
1667
  }
1648
1668
  function reviewProjection(task: Task): string | undefined {
1649
1669
  if (!projections.enabled || tasks.isPii(task)) return undefined;
1650
- if (!repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
1670
+ if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
1651
1671
  if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
1652
1672
  const created = taskProposalAt(task);
1653
1673
  const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
@@ -1749,10 +1769,21 @@ export async function startDaemon(opts: DaemonOptions) {
1749
1769
  }
1750
1770
  }, 5_000),
1751
1771
  ];
1752
- const taskSweep = taskSweepConfig(config.task_sweep);
1753
- if (taskSweep.enabled) intervals.push(setInterval(() => {
1754
- void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
1755
- }, taskSweep.interval_s * 1000));
1772
+ let sweepTimer: ReturnType<typeof setInterval> | undefined;
1773
+ const rescheduleSweep = () => {
1774
+ if (sweepTimer) clearInterval(sweepTimer);
1775
+ sweepTimer = undefined;
1776
+ const sweep = taskSweepConfig(config.task_sweep);
1777
+ tasks.configureSweep(sweep);
1778
+ if (sweep.enabled) {
1779
+ sweepTimer = setInterval(() => {
1780
+ void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
1781
+ }, sweep.interval_s * 1000);
1782
+ sweepTimer.unref?.();
1783
+ }
1784
+ };
1785
+ rescheduleSweep();
1786
+ startupCleanup.push(() => clearInterval(sweepTimer));
1756
1787
  for (const i of intervals) i.unref?.();
1757
1788
  startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
1758
1789
 
@@ -2126,6 +2157,20 @@ export async function startDaemon(opts: DaemonOptions) {
2126
2157
  const permissionChanges = new Map<string, Promise<void>>();
2127
2158
  const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
2128
2159
  for (const peer of pendingPermissionDefaults.keys()) permissionModes.set(peer, "ask");
2160
+ const changedPermissionDefaults = new Set<string>();
2161
+ const applyPendingPermissionDefault = (peer: string): void => {
2162
+ if (changedPermissionDefaults.has(peer) && (!bus.peers.has(peer) || bus.peers.get(peer)!.state === "offline")) {
2163
+ changedPermissionDefaults.delete(peer);
2164
+ const mode = config.permission_modes[peer] ?? "ask";
2165
+ const source = config.permission_default_sources?.[peer]?.source ?? "configuration default";
2166
+ permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
2167
+ permissionModeSources.set(peer, source);
2168
+ pendingPermissionDefaults.delete(peer);
2169
+ if (mode === "never-ask") pendingPermissionDefaults.set(peer, source);
2170
+ if (config.permission_default_sources?.[peer]) defaultSources[peer] = config.permission_default_sources[peer]!;
2171
+ else delete defaultSources[peer];
2172
+ }
2173
+ };
2129
2174
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2130
2175
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2131
2176
  const unattendedPeers = new Set<string>();
@@ -2343,6 +2388,7 @@ export async function startDaemon(opts: DaemonOptions) {
2343
2388
  projectId,
2344
2389
  instanceId,
2345
2390
  version: VERSION,
2391
+ pendingRestart,
2346
2392
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2347
2393
  lifecycleStages: [...lifecycleConfig.stages],
2348
2394
  protocol: PROTOCOL,
@@ -2568,17 +2614,57 @@ export async function startDaemon(opts: DaemonOptions) {
2568
2614
 
2569
2615
  // Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
2570
2616
  let sessionsWritten = "";
2617
+ // One bounded failure generation: unchanged events neither contend for the writer lock nor repeat its notice.
2618
+ let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
2619
+ function settlePiContinuity(): void {
2620
+ if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
2621
+ const peer = bus.peers.get("pi");
2622
+ if (!(peer instanceof PiPeer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
2623
+ const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
2624
+ if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
2625
+ const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
2626
+ if (piSettlementFailure?.key === key && Date.now() < piSettlementFailure.retryAfter) return;
2627
+ const matches = actual.sessionId === saved.pi.sessionId && (saved.pi.sessionFile === undefined || actual.sessionFile === saved.pi.sessionFile);
2628
+ try {
2629
+ const expected = { projectId, projectRoot: opts.cwd, operationId: saved.operationId, digest: piContinuity.digest };
2630
+ const settled = matches ? consumeRecoveryContinuity(opts.stateDir, expected) : retireRecoveryContinuity(opts.stateDir, expected, "authenticated_session_changed").retired;
2631
+ piSettlementFailure = undefined;
2632
+ if (settled) {
2633
+ piContinuity = { state: "absent" };
2634
+ if (!matches) log("Pi archived continuity retired after recording a different authenticated session");
2635
+ }
2636
+ else {
2637
+ // Re-read after the failed exact CAS; never settle a newer bookmark in this call.
2638
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2639
+ if (piContinuity.state !== "absent") log("Pi continuity bookmark changed before attachment settlement; retained for inspection");
2640
+ }
2641
+ } catch (error) {
2642
+ const signature = error instanceof Error ? `${error.name}:${error.message}` : String(error);
2643
+ const report = piSettlementFailure?.key !== key || piSettlementFailure.error !== signature;
2644
+ piSettlementFailure = { key, error: signature, retryAfter: Date.now() + 30_000 };
2645
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2646
+ if (report) log("Pi continuity attachment settlement unavailable; bookmark retained");
2647
+ }
2648
+ }
2571
2649
  const recordSessions = () => {
2572
2650
  if (stopping) return;
2651
+ const pi = bus.peers.get("pi");
2652
+ if (piContinuity.state === "ready" && (!(pi instanceof PiPeer) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
2653
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2654
+ }
2573
2655
  const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
2574
2656
  let meta: Record<string, unknown> = {};
2575
2657
  try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
2576
2658
  return { peer: p.id, meta };
2577
2659
  });
2578
2660
  const text = JSON.stringify(peers);
2579
- if (text === sessionsWritten) return;
2580
- sessionsWritten = text;
2581
- try { writeSessions(opts.stateDir, { instanceId, at: Date.now(), peers }); } catch (error) { log(`session record not written: ${(error as Error).message}`); }
2661
+ try {
2662
+ if (text !== sessionsWritten) {
2663
+ writeSessions(opts.stateDir, { instanceId, at: Date.now(), peers });
2664
+ sessionsWritten = text;
2665
+ }
2666
+ settlePiContinuity(); // the identical snapshot may already be durable; never retire after a failed write
2667
+ } catch (error) { log(`session record not written: ${(error as Error).message}`); }
2582
2668
  };
2583
2669
  const recoverAfterCrash = async (prev: SessionsFile) => {
2584
2670
  const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
@@ -2586,6 +2672,7 @@ export async function startDaemon(opts: DaemonOptions) {
2586
2672
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
2587
2673
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
2588
2674
  for (const step of crashPlan(prev.peers)) {
2675
+ if (step.peer === "pi" && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
2589
2676
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
2590
2677
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
2591
2678
  if (step.peer === "pi" && piAutoStart()) {
@@ -2601,7 +2688,7 @@ export async function startDaemon(opts: DaemonOptions) {
2601
2688
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
2602
2689
  continue;
2603
2690
  }
2604
- if (!step.resume || !autoResume) {
2691
+ if (!step.resume || !autoResume()) {
2605
2692
  report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
2606
2693
  continue;
2607
2694
  }
@@ -2626,7 +2713,7 @@ export async function startDaemon(opts: DaemonOptions) {
2626
2713
  // #248: the add-ons it works with, once per attach (names and versions only).
2627
2714
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
2628
2715
  // still did its work under that attachment.
2629
- if (e.state === "offline") attachedNow.delete(e.peer);
2716
+ if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
2630
2717
  else if (!attachedNow.has(e.peer)) {
2631
2718
  attachedNow.add(e.peer);
2632
2719
  const peer = e.peer, attachment = ++attachments;
@@ -2647,20 +2734,21 @@ export async function startDaemon(opts: DaemonOptions) {
2647
2734
  const pii = holdsPii(e.peer);
2648
2735
  for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
2649
2736
  turnTasks.set(id, board.list().filter((t) => t.owner === e.peer && ["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state)).map((t) => t.id));
2650
- const start = turnLog && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
2651
- try { turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
2737
+ const capture = config.snapshots.enabled && !!turnLog;
2738
+ const start = capture && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
2739
+ try { if (capture) turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
2652
2740
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
2653
- turns.set(e.peer, { id, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
2741
+ turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
2654
2742
  event({ type: "turn_start", peer: e.peer, turn: id });
2655
2743
  } else if (!busy && open) {
2656
2744
  turns.delete(e.peer);
2657
2745
  let afterTurn: (() => void) | undefined;
2658
2746
  let files: number | undefined;
2659
2747
  let snapshotMs = open.snapshotMs;
2660
- if (turnLog && (open.private || holdsPii(e.peer))) {
2748
+ if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
2661
2749
  try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
2662
- } else if (turnLog) {
2663
- const end = snap(`the end of ${open.id}`);
2750
+ } else if (open.capture && turnLog) {
2751
+ const end = snap(`the end of ${open.id}`, true);
2664
2752
  const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
2665
2753
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
2666
2754
  snapshotMs = (snapshotMs ?? 0) + end.ms;
@@ -2825,7 +2913,7 @@ export async function startDaemon(opts: DaemonOptions) {
2825
2913
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
2826
2914
  const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
2827
2915
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
2828
- (error) => log(`Pi auto-start failed: ${(error as Error).message}`));
2916
+ (error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
2829
2917
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
2830
2918
  replacingPi.add(pi);
2831
2919
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
@@ -2846,6 +2934,7 @@ export async function startDaemon(opts: DaemonOptions) {
2846
2934
  const running = startPeerOnce(peer, args).then(async result => {
2847
2935
  if (!result.ok && peer === "codex") unattendedPeers.delete(peer);
2848
2936
  if (!result.ok || result.already) return result;
2937
+ if (peer === "pi") recordSessions();
2849
2938
  return permissionChange(peer, async () => {
2850
2939
  if (peer === "codex") { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
2851
2940
  logPermissionStart(peer); writeStatus(); return result;
@@ -2874,6 +2963,8 @@ export async function startDaemon(opts: DaemonOptions) {
2874
2963
  const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
2875
2964
  return { peer, mode, attached: !!owner && owner.state !== "offline", command: peer === "kimi" ? "ahub kimi" : `ahub ${startWords(peer, mode).join(" ")}`, ...(can ? (can.ok ? { via: can.via } : { why: can.why }) : {}) };
2876
2965
  });
2966
+ // Printed for a person's explicit reconciliation only; never executed by auto-start.
2967
+ const continuityCleanup = () => `After inspection, archive the active bookmark with ${["mv", "-n", "--", join(opts.stateDir, "recovery-continuity.json"), join(opts.stateDir, `recovery-continuity.retired.manual.${randomUUID()}.json`)].map(shellQuote).join(" ")} before choosing another session`;
2877
2968
  async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
2878
2969
  if (stopping) return { ok: false, error: "hub is stopping" };
2879
2970
  if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
@@ -2881,7 +2972,31 @@ export async function startDaemon(opts: DaemonOptions) {
2881
2972
  if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
2882
2973
  if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
2883
2974
  if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
2975
+ const continuity = peer === "pi" && by === "pi.auto_start"
2976
+ ? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
2977
+ : undefined;
2978
+ if (continuity?.state === "invalid") return { ok: false, error: `archived Pi continuity evidence is invalid or unreadable; no fresh session was started. ${continuityCleanup()}` };
2979
+ const resume = continuity?.state === "ready" ? continuity.bookmark.pi : undefined;
2884
2980
  const mode = startMode(peer);
2981
+ if (resume?.sessionFile && continuity?.state === "ready") {
2982
+ try { validatePiSession(opts.stateDir, opts.cwd, resume.sessionFile, resume.sessionId); }
2983
+ catch (error) {
2984
+ const retry = `ahub pi --mode ${mode} --session-file ${shellQuote(resume.sessionFile)}`;
2985
+ if (error instanceof PiSessionUnresumable || error instanceof PiSessionBookmarkMismatch) {
2986
+ try {
2987
+ const retired = retireRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd, operationId: continuity.bookmark.operationId, digest: continuity.digest }, "resume_unavailable");
2988
+ if (retired.retired) {
2989
+ piContinuity = { state: "absent" };
2990
+ const diagnosis = error instanceof PiSessionBookmarkMismatch ? "Pi continuity bookmark does not match the preserved session" : "recorded Pi session is unresumable";
2991
+ return { ok: false, error: `${diagnosis}; continuity evidence was archived; no fresh session was started. Inspect the preserved session, then select a valid managed file with ahub pi --mode ${mode} --session-file <managed-file>` };
2992
+ }
2993
+ } catch { return { ok: false, error: `Pi continuity retirement could not be confirmed; bookmark retained; no fresh session was started. After inspecting storage, retry ${retry}. ${continuityCleanup()}` }; }
2994
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2995
+ return { ok: false, error: `Pi continuity changed before retirement; inspect recovery-continuity.json; no fresh session was started. Retry the intended session with ${retry}. ${continuityCleanup()}` };
2996
+ }
2997
+ return { ok: false, error: `Pi continuity session storage could not be verified; bookmark retained; no fresh session was started. After inspecting storage, retry ${retry}. ${continuityCleanup()}` };
2998
+ }
2999
+ }
2885
3000
  if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
2886
3001
  const owner = bus.peers.get(peer);
2887
3002
  if (mode === "headless") {
@@ -2890,23 +3005,26 @@ export async function startDaemon(opts: DaemonOptions) {
2890
3005
  // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
2891
3006
  // issued to tell the two apart.
2892
3007
  if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
2893
- return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless" } : {});
3008
+ return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless", ...(resume ?? {}) } : {});
2894
3009
  }
2895
3010
  if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
2896
- const command = `ahub ${startWords(peer, mode).join(" ")}`;
3011
+ const words = [...startWords(peer, mode)];
3012
+ if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
3013
+ const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
2897
3014
  const since = Date.now() - (opening.get(peer) ?? 0);
2898
3015
  if (since < PEER_START_RETRY_MS) return { ok: false, error: `a terminal for ${peer} was opened ${Math.round(since / 1000)}s ago; wait for its TUI, or run ${command} yourself`, command };
2899
3016
  // Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
2900
3017
  opening.set(peer, Date.now());
3018
+ if (resume && (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer))) { opening.delete(peer); return { ok: false, error: "Pi lifecycle changed before its continuity terminal opened; bookmark retained" }; }
2901
3019
  // Every word is the hub's own: its runtime, its entry point, this project and a peer from the closed list.
2902
- const opened = await opener.open(`${peer} (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, ...startWords(peer, mode)]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
3020
+ const opened = await opener.open(`${peer} (agent-hub)`, [process.execPath, CLI_ENTRY, "--project", opts.cwd, ...words]).catch((error: Error) => ({ ok: false as const, why: "the terminal provider failed", detail: error.message }));
2903
3021
  if (!opened.ok) {
2904
3022
  opening.delete(peer); // nothing was opened: the next request may try again at once
2905
3023
  if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
2906
3024
  const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
2907
3025
  return { ok: false, error: `${peer} starts in its TUI and the hub could not open a terminal for it (${opened.why}). Run ${command} in a terminal${headless}`, command };
2908
3026
  }
2909
- try { notify(`${peer}: ${by} opened a terminal running ${command} (${opened.via}); it attaches when its TUI is ready`); } catch { /* the terminal is open either way */ }
3027
+ try { notify(resume ? `pi: pi.auto_start opened a terminal for its recorded archived session (${opened.via}); attachment is not yet verified` : `${peer}: ${by} opened a terminal running ${command} (${opened.via}); it attaches when its TUI is ready`); } catch { /* the terminal is open either way */ }
2910
3028
  return { ok: true, mode, opened: opened.via, command };
2911
3029
  }
2912
3030
 
@@ -2965,6 +3083,7 @@ export async function startDaemon(opts: DaemonOptions) {
2965
3083
  async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }): Promise<Record<string, unknown>> {
2966
3084
  let unmute: (() => void) | undefined;
2967
3085
  try {
3086
+ applyPendingPermissionDefault(peer);
2968
3087
  if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
2969
3088
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
2970
3089
  } finally {
@@ -2973,8 +3092,12 @@ export async function startDaemon(opts: DaemonOptions) {
2973
3092
  }
2974
3093
 
2975
3094
  async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
3095
+ const adapter = definitionOf(peer);
2976
3096
  if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
2977
- if (peer === "qwen" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Qwen TUI is not offered before its native verification; use ahub qwen --mode headless" };
3097
+ if (adapter?.transport === "acp" && adapter.start.create) {
3098
+ const refusal = adapter.start.preflight?.({ instance: peer, form: args.mode });
3099
+ if (refusal) return { ok: false, error: refusal };
3100
+ }
2978
3101
  if (peer === "kimi") {
2979
3102
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
2980
3103
  const owner = bus.peers.get(peer);
@@ -3061,32 +3184,36 @@ export async function startDaemon(opts: DaemonOptions) {
3061
3184
  catch { await jcode.stop().catch(() => {}); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
3062
3185
  return { ok: true, policy: "unmanaged" };
3063
3186
  }
3064
- if (peer === "qwen") {
3065
- if (args.mode === "tui") return { ok: false, error: "Qwen TUI is not offered before its native verification; use ahub qwen --mode headless" };
3066
- const launch = buildQwenLaunch(config.qwen_cmd, args.model);
3067
- const qwen = new AcpPeer("qwen", {
3068
- onActivity: item => activityRings.qwen.append(item),
3069
- isPrivateTurn: () => !!turns.get("qwen")?.private || contextPii("qwen"),
3070
- cmd: [launch.cmd, ...launch.args], qwen025: true, permissionMode: permissionMode("qwen"),
3187
+ if (adapter?.transport === "acp" && adapter.start.create) {
3188
+ // ponytail: A1 migrates one ACP factory; A2 generalizes its native config and activity ports before adding another.
3189
+ const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
3190
+ userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
3191
+ const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3192
+ const permission = permissionMode(peer);
3193
+ const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3194
+ onActivity: item => activityRings[peer as keyof typeof activityRings].append(item),
3195
+ isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3196
+ cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3071
3197
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3072
3198
  cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
3073
3199
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3074
3200
  log: () => log("Qwen runtime diagnostic: details withheld"),
3075
3201
  onUsageDiagnostic: (observation, sessionId) => {
3076
3202
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3077
- const current = contextSession("qwen"); if (!current) return;
3078
- contexts.report("qwen", observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3203
+ const current = contextSession(peer); if (!current) return;
3204
+ contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3079
3205
  ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3080
3206
  : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
3081
3207
  },
3082
- onTurnFailure: () => { supervisionTurns.delete("qwen"); },
3083
- mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("qwen", true)).map(([name, value]) => ({ name, value })) }],
3084
- preamble: sessionRoleContract("qwen"),
3085
- });
3086
- recoveryTaskPreface("qwen"); await ensurePreface("qwen");
3087
- try { await qwen.start(); await reconcileNativeStart(peer, qwen); }
3208
+ onTurnFailure: () => { supervisionTurns.delete(peer); },
3209
+ mcpServers,
3210
+ preamble: sessionRoleContract(peer),
3211
+ } } });
3212
+ if (!(owner instanceof AcpPeer)) throw new Error("ACP factory did not return its native transport");
3213
+ recoveryTaskPreface(peer); await ensurePreface(peer);
3214
+ try { await owner.start(); await reconcileNativeStart(peer, owner); }
3088
3215
  catch (error) {
3089
- await qwen.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
3216
+ await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
3090
3217
  if (error instanceof QwenIdentityError) throw error;
3091
3218
  throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
3092
3219
  }
@@ -3220,11 +3347,11 @@ export async function startDaemon(opts: DaemonOptions) {
3220
3347
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
3221
3348
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
3222
3349
  if (problem) return { ok: false, error: problem };
3223
- modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: currentRouting(opts.cwd, log).pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
3350
+ modelRelay ??= await startModelRelay({ omni, admitRequest: admitPiRequest, dgxMaxInputTokens: activeRouting().pi.dgx_max_context_tokens, allowedDGXmodels: { "dgx/coding": config.pi.dgx_coding, "dgx/fast": config.pi.dgx_fast }, mlx: config.mlx.enabled === false ? undefined : config.mlx, mlxAlias: "mlx/fast", fallbackDGXAlias: "dgx/fast", enableHubAuto: true,
3224
3351
  routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
3225
- staySwitch: () => currentRouting(opts.cwd, log),
3352
+ staySwitch: () => activeRouting(),
3226
3353
  onRoute: record => event({ type: "route", peer: "pi", ...record }),
3227
- efficientWaitMs: currentRouting(opts.cwd, log).pi.efficient_wait_ms,
3354
+ efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3228
3355
  underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
3229
3356
  onCooldown: record => {
3230
3357
  log(`model ${record.alias}: ${record.event === "start" ? `cooling down for ${Math.round(record.ms! / 1000)} s after ${record.failures} transport or startup failures` : "cooldown ended"}`);
@@ -3264,7 +3391,7 @@ export async function startDaemon(opts: DaemonOptions) {
3264
3391
  return typeof refused === "string" ? `not sent: ${refused}` : "sent";
3265
3392
  },
3266
3393
  };
3267
- const routing = currentRouting(opts.cwd, log);
3394
+ const routing = activeRouting();
3268
3395
  const pi = new PiPeer("pi", {
3269
3396
  cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
3270
3397
  model: args.model,
@@ -3312,7 +3439,7 @@ export async function startDaemon(opts: DaemonOptions) {
3312
3439
  if (backend !== "auto") return backend === "mlx" ? "mlx/fast" : "dgx/coding";
3313
3440
  const taskId = envs.find(env => env.refs?.task)?.refs?.task;
3314
3441
  const task = taskId ? board.get(Number(taskId)) : undefined;
3315
- const policyBackend = task ? currentRouting(opts.cwd, log).classes[task.class]?.pi_backend : undefined;
3442
+ const policyBackend = task ? activeRouting().classes[task.class]?.pi_backend : undefined;
3316
3443
  if (policyBackend === "mlx") {
3317
3444
  if (config.mlx.enabled !== false) return "mlx/fast";
3318
3445
  const problem = disabledMlxPolicyProblem(config, opts.cwd);
@@ -3380,7 +3507,7 @@ export async function startDaemon(opts: DaemonOptions) {
3380
3507
  return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
3381
3508
  }
3382
3509
  if (peer === "local") {
3383
- const routing = currentRouting(opts.cwd, log);
3510
+ const routing = activeRouting();
3384
3511
  // `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
3385
3512
  const route = args.model ? undefined : (args.route ?? routing.local.route);
3386
3513
  if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) return { ok: false, ...(existing && existing.state !== "offline" ? { already: true } : {}), error: `routing.toml has no route "${route}"` };
@@ -3427,8 +3554,8 @@ export async function startDaemon(opts: DaemonOptions) {
3427
3554
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
3428
3555
  ...(route ? { route } : {}),
3429
3556
  ...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
3430
- hubRoutes: () => currentRouting(opts.cwd, log).hub_routes ?? {},
3431
- staySwitch: () => currentRouting(opts.cwd, log),
3557
+ hubRoutes: () => activeRouting().hub_routes ?? {},
3558
+ staySwitch: () => activeRouting(),
3432
3559
  onRoute: record => event({ type: "route", peer: "local", ...record }),
3433
3560
  onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
3434
3561
  onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
@@ -3471,7 +3598,7 @@ export async function startDaemon(opts: DaemonOptions) {
3471
3598
  }
3472
3599
 
3473
3600
  async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
3474
- const routing = currentRouting(opts.cwd, log);
3601
+ const routing = activeRouting();
3475
3602
  if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
3476
3603
  const models = await omni.models();
3477
3604
  const required = new Set([fixedModel]);
@@ -3655,7 +3782,7 @@ export async function startDaemon(opts: DaemonOptions) {
3655
3782
  instanceId,
3656
3783
  attention: attentionStatus.attentionInput,
3657
3784
  pet,
3658
- status: { peers: peerView, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
3785
+ status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
3659
3786
  boardCapabilities: { backlog: true },
3660
3787
  planningCapabilities: { enabled: true, wipPull: true, split: true },
3661
3788
  releases: tasks.releaseList(USER),
@@ -3690,6 +3817,237 @@ export async function startDaemon(opts: DaemonOptions) {
3690
3817
  };
3691
3818
  }
3692
3819
 
3820
+ /** Set a validated leaf in place: objects captured by policy consumers retain their identity. */
3821
+ function putConfig(target: object, key: string, value: unknown): void {
3822
+ const parts = key.split(".");
3823
+ if (parts.some(part => ["__proto__", "constructor", "prototype"].includes(part))) throw new Error("unsafe configuration path");
3824
+ let node = target as Record<string, unknown>;
3825
+ for (const part of parts.slice(0, -1)) {
3826
+ if (!Object.hasOwn(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
3827
+ node = node[part] as Record<string, unknown>;
3828
+ }
3829
+ if (value === undefined) delete node[parts.at(-1)!];
3830
+ else node[parts.at(-1)!] = structuredClone(value);
3831
+ }
3832
+ function changedRawPaths(before: unknown, after: unknown, prefix = ""): string[] {
3833
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
3834
+ if (object(before) || object(after)) {
3835
+ const old = object(before) ? before : {}, next = object(after) ? after : {};
3836
+ return [...new Set([...Object.keys(old), ...Object.keys(next)])].flatMap(key => changedRawPaths(old[key], next[key], prefix ? `${prefix}.${key}` : key));
3837
+ }
3838
+ return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
3839
+ }
3840
+ function declaredPaths(value: unknown, prefix = ""): string[] {
3841
+ // Switchyard dictionaries have opaque keys (including dots); publish each dictionary as one value.
3842
+ if (["routing.targets", "routing.routes", "routing.hub_routes"].includes(prefix)) return [prefix];
3843
+ if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
3844
+ return prefix ? [prefix] : [];
3845
+ }
3846
+ const isMachineKey = (key: string) => ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
3847
+ function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
3848
+ // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
3849
+ const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
3850
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled));
3851
+ return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
3852
+ }
3853
+ function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
3854
+ const fileConfig = loadConfig(opts.cwd, sources.layers);
3855
+ validateEffectiveConfig(fileConfig);
3856
+ const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
3857
+ const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
3858
+ const previews: { key: string; before: string; after: string; effect?: string }[] = [];
3859
+ const paths = new Set<string>();
3860
+ for (const layer of [...sources.trustLayers, ...acceptedSources.trustLayers]) {
3861
+ const prefix = layer.name.endsWith(".toml") ? "routing" : "";
3862
+ for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
3863
+ }
3864
+ if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
3865
+ for (const key of paths) {
3866
+ if (scope && key !== scope) continue;
3867
+ const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
3868
+ const layers = sources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
3869
+ const priorLayers = acceptedSources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
3870
+ const current = [...layers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
3871
+ const prior = [...priorLayers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
3872
+ const changedLayers = [...new Set([...layers.map(layer => layer.name), ...priorLayers.map(layer => layer.name)])].filter(name => JSON.stringify(valueAt(layers.find(layer => layer.name === name)?.raw, path)) !== JSON.stringify(valueAt(priorLayers.find(layer => layer.name === name)?.raw, path)));
3873
+ const rawChanged = changedLayers.length > 0;
3874
+ if (opts.config && !rawChanged) continue; // embedding overrides are not file defaults
3875
+ const before = valueAt(route ? desiredRouting : desiredConfig, path);
3876
+ const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
3877
+ const rawAfter = current ? valueAt(current.raw, path) : undefined;
3878
+ if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
3879
+ const machine = !route && isMachineKey(key);
3880
+ const origin = current ?? prior;
3881
+ let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
3882
+ if (!machine && tracked === undefined && !configRepositoryPresent(opts.cwd)) tracked = false;
3883
+ const guard = SETTINGS.find(def => def.store === (route ? "routing" : "config") && def.path.join(".") === path.join("."));
3884
+ const acceptedFallback = !machine && tracked !== undefined && !!current && JSON.stringify(after) === JSON.stringify(valueAt(route ? startupRouting : embeddedConfig, path)) && JSON.stringify(after) === JSON.stringify(rawAfter) && JSON.stringify(rawAfter) === JSON.stringify(valueAt(startupSources.find(layer => layer.name === current.name)?.raw, path));
3885
+ if (key.startsWith("roster.") && scope !== key && JSON.stringify(before) !== JSON.stringify(after)) {
3886
+ rejected.push({ key, reason: "use the confirmed ahub roster add/remove setting; a full reload cannot bypass peer lifecycle" });
3887
+ continue;
3888
+ }
3889
+ let settledHolderChange = false, effect: string | undefined;
3890
+ if (key === "planner" || key === "roles" || key.startsWith("roles.")) {
3891
+ const prospective = structuredClone(candidate);
3892
+ putConfig(prospective, path.join("."), after);
3893
+ const oldHolders = roleHolders(candidate), newHolders = roleHolders(prospective);
3894
+ settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
3895
+ if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
3896
+ }
3897
+ const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
3898
+ const decision = authorizeConfigLeaf(input);
3899
+ if (!decision.allowed) {
3900
+ const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
3901
+ rejected.push({ key, reason: next });
3902
+ if (!scope && authority === "terminal" && authorizeConfigLeaf({ ...input, confirmed: true }).allowed) {
3903
+ const shown = { key, before: before === undefined ? "(unset)" : JSON.stringify(before)!, after: after === undefined ? "(unset)" : JSON.stringify(after)!, ...(effect ? { effect } : {}) };
3904
+ if (key.length > 160 || Buffer.byteLength(shown.before) > 16_384 || Buffer.byteLength(shown.after) > 16_384 || effect && Buffer.byteLength(effect) > 2048) rejected.at(-1)!.reason += "; complete preview exceeds the 16 KiB value/2 KiB effect bound; reduce this edit before confirmation";
3905
+ else { guarded.push(key); previews.push(shown); }
3906
+ }
3907
+ continue;
3908
+ }
3909
+ putConfig(route ? routing : candidate, path.join("."), after);
3910
+ if (route) {
3911
+ routing.sources ??= {};
3912
+ const origin = sources.routing.sources?.[path.join(".")];
3913
+ if (origin) routing.sources[path.join(".")] = origin; else delete routing.sources[path.join(".")];
3914
+ }
3915
+ if (!route && path[0] === "permission_modes" && path[1]) {
3916
+ candidate.permission_default_sources ??= {};
3917
+ const source = fileConfig.permission_default_sources?.[path[1]];
3918
+ if (source) candidate.permission_default_sources[path[1]] = source;
3919
+ else delete candidate.permission_default_sources[path[1]];
3920
+ }
3921
+ accepted.push(key);
3922
+ }
3923
+ // Describe the guarded transaction against all safe changes already admitted by this plan.
3924
+ const rolePreviews = previews.filter(item => item.key === "planner" || item.key === "roles" || item.key.startsWith("roles."));
3925
+ if (rolePreviews.length) {
3926
+ const prospective = structuredClone(candidate);
3927
+ for (const item of rolePreviews) putConfig(prospective, item.key, item.after === "(unset)" ? undefined : JSON.parse(item.after));
3928
+ const before = roleHolders(candidate), after = roleHolders(prospective);
3929
+ const effect = `planner ${JSON.stringify(before.planners)} -> ${JSON.stringify(after.planners)}; conductor ${JSON.stringify(before.conductors)} -> ${JSON.stringify(after.conductors)}`;
3930
+ for (const item of rolePreviews) item.effect = effect;
3931
+ }
3932
+ guarded.sort(); previews.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
3933
+ if (previews.some(item => item.effect && Buffer.byteLength(item.effect) > 2048) || guarded.length > 200 || Buffer.byteLength(JSON.stringify({ token: "x".repeat(36), keys: guarded, changes: previews })) > 131_072) {
3934
+ for (const item of rejected) if (guarded.includes(item.key)) item.reason += "; combined confirmation preview exceeds its 128 KiB bound; split this edit";
3935
+ guarded.length = 0; previews.length = 0;
3936
+ }
3937
+ const changes = configChanges({ ...config, roles: policyRoles }, candidate);
3938
+ const running = structuredClone({ ...config, roles: policyRoles });
3939
+ for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
3940
+ validateEffectiveConfig(running);
3941
+ conductorPeer(running.roles);
3942
+ for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
3943
+ const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
3944
+ if (problem) throw new Error(problem);
3945
+ return { sources, fileConfig, candidate, running, routing, changes, accepted, rejected, guarded, previews };
3946
+ }
3947
+ /** Acknowledging one field never hides another pending edit in the same file. */
3948
+ function acknowledgeConfiguration(sources: ConfigSources, paths: readonly string[]): void {
3949
+ const layers = acceptedSources.trustLayers.map(layer => ({ ...layer, raw: structuredClone(layer.raw) }));
3950
+ for (const name of CONFIG_SOURCE_FILES) {
3951
+ let layer = layers.find(layer => layer.name === name);
3952
+ const relevant = paths.filter(key => key.startsWith("routing.") === name.endsWith(".toml"));
3953
+ if (!relevant.length) continue;
3954
+ if (!layer) { layer = { name, raw: {} }; layers.push(layer); }
3955
+ const next = sources.trustLayers.find(layer => layer.name === name)?.raw;
3956
+ for (const key of relevant) {
3957
+ const path = key.startsWith("routing.") ? key.slice(8) : key;
3958
+ const raw = valueAt(next, path.split("."));
3959
+ const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
3960
+ if (raw !== undefined && winning?.name !== name) continue; // a shadowed edit was never authorized by its no-op effective value
3961
+ putConfig(layer.raw, path, raw);
3962
+ }
3963
+ }
3964
+ acceptedSources = { ...sources, trustLayers: layers, layers: layers.filter((layer): layer is typeof layer & { name: "config.json" | "config.local.json" } => layer.name.endsWith(".json")), routing: desiredRouting };
3965
+ }
3966
+ function configWriteCheck(scratch: string, authority: SettingAuthority, scope?: string): HubConfig {
3967
+ const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
3968
+ texts["config.json"] = next["config.json"];
3969
+ texts["config.local.json"] = next["config.local.json"];
3970
+ const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
3971
+ const refusal = prepared.rejected.find(item => item.key === scope);
3972
+ if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
3973
+ return prepared.fileConfig;
3974
+ }
3975
+ function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
3976
+ const texts = configSourceTexts(opts.cwd);
3977
+ texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
3978
+ const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
3979
+ const refusal = prepared.rejected.find(item => item.key === scope);
3980
+ if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
3981
+ }
3982
+ function runConfigWatch(): void {
3983
+ if (stopping) return;
3984
+ if (rosterChanging) { configWatchPending = true; return; }
3985
+ void reloadConfig("ordinary");
3986
+ }
3987
+ /** The only publication path; named writes are scoped, full guarded reloads need person confirmation. */
3988
+ async function reloadConfig(authority: SettingAuthority, scope?: string, confirm?: unknown): Promise<Record<string, unknown>> {
3989
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding configuration reload" };
3990
+ let fingerprint = "unavailable", published = false;
3991
+ try {
3992
+ const texts = configSourceTexts(opts.cwd);
3993
+ fingerprint = configFingerprint(texts);
3994
+ if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
3995
+ const sources = parseConfigSources(texts);
3996
+ const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
3997
+ if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
3998
+ if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
3999
+ const { changes, candidate } = prepared;
4000
+ const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined }, { ...prepared.routing, sources: undefined }, "routing");
4001
+ if (prepared.running.snapshots.enabled && repo && !turnLog) turnLog = new Turns(join(opts.stateDir, "hub.db"));
4002
+ const priorDesired = JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers]);
4003
+ const rolesChanged = changes.some(change => change.key === "roles" || change.key.startsWith("roles.") || change.key === "planner");
4004
+ const beforeConductor = conductorPeer(config.roles);
4005
+ published = true;
4006
+ for (const change of changes) {
4007
+ if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
4008
+ putConfig(config, change.key, change.after);
4009
+ }
4010
+ if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
4011
+ for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
4012
+ for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
4013
+ }
4014
+ config.permission_default_sources = candidate.permission_default_sources;
4015
+ for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
4016
+ if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
4017
+ if (changes.some(change => ["batch_max", "batch_ms", "queue_cap"].includes(change.key))) bus.configureScheduling({ batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap });
4018
+ if (changes.some(change => change.key === "watchdog_ms")) for (const peer of bus.peers.values()) if (peer instanceof BasePeer) peer.configureWatchdog(config.watchdog_ms);
4019
+ Object.assign(limits, config.limits);
4020
+ turnFreeNow = undefined;
4021
+ runningRouting = { ...prepared.routing, pi: runningRouting.pi };
4022
+ desiredConfig = structuredClone(candidate);
4023
+ desiredRouting = structuredClone(prepared.routing);
4024
+ acknowledgeConfiguration(sources, prepared.accepted);
4025
+ if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4026
+ pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4027
+ pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
4028
+ storedConfigCache = undefined;
4029
+ if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
4030
+ bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
4031
+ }
4032
+ if (confirm !== undefined) reloadConfirmation = undefined;
4033
+ if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
4034
+ const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
4035
+ const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
4036
+ if (failure && lastReloadFailure !== failure) { try { notify(`configuration keys retained: ${sanitize(refusal).slice(0, 1200)}`); } catch { log("configuration keys retained; notice unavailable"); } }
4037
+ lastReloadFailure = failure;
4038
+ writeStatus();
4039
+ const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
4040
+ return { ok: true, applied, pendingRestart, rejected: prepared.rejected,
4041
+ ...(prepared.guarded.length ? { confirmation: { token: reloadConfirmation!.token, keys: reloadConfirmation!.keys, changes: prepared.previews } } : {}),
4042
+ text: [applied.length ? `configuration reloaded: ${applied.join(", ")}` : pendingRestart.length ? `pending restart: ${pendingRestart.map(item => item.key).join(", ")}` : "configuration unchanged", ...(prepared.rejected.length ? [`retained keys: ${prepared.rejected.map(item => item.key).join(", ")}; review ahub settings reload or revert the file`] : [])].join("\n") };
4043
+ } catch (error) {
4044
+ const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
4045
+ const failure = `${fingerprint}:${message}`;
4046
+ if (lastReloadFailure !== failure) { lastReloadFailure = failure; try { notify(`configuration reload ${published ? "unconfirmed" : "refused"}: ${message}; run ahub settings reload after reviewing or reverting the file`); } catch { log("configuration reload notice unavailable"); } }
4047
+ return { ok: false, error: message, ...(published ? { applied: true } : {}), pendingRestart };
4048
+ }
4049
+ }
4050
+
3693
4051
  // #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
3694
4052
  let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
3695
4053
  /** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
@@ -3702,18 +4060,6 @@ export async function startDaemon(opts: DaemonOptions) {
3702
4060
  }
3703
4061
  return storedConfigCache.config;
3704
4062
  };
3705
- /** Stored start modes and the scoped instruction policy follow a settings write or undo. */
3706
- /** #455: the stored planner key, applied at once; planning authority and backlog notices follow config.roles. */
3707
- const applyPlanner = () => { const stored = storedConfig(); if (stored) { config.planner = stored.planner; settlePlanner(); writeStatus(); } };
3708
- const applyStartModes = () => { const stored = storedConfig(); if (stored) { config.peers = stored.peers; config.protect_instruction_files = stored.protect_instruction_files; } };
3709
- /** Apply only the named peer; unrelated hand edits retain their prior publication scope. */
3710
- const applyRoleSelection = (peer: string) => {
3711
- const stored = storedConfig(); if (!stored) return;
3712
- config.catalog_roles = { ...config.catalog_roles };
3713
- const ids = catalogAssignments(stored.catalog_roles)[peer];
3714
- if (ids === undefined) delete config.catalog_roles[peer]; else config.catalog_roles[peer] = ids;
3715
- roleAssignments[peer] = ids ?? resolvedProfile.catalog_roles[peer] ?? [];
3716
- };
3717
4063
  const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
3718
4064
  const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
3719
4065
  settingScalar(def.path[0] === "stay_switch" ? routing.stay_switch : (routing.classes[def.path[1] as TaskClass] as Record<string, unknown> | undefined)?.[def.path[2]!]);
@@ -3723,14 +4069,14 @@ export async function startDaemon(opts: DaemonOptions) {
3723
4069
  try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
3724
4070
  catch { return {}; }
3725
4071
  }
3726
- function settingsView(): { rows: SettingRow[]; undo?: string } {
4072
+ function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
3727
4073
  const stored = storedConfig();
3728
4074
  // What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
3729
4075
  // merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
3730
4076
  let routingProblem: string | undefined;
3731
4077
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
3732
4078
  const rows = settingRows({
3733
- cwd: opts.cwd, routing: currentRouting(opts.cwd, log), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4079
+ cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
3734
4080
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
3735
4081
  next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
3736
4082
  roles: roleState,
@@ -3742,11 +4088,11 @@ export async function startDaemon(opts: DaemonOptions) {
3742
4088
  });
3743
4089
  const undo = pendingUndo(opts.stateDir);
3744
4090
  const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
3745
- return { rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
4091
+ return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
3746
4092
  }
3747
4093
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
3748
- const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
3749
- event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}) });
4094
+ const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
4095
+ event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}), ...(reason ? { reason } : {}) });
3750
4096
  try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
3751
4097
  };
3752
4098
  const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
@@ -3782,7 +4128,7 @@ export async function startDaemon(opts: DaemonOptions) {
3782
4128
  ? `Stop ${peer}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`
3783
4129
  : undefined;
3784
4130
  }
3785
- async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false): Promise<Record<string, unknown>> {
4131
+ async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false, authority: SettingAuthority = "ordinary", reason?: string, undoIdentity?: UndoIdentity): Promise<Record<string, unknown>> {
3786
4132
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3787
4133
  const peer = def.path[1]!;
3788
4134
  let saved = false;
@@ -3797,13 +4143,13 @@ export async function startDaemon(opts: DaemonOptions) {
3797
4143
  }
3798
4144
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
3799
4145
  rosterChanging = peer;
3800
- if (undo) undoSetting(opts.cwd, opts.stateDir, (scratch) => loadConfig(scratch), routingCheck);
3801
- else writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => loadConfig(scratch));
4146
+ if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
4147
+ else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`));
3802
4148
  // Apply exactly the authorized key. Other file edits stay pending until the next hub start.
3803
4149
  config.roster = next;
3804
4150
  settlePlanner(); // #455: an automatic planner follows the roster
3805
4151
  saved = true;
3806
- recordSetting(def.key, before, after, source, undo);
4152
+ recordSetting(def.key, before, after, source, undo, reason);
3807
4153
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
3808
4154
  if (!after) {
3809
4155
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
@@ -3825,30 +4171,40 @@ export async function startDaemon(opts: DaemonOptions) {
3825
4171
  const owner = bus.peers.get(peer);
3826
4172
  if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
3827
4173
  }
4174
+ const reloaded = await reloadConfig(authority, `roster.${peer}`);
4175
+ if (reloaded.ok !== true) return { ...reloaded, saved: true };
3828
4176
  writeStatus();
3829
4177
  const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
3830
4178
  return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && peer === "codex" ? [`Codex 0.146.0 needs a manual return: resume the previous native session with ${codexResumeCommand()}.`] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
3831
4179
  } catch (error) {
3832
4180
  if (saved) writeStatus();
3833
4181
  return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
3834
- } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); } }
4182
+ } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
3835
4183
  }
3836
4184
  /** A checked definition and value, or the refusal. Every settings write and preview starts here. */
3837
4185
  function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
3838
4186
  const def = settingDef(key);
3839
4187
  if (typeof def === "string") return { error: def };
3840
- const routing = currentRouting(opts.cwd, log);
4188
+ const routing = activeRouting();
3841
4189
  try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
3842
4190
  catch (error) { return { error: (error as Error).message }; }
3843
4191
  }
3844
- async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown): Promise<Record<string, unknown>> {
4192
+ const settingReason = (reason: unknown): string | undefined => {
4193
+ if (reason === undefined) return undefined;
4194
+ if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
4195
+ return sanitize(reason.trim());
4196
+ };
4197
+ async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
4198
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
4199
+ let reason: string | undefined;
4200
+ try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
3845
4201
  const input = settingInput(key, raw);
3846
4202
  if ("error" in input) return { ok: false, error: input.error };
3847
4203
  const { def, value } = input;
3848
4204
  const refusal = settingRefusal(def, value, authority);
3849
4205
  if (refusal) return { ok: false, error: refusal };
3850
4206
  const source = settingSource(authority);
3851
- if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true);
4207
+ if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true, false, authority, reason);
3852
4208
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3853
4209
  const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
3854
4210
  // The console asks for y before never-ask; here the person types the peer it is for.
@@ -3859,53 +4215,63 @@ export async function startDaemon(opts: DaemonOptions) {
3859
4215
  const from = permissionMode(peer!);
3860
4216
  const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
3861
4217
  if (result.ok !== true) return result;
3862
- recordSetting(def.key, from, permissionMode(peer!), source);
4218
+ recordSetting(def.key, from, permissionMode(peer!), source, false, reason);
3863
4219
  return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
3864
4220
  }
4221
+ let saved = false;
3865
4222
  try {
3866
4223
  if (def.store === "routing") {
3867
- const from = routingValue(currentRouting(opts.cwd, log), def);
3868
- const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, routingCheck);
4224
+ const from = routingValue(activeRouting(), def);
4225
+ const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, (_routing, overlay) => routingWriteCheck(authority, overlay, def.key));
3869
4226
  if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
3870
- recordSetting(def.key, from, routingValue(next, def), source);
3871
- return { ok: true, text: `${def.key}: ${settingText(routingValue(next, def))} (in force now)` };
4227
+ saved = true;
4228
+ const result = await reloadConfig(authority, def.key);
4229
+ if (result.ok !== true) return { ...result, saved: true };
4230
+ recordSetting(def.key, from, routingValue(activeRouting(), def), source, false, reason);
4231
+ return { ok: true, text: `${def.key}: ${settingText(routingValue(activeRouting(), def))} (in force now)` };
3872
4232
  }
3873
- // Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
3874
4233
  if (peer && value === null && storedLocally(opts.cwd, def) && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" && confirm !== peer) return { ok: false, error: `removing the machine-local value leaves never-ask from config.json for ${peer}: it needs the peer id as the confirmation; nothing was changed` };
3875
4234
  const from = settingScalar(valueAt(storedConfig(), def.path));
3876
- if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
3877
- storedConfigCache = undefined;
4235
+ if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, def.path.join(".")))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
4236
+ saved = true;
4237
+ const result = await reloadConfig(authority, def.path.join("."));
4238
+ if (result.ok !== true) return { ...result, saved: true };
3878
4239
  const to = settingScalar(valueAt(storedConfig(), def.path));
3879
- if (def.type === "roles") applyRoleSelection(def.path[1]!); else if (def.key === "planner") applyPlanner(); else applyStartModes();
3880
- recordSetting(def.key, from, to, source);
3881
- if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
3882
- // Config trust (#17) can drop a machine-local field at load: the write happened, and the person is told it will not count.
3883
- const dropped = ignoredLine(storedConfig()?.ignored, def);
3884
- return { ok: true, text: dropped ? `${def.key}: written, but the hub does not read it (${dropped})` : `${def.key}: ${settingText(to)} (${def.applies === "live" ? "in force now" : def.applies === "peer start" ? "applies at the peer's next start" : "applies at the next hub start"})` };
4240
+ recordSetting(def.key, from, to, source, false, reason);
4241
+ if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
4242
+ const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
4243
+ : ["permission_modes", "catalog_roles", "peers"].includes(def.path[0]!) ? "applies at the peer's next start" : def.key === "coordination" ? "applies to new tasks" : "applies at next use";
4244
+ return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
3885
4245
  } catch (error) {
3886
- return { ok: false, error: (error as Error).message };
4246
+ return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
3887
4247
  }
3888
4248
  }
3889
- /** A pin the hub could not start with: refused before a write or an undo puts it on disk. */
3890
- // As at hub start: with no project routing.toml only a pin the overlay sets counts, never the shipped template's own.
3891
- const routingCheck = (routing: Routing) => { const problem = config.mlx.enabled === false ? mlxPinProblem(routing, existsSync(join(opts.cwd, ".agenthub", "routing.toml"))) : undefined; if (problem) throw new Error(problem); };
3892
- async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown): Promise<Record<string, unknown>> {
4249
+
4250
+ async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, expectedKey?: unknown, expectedDigest?: unknown): Promise<Record<string, unknown>> {
4251
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
4252
+ let reason: string | undefined;
4253
+ try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
3893
4254
  // The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
3894
4255
  if (authority === "ordinary") return { ok: false, error: "undo can put back a value that widens what agents do without asking: open a settings session with ahub ui --settings, or use ahub settings undo in a terminal" };
3895
- const def = settingDef(pendingUndo(opts.stateDir));
3896
- if (typeof def === "string") return { ok: false, error: "nothing to undo" };
3897
- if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true);
4256
+ const identity = pendingUndoIdentity(opts.stateDir);
4257
+ if (expectedKey !== undefined || expectedDigest !== undefined) {
4258
+ if (typeof expectedKey !== "string" || !expectedKey || typeof expectedDigest !== "string" || !/^[a-f0-9]{64}$/.test(expectedDigest)) return { ok: false, error: "invalid undo preview identity; preview again; nothing was undone" };
4259
+ if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
4260
+ }
4261
+ const def = settingDef(identity?.key);
4262
+ if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
4263
+ if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
3898
4264
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3899
- const read = (): SettingValue => def.store === "routing" ? routingValue(currentRouting(opts.cwd, log), def) : settingScalar(valueAt(storedConfig(), def.path));
4265
+ const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
3900
4266
  // never-ask is set with its confirmation, never put back by an undo that carries none.
3901
4267
  if (def.group === "Permissions" && restoredValue(opts.cwd, opts.stateDir, def) === "never-ask") return { ok: false, error: `undo would put never-ask back for ${def.path.at(-1)} without its confirmation: set ${def.key} again (never-ask, or inherit where the shared file holds it) with the confirmation instead; nothing was undone` };
3902
4268
  try {
3903
4269
  const from = read();
3904
- undoSetting(opts.cwd, opts.stateDir, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch), routingCheck);
3905
- storedConfigCache = undefined;
3906
- if (def.type === "roles") applyRoleSelection(def.path[1]!); else if (def.key === "planner") applyPlanner(); else applyStartModes();
3907
- recordSetting(def.key, from, read(), settingSource(authority), true);
3908
- if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
4270
+ undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
4271
+ const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
4272
+ if (result.ok !== true) return { ...result, saved: true };
4273
+ recordSetting(def.key, from, read(), settingSource(authority), true, reason);
4274
+ if (def.type === "roles") { prepareRoleSelection(def.path[1]!); event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) }); }
3909
4275
  return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
3910
4276
  } catch (error) {
3911
4277
  return { ok: false, error: (error as Error).message };
@@ -3919,21 +4285,21 @@ export async function startDaemon(opts: DaemonOptions) {
3919
4285
  if (def.group === "Roster") {
3920
4286
  try {
3921
4287
  const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
3922
- return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
4288
+ return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
3923
4289
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
3924
4290
  }
3925
4291
  if (def.key === "scoped_grants.protect_instructions") return { ok: true, lines: ["Applies now to scoped writes and edits of AGENTS.md, CLAUDE.md and QWEN.md, including symlinks and folded spellings.", "true asks a person; false keeps these documents grantable (the default). Explicit approval and never-ask keep their meaning."] };
3926
4292
  if (def.group === "Permissions") {
3927
4293
  const peer = def.path.at(-1)!;
3928
- return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])] };
4294
+ return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])], ...(value === "never-ask" || value === null && restoredValue(opts.cwd, opts.stateDir, def, true) === "never-ask" ? { confirm: peer } : {}) };
3929
4295
  }
3930
- if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : def.applies === "live" ? "in force as soon as it is saved" : "read at the next hub start"}`] };
4296
+ if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "next use" ? "applies at next use" : def.applies === "live" ? "in force as soon as it is saved" : "pending restart"}`] };
3931
4297
  try {
3932
4298
  const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
3933
4299
  const open = board.list().filter((task) => !isClosedTaskState(task.state));
3934
4300
  // A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
3935
4301
  const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
3936
- return { ok: true, lines: [`${def.key}: ${settingText(routingValue(currentRouting(opts.cwd, log), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
4302
+ return { ok: true, lines: [`${def.key}: ${settingText(routingValue(activeRouting(), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
3937
4303
  tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
3938
4304
  ...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
3939
4305
  } catch (error) {
@@ -3941,6 +4307,14 @@ export async function startDaemon(opts: DaemonOptions) {
3941
4307
  }
3942
4308
  }
3943
4309
 
4310
+ function settingsUndoPreview(): Record<string, unknown> {
4311
+ const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
4312
+ if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
4313
+ const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
4314
+ if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
4315
+ return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
4316
+ }
4317
+
3944
4318
  async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
3945
4319
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
3946
4320
  // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
@@ -4019,11 +4393,13 @@ export async function startDaemon(opts: DaemonOptions) {
4019
4393
  return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
4020
4394
  }
4021
4395
  case "setting":
4022
- return settingsSet(a.key, a.value, authority, a.confirm, a.now);
4396
+ return settingsSet(a.key, a.value, authority, a.confirm, a.now, a.reason);
4023
4397
  case "setting_undo":
4024
- return settingsUndo(authority, a.confirm, a.now);
4398
+ return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
4025
4399
  case "setting_preview":
4026
- return settingsPreview(a.key, a.value);
4400
+ return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
4401
+ case "setting_reload":
4402
+ return reloadConfig(authority);
4027
4403
  default:
4028
4404
  return bad;
4029
4405
  }
@@ -4093,7 +4469,7 @@ export async function startDaemon(opts: DaemonOptions) {
4093
4469
  armRecoveryLease();
4094
4470
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
4095
4471
  budget.setRecoveryHold(true);
4096
- bus.setRecoveryHold(true);
4472
+ bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
4097
4473
  await bus.fenceRecovery();
4098
4474
  { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4099
4475
  const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
@@ -4111,11 +4487,11 @@ export async function startDaemon(opts: DaemonOptions) {
4111
4487
  if (msg.op === "abort") {
4112
4488
  if (recoveryCommitted) return recoveryError("a committed recovery cannot be aborted");
4113
4489
  if (recoveryPhase === "released") return { t: "recovery", ok: true, recovery: recoveryView() };
4490
+ bus.setRecoveryHold(false);
4114
4491
  recoveryOperationId = undefined;
4115
4492
  recoveryPhase = undefined;
4116
4493
  recoveryPeerSnapshot = undefined;
4117
4494
  clearTimeout(recoveryLeaseTimer);
4118
- bus.setRecoveryHold(false);
4119
4495
  budget.setRecoveryHold(false);
4120
4496
  writeStatus();
4121
4497
  return { t: "recovery", ok: true, aborted: true, recovery: recoveryView() };
@@ -4123,6 +4499,7 @@ export async function startDaemon(opts: DaemonOptions) {
4123
4499
  if (msg.op === "commit") {
4124
4500
  if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
4125
4501
  recoveryPhase = "prepared";
4502
+ bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
4126
4503
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
4127
4504
  const currentPeers = recoveryPeers();
4128
4505
  const peers = (recoveryPeerSnapshot ?? Object.values(currentPeers)).map((saved) => ({
@@ -4153,7 +4530,10 @@ export async function startDaemon(opts: DaemonOptions) {
4153
4530
  return { t: "recovery", ok: true, committed: true, recovery: { ...recoveryView(), phase: "prepared" } };
4154
4531
  }
4155
4532
  if (msg.op === "release") {
4156
- if (recoveryPhase === "released") return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
4533
+ if (recoveryPhase === "released") {
4534
+ bus.setRecoveryHold(false); budget.setRecoveryHold(false);
4535
+ return { t: "recovery", ok: true, released: true, recovery: recoveryView() };
4536
+ }
4157
4537
  if (recoveryPhase !== "restored") return recoveryError("recovery is not restored");
4158
4538
  await bus.fenceRecovery();
4159
4539
  const current = recoveryPeers();
@@ -4425,7 +4805,8 @@ export async function startDaemon(opts: DaemonOptions) {
4425
4805
  try { log(`recovery ${sanitize(String(msg.op))} database contention: ${databaseBusySite(error).join("\n")}`); } catch { /* the reply still settles its rid */ }
4426
4806
  const busy = new RecoveryDatabaseBusy();
4427
4807
  reply({ ...recoveryError(busy.message), code: busy.code, retryable: busy.retryable });
4428
- } else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
4808
+ } else if (error instanceof RecoveryDeferredHubError) reply({ ...recoveryError(error.message), code: error.code });
4809
+ else reply(recoveryError((error as Error)?.message ?? "recovery operation failed"));
4429
4810
  });
4430
4811
  return;
4431
4812
  case "send": {
@@ -4614,7 +4995,7 @@ export async function startDaemon(opts: DaemonOptions) {
4614
4995
  }
4615
4996
  case "watch": {
4616
4997
  if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
4617
- if (msg.peer !== "kimi" && msg.peer !== "qwen") return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
4998
+ if (typeof msg.peer !== "string" || !definitionOf(msg.peer)?.capabilities?.watch) return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
4618
4999
  const peer = msg.peer as "kimi" | "qwen";
4619
5000
  // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
4620
5001
  const kimiOwner = bus.peers.get("kimi");
@@ -4687,16 +5068,19 @@ export async function startDaemon(opts: DaemonOptions) {
4687
5068
  case "settings_get":
4688
5069
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4689
5070
  return void reply({ ok: true, ...settingsView(), roster: rosterView() });
5071
+ case "settings_reload":
5072
+ if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
5073
+ return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
4690
5074
  case "settings_preview":
4691
5075
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4692
- return void reply(settingsPreview(msg.key, msg.value));
5076
+ return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
4693
5077
  case "settings_set":
4694
5078
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4695
- void settingsSet(msg.key, msg.value, "terminal", msg.confirm, msg.now).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
5079
+ void settingsSet(msg.key, msg.value, "terminal", msg.confirm, msg.now, msg.reason).then(reply, () => reply({ ok: false, error: "the setting could not be changed; inspect hub.log" }));
4696
5080
  return;
4697
5081
  case "settings_undo":
4698
5082
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4699
- return void settingsUndo("terminal", msg.confirm, msg.now).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
5083
+ return void settingsUndo("terminal", msg.confirm, msg.now, msg.reason, msg.key, msg.undoDigest).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
4700
5084
  case "kimi_launch_end": {
4701
5085
  if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
4702
5086
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
@@ -4753,7 +5137,7 @@ export async function startDaemon(opts: DaemonOptions) {
4753
5137
  board,
4754
5138
  isPii: (t) => tasks.isPii(t),
4755
5139
  onCampus,
4756
- isPiiText: (q) => currentRouting(opts.cwd, log).constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, currentRouting(opts.cwd, log), opts.cwd).includes("pii"),
5140
+ isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
4757
5141
  ...(config.memory.enabled ? { memory } : {}),
4758
5142
  // #258: entries that match the question, then the graph neighbourhood of what they are about.
4759
5143
  ...(memoryStore ? { native: (keys: string[]) => {
@@ -4838,6 +5222,8 @@ export async function startDaemon(opts: DaemonOptions) {
4838
5222
  log("hub stopping");
4839
5223
  writeStatus();
4840
5224
  dashboard?.stop();
5225
+ stopConfigWatch();
5226
+ clearInterval(sweepTimer);
4841
5227
  for (const i of intervals) clearInterval(i);
4842
5228
  for (const pending of checkpointWaits.values()) pending.done(undefined);
4843
5229
  for (const p of permissions.values()) p.done(undefined);
@@ -4902,6 +5288,18 @@ export async function startDaemon(opts: DaemonOptions) {
4902
5288
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
4903
5289
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
4904
5290
  ready = true;
5291
+ for (const name of CONFIG_SOURCE_FILES) {
5292
+ const path = join(opts.cwd, ".agenthub", name);
5293
+ const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
5294
+ if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
5295
+ clearTimeout(reloadDebounce);
5296
+ reloadDebounce = setTimeout(runConfigWatch, 100);
5297
+ reloadDebounce.unref?.();
5298
+ };
5299
+ watchFile(path, { persistent: false, interval: 100 }, listener);
5300
+ configWatchers.push({ path, listener });
5301
+ }
5302
+ startupCleanup.push(stopConfigWatch);
4905
5303
  // #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
4906
5304
  try { const settled = tasks.reconcileMemory(); if (settled) log(`hub memory: settled ${settled} approved task${settled === 1 ? "" : "s"} a stop had left unsettled`); }
4907
5305
  catch (e) { log(`hub memory reconciliation failed: ${(e as Error).message}`); }
@@ -4927,7 +5325,7 @@ export async function startDaemon(opts: DaemonOptions) {
4927
5325
  });
4928
5326
  }
4929
5327
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
4930
- const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
5328
+ const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => p.peer === "pi");
4931
5329
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
4932
5330
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
4933
5331
  } finally {