@staix/agent-hub 0.12.31 → 0.12.32

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 (73) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +10 -7
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +3 -2
  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 +114 -22
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +12 -8
  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 +191 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verified.json +20 -15
  20. package/package.json +2 -2
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +1296 -26
  23. package/src/adapters/acp.ts +5 -6
  24. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  25. package/src/adapters/definitions/kimi-native.ts +4 -0
  26. package/src/adapters/definitions/qwen-native.ts +24 -0
  27. package/src/adapters/definitions/qwen.ts +71 -0
  28. package/src/adapters/pi.ts +6 -2
  29. package/src/adapters/registry-types.ts +174 -0
  30. package/src/adapters/registry.ts +36 -0
  31. package/src/cli/console-state.ts +74 -5
  32. package/src/cli/console.ts +35 -2
  33. package/src/cli/help.ts +3 -1
  34. package/src/cli/identity.ts +13 -3
  35. package/src/cli/launch.ts +1 -6
  36. package/src/cli/main.ts +200 -39
  37. package/src/cli/output.ts +2 -0
  38. package/src/cli/preview.ts +8 -5
  39. package/src/cli/recovery-guidance.ts +137 -0
  40. package/src/cli/recovery-presentation-options.ts +8 -0
  41. package/src/cli/recovery-session-file.ts +18 -0
  42. package/src/cli/terminal-recovery.ts +48 -0
  43. package/src/cli/upgrade-interactive.ts +129 -75
  44. package/src/cli/upgrade-labels.ts +30 -0
  45. package/src/cli/upgrade-presentation-data.ts +41 -0
  46. package/src/cli/upgrade-presentation-flags.ts +31 -0
  47. package/src/cli/upgrade-presentation.ts +217 -0
  48. package/src/cli/upgrade-restore-state.ts +6 -2
  49. package/src/cli/upgrade-runtime.ts +345 -194
  50. package/src/cli/upgrade.ts +276 -25
  51. package/src/hub/bus.ts +11 -0
  52. package/src/hub/cohorts.ts +2 -2
  53. package/src/hub/config-apply.ts +242 -0
  54. package/src/hub/config-reload.ts +120 -0
  55. package/src/hub/config-trust.ts +11 -2
  56. package/src/hub/crash.ts +8 -6
  57. package/src/hub/daemon.ts +559 -170
  58. package/src/hub/events.ts +2 -2
  59. package/src/hub/manager.ts +1 -1
  60. package/src/hub/peer-catalog.ts +10 -13
  61. package/src/hub/peers.ts +12 -2
  62. package/src/hub/permission-mode.ts +5 -3
  63. package/src/hub/recovery-continuity.ts +152 -0
  64. package/src/hub/recovery-store.ts +10 -4
  65. package/src/hub/sdlc.ts +5 -1
  66. package/src/hub/settings.ts +50 -36
  67. package/src/hub/settled-roles.ts +35 -0
  68. package/src/hub/supervision.ts +3 -2
  69. package/src/hub/tasks.ts +28 -8
  70. package/src/hub/ui.ts +1 -1
  71. package/src/memory/brief.ts +6 -5
  72. package/src/pi/session.ts +23 -4
  73. package/src/ui/index.html +23 -7
package/src/hub/daemon.ts CHANGED
@@ -19,22 +19,26 @@ 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
25
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
26
- import { buildKimiLaunch, buildQwenLaunch } from "../cli/launch.ts";
26
+ import { buildKimiLaunch } from "../cli/launch.ts";
27
+ import { definitionOf } from "../adapters/registry.ts";
27
28
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
28
29
  import type { ToolObservation } from "../models/route/signals.ts";
29
30
  import { createHash, randomBytes, randomUUID } from "node:crypto";
30
- import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
31
+ import { appendFileSync, chmodSync, existsSync, fstatSync, mkdirSync, readFileSync, renameSync, rmSync, watchFile, unwatchFile, writeFileSync } from "node:fs";
31
32
  import { join, relative, resolve } from "node:path";
32
33
  import { homedir } from "node:os";
33
34
  import { spawn } from "node:child_process";
34
35
  import { childEnv } from "./child-process.ts";
35
36
  import { runCheck } from "./checks.ts";
36
37
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
37
- import { stripUntrusted } from "./config-trust.ts";
38
+ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
39
+ import { authorizeConfigLeaf, configApply, configChanges, validateEffectiveConfig } from "./config-apply.ts";
40
+ import { resolveSettledRoles } from "./settled-roles.ts";
41
+ import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
38
42
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
39
43
  import { ExecutionBudget } from "./execution-budget.ts";
40
44
  import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
@@ -47,7 +51,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
47
51
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
48
52
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
49
53
  import { processSignature } from "../pi/process-signature.ts";
50
- import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
54
+ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
51
55
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
52
56
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
53
57
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
@@ -73,8 +77,8 @@ import { launcherPreview } from "../cli/preview.ts";
73
77
  import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
74
78
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
75
79
  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";
80
+ import { currentRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
81
+ 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
82
  import { Bus } from "./bus.ts";
79
83
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
80
84
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -95,6 +99,8 @@ import type { JournalDelivery } from "./delivery-journal.ts";
95
99
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
96
100
  import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
97
101
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
102
+ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
103
+ import { shellQuote } from "../cli/terminal-recovery.ts";
98
104
 
99
105
  import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
100
106
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
@@ -235,16 +241,18 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
235
241
 
236
242
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
237
243
  const CONFIG_FILES = ["config.json", "config.local.json"] as const;
244
+ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
238
245
  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
246
 
240
247
  /** 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 } {
248
+ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
242
249
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
243
250
  const conductor = { ...DEFAULT_CONFIG.conductor };
244
251
  let planner: string | undefined;
245
252
  for (const name of CONFIG_FILES) {
246
253
  let file: Record<string, unknown>;
247
- try { file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
254
+ if (layers && !layers.some(layer => layer.name === name)) continue;
255
+ try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
248
256
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
249
257
  if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
250
258
  if (file.planner !== undefined) {
@@ -279,13 +287,14 @@ function syncMinutes(v: unknown): number {
279
287
  return v;
280
288
  }
281
289
 
282
- export function loadConfig(cwd: string): HubConfig {
290
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
283
291
  const ignored: string[] = [];
284
292
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
285
293
  const files = CONFIG_FILES.flatMap((name) => {
286
294
  let file: Record<string, any>; // parsed JSON, checked field by field below
295
+ if (layers && !layers.some(layer => layer.name === name)) return [];
287
296
  try {
288
- file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
297
+ file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
289
298
  } catch (error) {
290
299
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
291
300
  throw error;
@@ -340,7 +349,7 @@ export function loadConfig(cwd: string): HubConfig {
340
349
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
341
350
  }
342
351
  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);
352
+ const conductorPolicy = loadConductorPolicy(cwd, layers);
344
353
  if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
345
354
  if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
346
355
  if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
@@ -563,7 +572,9 @@ export async function startDaemon(opts: DaemonOptions) {
563
572
  try {
564
573
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
565
574
  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 } };
575
+ // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
576
+ 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;
577
+ const config = { ...ownedConfig, roster: peerRosterConfig(loadedConfig.roster), permission_modes: permissionDefaults(loadedConfig.permission_modes), roles: { ...loadedConfig.roles }, conductor: { ...DEFAULT_CONFIG.conductor, ...loadedConfig.conductor } };
567
578
  let rosterChanging: string | undefined;
568
579
  let kimiSessionMode: StartMode | undefined;
569
580
  let kimiAdmissionId: string | undefined;
@@ -579,6 +590,22 @@ export async function startDaemon(opts: DaemonOptions) {
579
590
  const logFile = join(opts.stateDir, "hub.log");
580
591
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
581
592
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
593
+ let runningRouting = currentRouting(opts.cwd, log);
594
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting);
595
+ const startupSources = structuredClone(acceptedSources.trustLayers);
596
+ const embeddedConfig = structuredClone(config);
597
+ let desiredConfig = structuredClone(config);
598
+ const startupRouting = structuredClone(runningRouting);
599
+ let desiredRouting = structuredClone(runningRouting);
600
+ let configRevision = 0;
601
+ let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
602
+ let pendingRestart: { key: string; reason: string }[] = [];
603
+ let lastReloadFailure: string | undefined;
604
+ let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
605
+ let configWatchPending = false;
606
+ const configWatchers: { path: string; listener: (current: import("node:fs").Stats, previous: import("node:fs").Stats) => void }[] = [];
607
+ const stopConfigWatch = () => { configWatchPending = false; clearTimeout(reloadDebounce); for (const item of configWatchers.splice(0)) unwatchFile(item.path, item.listener); };
608
+ const activeRouting = () => runningRouting;
582
609
  // #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
583
610
  // place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
584
611
  // the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
@@ -586,23 +613,7 @@ export async function startDaemon(opts: DaemonOptions) {
586
613
  let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
587
614
  let plannerLogged = "";
588
615
  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);
616
+ const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
606
617
  for (const key of Object.keys(config.roles)) delete config.roles[key];
607
618
  Object.assign(config.roles, roles);
608
619
  plannerSource = source;
@@ -631,16 +642,16 @@ export async function startDaemon(opts: DaemonOptions) {
631
642
  try { configured = configuredRoles(peer); } catch { note = "catalog selection is incompatible or unavailable"; }
632
643
  const prepared = preparedSessionRoles.get(peer);
633
644
  const active = bus.peers.get(peer)?.state !== "offline" && bus.peers.has(peer);
645
+ let desired = configured;
646
+ try { desired = selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(config.catalog_roles) }, catalog).map(role => role.id); }
647
+ catch { note = "catalog selection is incompatible or unavailable"; }
634
648
  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;
649
+ 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
650
  }
651
+ const prepareRoleSelection = (peer: string) => { roleAssignments[peer] = catalogAssignments(config.catalog_roles)[peer] ?? resolvedProfile.catalog_roles[peer] ?? []; };
642
652
  function sessionRoleContract(peer: string): string {
643
653
  try {
654
+ prepareRoleSelection(peer);
644
655
  const selected = selectedRoles(peer, config.roles, roleAssignments, catalog);
645
656
  preparedSessionRoles.set(peer, selected.map(role => role.id));
646
657
  preparedSessionRoleIdentities.set(peer, selected.map(roleIdentity));
@@ -746,8 +757,11 @@ export async function startDaemon(opts: DaemonOptions) {
746
757
 
747
758
  // A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
748
759
  const crashed = !recoveryOperation && !restartFilePresent ? readSessions(opts.stateDir) : undefined;
760
+ let piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
761
+ // A disposed operation's exact session takes precedence over a stale crash record for this whole startup.
762
+ const archivedPiContinuity = piContinuity.state !== "absent";
749
763
  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)];
764
+ 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
765
  for (const launch of launches) {
752
766
  const problem = mlxLaunchProblem(config, (launch ?? {}) as { backend?: unknown; model?: unknown });
753
767
  if (problem) throw new Error(`recorded Pi recovery: ${problem}`);
@@ -756,7 +770,7 @@ export async function startDaemon(opts: DaemonOptions) {
756
770
  // A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
757
771
  // a record left now would make the next ordinary start look like one.
758
772
  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
773
+ const autoResume = () => config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
760
774
  const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
761
775
  const startedAt = Date.now();
762
776
  /** What crash recovery did or asks the user to do, for `ahub status`. */
@@ -790,8 +804,8 @@ export async function startDaemon(opts: DaemonOptions) {
790
804
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
791
805
  // together. Cached until the board changes: it is asked per recipient of every message.
792
806
  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)));
807
+ 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 }>();
808
+ 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
809
  /**
796
810
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
797
811
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
@@ -836,10 +850,10 @@ export async function startDaemon(opts: DaemonOptions) {
836
850
  const hubStartedAt = Date.now();
837
851
  // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
838
852
  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)");
853
+ const staleOff = () => config.experiments?.stale_notices === "deliver";
854
+ if (staleOff()) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
841
855
  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,
856
+ 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
857
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
844
858
  startupCleanup.push(() => bus.closeJournal());
845
859
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -895,7 +909,7 @@ export async function startDaemon(opts: DaemonOptions) {
895
909
  memoryNotice(`hub native memory disabled: ${reason}`);
896
910
  };
897
911
  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) => {
912
+ 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
913
  const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
900
914
  return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
901
915
  }) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
@@ -920,7 +934,7 @@ export async function startDaemon(opts: DaemonOptions) {
920
934
  * Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
921
935
  * policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
922
936
  */
923
- const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, currentRouting(opts.cwd, log), opts.cwd, config.pii?.paths ?? []);
937
+ const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
924
938
  let candidates: CandidateStore | undefined;
925
939
  if (memoryStore) {
926
940
  try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
@@ -1031,7 +1045,7 @@ export async function startDaemon(opts: DaemonOptions) {
1031
1045
  const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
1032
1046
  let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
1033
1047
  let sidecarRouting = "";
1034
- inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => currentRouting(opts.cwd, log).local.fixed_model, log });
1048
+ inference = new Inference(config.inference, { omni, sidecar: () => sidecar, route: "sy/fast", fixedModel: () => activeRouting().local.fixed_model, log });
1035
1049
 
1036
1050
  let dashboard: ReturnType<typeof startDashboard> | undefined;
1037
1051
  const uiEvents: { seq: number; event: BusEvent }[] = [];
@@ -1053,7 +1067,7 @@ export async function startDaemon(opts: DaemonOptions) {
1053
1067
  conductor: () => conductorPeer(config.roles) ?? undefined,
1054
1068
  scope: () => config.conductor?.feed ?? "own",
1055
1069
  tasks: () => board.list(), publicTitle: (task) => tasks.publicTitle(task), isPrivate: (task) => tasks.isPii(task),
1056
- approvalAgeMs: (config.conductor?.approval_wait_s ?? 30) * 1000,
1070
+ approvalAgeMs: () => (config.conductor?.approval_wait_s ?? 30) * 1000,
1057
1071
  readRound: (peer) => conductorHolds.readRound(peer), writeRound: (peer, signature) => conductorHolds.writeRound(peer, signature),
1058
1072
  emit: (notice) => {
1059
1073
  if (stopping || recoveryActive()) return false;
@@ -1064,7 +1078,7 @@ export async function startDaemon(opts: DaemonOptions) {
1064
1078
  const refreshConductorPolicy = () => {
1065
1079
  if (opts.config) return; // explicit embedding/test configuration remains its authority
1066
1080
  const before = conductorPeer(config.roles);
1067
- const policy = loadConductorPolicy(opts.cwd);
1081
+ const policy = { roles: policyRoles, planner: config.planner, conductor: config.conductor };
1068
1082
  policyRoles = structuredClone(policy.roles);
1069
1083
  config.planner = policy.planner;
1070
1084
  config.conductor = policy.conductor;
@@ -1186,17 +1200,17 @@ export async function startDaemon(opts: DaemonOptions) {
1186
1200
  catalog,
1187
1201
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1188
1202
  projectedReview: task => reviewProjection(task),
1189
- sdlc: { config: () => lifecycleConfig },
1203
+ sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1190
1204
  board,
1191
1205
  bus,
1192
1206
  executionBudget,
1193
- routing: () => currentRouting(opts.cwd, log), // routing.toml is edited while the hub runs: re-read on change, last good parse kept
1207
+ routing: () => activeRouting(), // only the complete validated reload can publish routing changes
1194
1208
  cwd: opts.cwd,
1195
1209
  project: chain.at(-1)!,
1196
1210
  ...(config.memory.enabled ? { memory } : {}),
1197
1211
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1198
1212
  memoryCheck: checkMemory,
1199
- ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, config.memory.brief_items,
1213
+ ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1200
1214
  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
1215
  (peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
1202
1216
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
@@ -1231,7 +1245,7 @@ export async function startDaemon(opts: DaemonOptions) {
1231
1245
  },
1232
1246
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
1233
1247
  // 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 }),
1248
+ piiScreen: (text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }),
1235
1249
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1236
1250
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
1237
1251
  review: config.review,
@@ -1243,6 +1257,7 @@ export async function startDaemon(opts: DaemonOptions) {
1243
1257
  stallNotice: stall => { supervision.taskStalled(stall); },
1244
1258
  roles: config.roles,
1245
1259
  turnFree,
1260
+ coordination: () => config.coordination === "turn-free",
1246
1261
  capable: (peer) => capable.has(peer),
1247
1262
  idle: (peer) => idle(peer),
1248
1263
  treeHash: (paths, windows) => facts.tree(paths, windows),
@@ -1605,9 +1620,9 @@ export async function startDaemon(opts: DaemonOptions) {
1605
1620
  event({ type: "supervision_turn", peer, turn: receipt.id, ...(receipt.tokens === undefined ? {} : { tokens: receipt.tokens }), ms: Math.max(0, Date.now() - receipt.at) });
1606
1621
  };
1607
1622
  // 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());
1623
+ const repo = factsRepo;
1624
+ let turnLog = config.snapshots.enabled && repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
1625
+ startupCleanup.push(() => turnLog?.close());
1611
1626
  /** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
1612
1627
  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
1628
  const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
@@ -1631,7 +1646,8 @@ export async function startDaemon(opts: DaemonOptions) {
1631
1646
  * 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
1647
  * in snapshots.ts.
1633
1648
  */
1634
- const snap = (what: string): { tree?: string; ms: number } => {
1649
+ const snap = (what: string, capture = config.snapshots.enabled): { tree?: string; ms: number } => {
1650
+ if (!capture) return { ms: 0 };
1635
1651
  const t0 = performance.now();
1636
1652
  let tree: string | undefined;
1637
1653
  try {
@@ -1647,7 +1663,7 @@ export async function startDaemon(opts: DaemonOptions) {
1647
1663
  }
1648
1664
  function reviewProjection(task: Task): string | undefined {
1649
1665
  if (!projections.enabled || tasks.isPii(task)) return undefined;
1650
- if (!repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
1666
+ if (!config.snapshots.enabled || !repo || !turnLog) return "(projected review unavailable: snapshots are disabled or unavailable)";
1651
1667
  if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
1652
1668
  const created = taskProposalAt(task);
1653
1669
  const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
@@ -1749,10 +1765,21 @@ export async function startDaemon(opts: DaemonOptions) {
1749
1765
  }
1750
1766
  }, 5_000),
1751
1767
  ];
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));
1768
+ let sweepTimer: ReturnType<typeof setInterval> | undefined;
1769
+ const rescheduleSweep = () => {
1770
+ if (sweepTimer) clearInterval(sweepTimer);
1771
+ sweepTimer = undefined;
1772
+ const sweep = taskSweepConfig(config.task_sweep);
1773
+ tasks.configureSweep(sweep);
1774
+ if (sweep.enabled) {
1775
+ sweepTimer = setInterval(() => {
1776
+ void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
1777
+ }, sweep.interval_s * 1000);
1778
+ sweepTimer.unref?.();
1779
+ }
1780
+ };
1781
+ rescheduleSweep();
1782
+ startupCleanup.push(() => clearInterval(sweepTimer));
1756
1783
  for (const i of intervals) i.unref?.();
1757
1784
  startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
1758
1785
 
@@ -2126,6 +2153,20 @@ export async function startDaemon(opts: DaemonOptions) {
2126
2153
  const permissionChanges = new Map<string, Promise<void>>();
2127
2154
  const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
2128
2155
  for (const peer of pendingPermissionDefaults.keys()) permissionModes.set(peer, "ask");
2156
+ const changedPermissionDefaults = new Set<string>();
2157
+ const applyPendingPermissionDefault = (peer: string): void => {
2158
+ if (changedPermissionDefaults.has(peer) && (!bus.peers.has(peer) || bus.peers.get(peer)!.state === "offline")) {
2159
+ changedPermissionDefaults.delete(peer);
2160
+ const mode = config.permission_modes[peer] ?? "ask";
2161
+ const source = config.permission_default_sources?.[peer]?.source ?? "configuration default";
2162
+ permissionModes.set(peer, mode === "never-ask" ? "ask" : mode);
2163
+ permissionModeSources.set(peer, source);
2164
+ pendingPermissionDefaults.delete(peer);
2165
+ if (mode === "never-ask") pendingPermissionDefaults.set(peer, source);
2166
+ if (config.permission_default_sources?.[peer]) defaultSources[peer] = config.permission_default_sources[peer]!;
2167
+ else delete defaultSources[peer];
2168
+ }
2169
+ };
2129
2170
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2130
2171
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2131
2172
  const unattendedPeers = new Set<string>();
@@ -2343,6 +2384,7 @@ export async function startDaemon(opts: DaemonOptions) {
2343
2384
  projectId,
2344
2385
  instanceId,
2345
2386
  version: VERSION,
2387
+ pendingRestart,
2346
2388
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2347
2389
  lifecycleStages: [...lifecycleConfig.stages],
2348
2390
  protocol: PROTOCOL,
@@ -2568,17 +2610,57 @@ export async function startDaemon(opts: DaemonOptions) {
2568
2610
 
2569
2611
  // Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
2570
2612
  let sessionsWritten = "";
2613
+ // One bounded failure generation: unchanged events neither contend for the writer lock nor repeat its notice.
2614
+ let piSettlementFailure: { key: string; error: string; retryAfter: number } | undefined;
2615
+ function settlePiContinuity(): void {
2616
+ if (stopping || recoveryActive() || piContinuity.state !== "ready") return;
2617
+ const peer = bus.peers.get("pi");
2618
+ if (!(peer instanceof PiPeer) || peer.state === "offline" || !peer.sessionGeneration || !peer.recoveryReady) return;
2619
+ const saved = piContinuity.bookmark, actual = peer.recoveryMetadata();
2620
+ if (typeof actual.sessionId !== "string" || !actual.sessionId) return;
2621
+ const key = JSON.stringify([piContinuity.digest, peer.sessionGeneration, actual]);
2622
+ if (piSettlementFailure?.key === key && Date.now() < piSettlementFailure.retryAfter) return;
2623
+ const matches = actual.sessionId === saved.pi.sessionId && (saved.pi.sessionFile === undefined || actual.sessionFile === saved.pi.sessionFile);
2624
+ try {
2625
+ const expected = { projectId, projectRoot: opts.cwd, operationId: saved.operationId, digest: piContinuity.digest };
2626
+ const settled = matches ? consumeRecoveryContinuity(opts.stateDir, expected) : retireRecoveryContinuity(opts.stateDir, expected, "authenticated_session_changed").retired;
2627
+ piSettlementFailure = undefined;
2628
+ if (settled) {
2629
+ piContinuity = { state: "absent" };
2630
+ if (!matches) log("Pi archived continuity retired after recording a different authenticated session");
2631
+ }
2632
+ else {
2633
+ // Re-read after the failed exact CAS; never settle a newer bookmark in this call.
2634
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2635
+ if (piContinuity.state !== "absent") log("Pi continuity bookmark changed before attachment settlement; retained for inspection");
2636
+ }
2637
+ } catch (error) {
2638
+ const signature = error instanceof Error ? `${error.name}:${error.message}` : String(error);
2639
+ const report = piSettlementFailure?.key !== key || piSettlementFailure.error !== signature;
2640
+ piSettlementFailure = { key, error: signature, retryAfter: Date.now() + 30_000 };
2641
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2642
+ if (report) log("Pi continuity attachment settlement unavailable; bookmark retained");
2643
+ }
2644
+ }
2571
2645
  const recordSessions = () => {
2572
2646
  if (stopping) return;
2647
+ const pi = bus.peers.get("pi");
2648
+ if (piContinuity.state === "ready" && (!(pi instanceof PiPeer) || pi.state === "offline" || !pi.sessionGeneration || !pi.recoveryReady)) {
2649
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2650
+ }
2573
2651
  const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
2574
2652
  let meta: Record<string, unknown> = {};
2575
2653
  try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
2576
2654
  return { peer: p.id, meta };
2577
2655
  });
2578
2656
  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}`); }
2657
+ try {
2658
+ if (text !== sessionsWritten) {
2659
+ writeSessions(opts.stateDir, { instanceId, at: Date.now(), peers });
2660
+ sessionsWritten = text;
2661
+ }
2662
+ settlePiContinuity(); // the identical snapshot may already be durable; never retire after a failed write
2663
+ } catch (error) { log(`session record not written: ${(error as Error).message}`); }
2582
2664
  };
2583
2665
  const recoverAfterCrash = async (prev: SessionsFile) => {
2584
2666
  const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
@@ -2586,6 +2668,7 @@ export async function startDaemon(opts: DaemonOptions) {
2586
2668
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
2587
2669
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
2588
2670
  for (const step of crashPlan(prev.peers)) {
2671
+ 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
2672
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
2590
2673
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
2591
2674
  if (step.peer === "pi" && piAutoStart()) {
@@ -2601,7 +2684,7 @@ export async function startDaemon(opts: DaemonOptions) {
2601
2684
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
2602
2685
  continue;
2603
2686
  }
2604
- if (!step.resume || !autoResume) {
2687
+ if (!step.resume || !autoResume()) {
2605
2688
  report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
2606
2689
  continue;
2607
2690
  }
@@ -2626,7 +2709,7 @@ export async function startDaemon(opts: DaemonOptions) {
2626
2709
  // #248: the add-ons it works with, once per attach (names and versions only).
2627
2710
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
2628
2711
  // still did its work under that attachment.
2629
- if (e.state === "offline") attachedNow.delete(e.peer);
2712
+ if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
2630
2713
  else if (!attachedNow.has(e.peer)) {
2631
2714
  attachedNow.add(e.peer);
2632
2715
  const peer = e.peer, attachment = ++attachments;
@@ -2647,20 +2730,21 @@ export async function startDaemon(opts: DaemonOptions) {
2647
2730
  const pii = holdsPii(e.peer);
2648
2731
  for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
2649
2732
  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}`); }
2733
+ const capture = config.snapshots.enabled && !!turnLog;
2734
+ const start = capture && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
2735
+ try { if (capture) turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
2652
2736
  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 } : {}) });
2737
+ 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
2738
  event({ type: "turn_start", peer: e.peer, turn: id });
2655
2739
  } else if (!busy && open) {
2656
2740
  turns.delete(e.peer);
2657
2741
  let afterTurn: (() => void) | undefined;
2658
2742
  let files: number | undefined;
2659
2743
  let snapshotMs = open.snapshotMs;
2660
- if (turnLog && (open.private || holdsPii(e.peer))) {
2744
+ if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
2661
2745
  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}`);
2746
+ } else if (open.capture && turnLog) {
2747
+ const end = snap(`the end of ${open.id}`, true);
2664
2748
  const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
2665
2749
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
2666
2750
  snapshotMs = (snapshotMs ?? 0) + end.ms;
@@ -2825,7 +2909,7 @@ export async function startDaemon(opts: DaemonOptions) {
2825
2909
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
2826
2910
  const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
2827
2911
  (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}`));
2912
+ (error) => log(`Pi auto-start failed: ${(error as Error).message}${piContinuity.state === "ready" ? `. ${continuityCleanup()}` : ""}`));
2829
2913
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
2830
2914
  replacingPi.add(pi);
2831
2915
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
@@ -2846,6 +2930,7 @@ export async function startDaemon(opts: DaemonOptions) {
2846
2930
  const running = startPeerOnce(peer, args).then(async result => {
2847
2931
  if (!result.ok && peer === "codex") unattendedPeers.delete(peer);
2848
2932
  if (!result.ok || result.already) return result;
2933
+ if (peer === "pi") recordSessions();
2849
2934
  return permissionChange(peer, async () => {
2850
2935
  if (peer === "codex") { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
2851
2936
  logPermissionStart(peer); writeStatus(); return result;
@@ -2874,6 +2959,8 @@ export async function startDaemon(opts: DaemonOptions) {
2874
2959
  const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
2875
2960
  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
2961
  });
2962
+ // Printed for a person's explicit reconciliation only; never executed by auto-start.
2963
+ 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
2964
  async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
2878
2965
  if (stopping) return { ok: false, error: "hub is stopping" };
2879
2966
  if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
@@ -2881,7 +2968,31 @@ export async function startDaemon(opts: DaemonOptions) {
2881
2968
  if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
2882
2969
  if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
2883
2970
  if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
2971
+ const continuity = peer === "pi" && by === "pi.auto_start"
2972
+ ? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
2973
+ : undefined;
2974
+ if (continuity?.state === "invalid") return { ok: false, error: `archived Pi continuity evidence is invalid or unreadable; no fresh session was started. ${continuityCleanup()}` };
2975
+ const resume = continuity?.state === "ready" ? continuity.bookmark.pi : undefined;
2884
2976
  const mode = startMode(peer);
2977
+ if (resume?.sessionFile && continuity?.state === "ready") {
2978
+ try { validatePiSession(opts.stateDir, opts.cwd, resume.sessionFile, resume.sessionId); }
2979
+ catch (error) {
2980
+ const retry = `ahub pi --mode ${mode} --session-file ${shellQuote(resume.sessionFile)}`;
2981
+ if (error instanceof PiSessionUnresumable || error instanceof PiSessionBookmarkMismatch) {
2982
+ try {
2983
+ const retired = retireRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd, operationId: continuity.bookmark.operationId, digest: continuity.digest }, "resume_unavailable");
2984
+ if (retired.retired) {
2985
+ piContinuity = { state: "absent" };
2986
+ const diagnosis = error instanceof PiSessionBookmarkMismatch ? "Pi continuity bookmark does not match the preserved session" : "recorded Pi session is unresumable";
2987
+ 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>` };
2988
+ }
2989
+ } 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()}` }; }
2990
+ piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd });
2991
+ 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()}` };
2992
+ }
2993
+ 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()}` };
2994
+ }
2995
+ }
2885
2996
  if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
2886
2997
  const owner = bus.peers.get(peer);
2887
2998
  if (mode === "headless") {
@@ -2890,23 +3001,26 @@ export async function startDaemon(opts: DaemonOptions) {
2890
3001
  // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
2891
3002
  // issued to tell the two apart.
2892
3003
  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" } : {});
3004
+ return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless", ...(resume ?? {}) } : {});
2894
3005
  }
2895
3006
  if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
2896
- const command = `ahub ${startWords(peer, mode).join(" ")}`;
3007
+ const words = [...startWords(peer, mode)];
3008
+ if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
3009
+ const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
2897
3010
  const since = Date.now() - (opening.get(peer) ?? 0);
2898
3011
  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
3012
  // Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
2900
3013
  opening.set(peer, Date.now());
3014
+ 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
3015
  // 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 }));
3016
+ 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
3017
  if (!opened.ok) {
2904
3018
  opening.delete(peer); // nothing was opened: the next request may try again at once
2905
3019
  if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
2906
3020
  const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
2907
3021
  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
3022
  }
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 */ }
3023
+ 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
3024
  return { ok: true, mode, opened: opened.via, command };
2911
3025
  }
2912
3026
 
@@ -2965,6 +3079,7 @@ export async function startDaemon(opts: DaemonOptions) {
2965
3079
  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
3080
  let unmute: (() => void) | undefined;
2967
3081
  try {
3082
+ applyPendingPermissionDefault(peer);
2968
3083
  if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
2969
3084
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
2970
3085
  } finally {
@@ -2973,8 +3088,12 @@ export async function startDaemon(opts: DaemonOptions) {
2973
3088
  }
2974
3089
 
2975
3090
  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>> {
3091
+ const adapter = definitionOf(peer);
2976
3092
  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" };
3093
+ if (adapter?.transport === "acp" && adapter.start.create) {
3094
+ const refusal = adapter.start.preflight?.({ instance: peer, form: args.mode });
3095
+ if (refusal) return { ok: false, error: refusal };
3096
+ }
2978
3097
  if (peer === "kimi") {
2979
3098
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
2980
3099
  const owner = bus.peers.get(peer);
@@ -3061,32 +3180,36 @@ export async function startDaemon(opts: DaemonOptions) {
3061
3180
  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
3181
  return { ok: true, policy: "unmanaged" };
3063
3182
  }
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"),
3183
+ if (adapter?.transport === "acp" && adapter.start.create) {
3184
+ // ponytail: A1 migrates one ACP factory; A2 generalizes its native config and activity ports before adding another.
3185
+ const launch = adapter.start.launch({ instance: peer, form: "headless", projectRoot: opts.cwd, stateDir: opts.stateDir, entrypoint: CLI_ENTRY,
3186
+ userArgs: [], unattended: false, env: {}, nativeCommand: config.qwen_cmd, model: args.model });
3187
+ const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3188
+ const permission = permissionMode(peer);
3189
+ const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3190
+ onActivity: item => activityRings[peer as keyof typeof activityRings].append(item),
3191
+ isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3192
+ cmd: [launch.cmd, ...launch.args], permissionMode: permission,
3071
3193
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3072
3194
  cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
3073
3195
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3074
3196
  log: () => log("Qwen runtime diagnostic: details withheld"),
3075
3197
  onUsageDiagnostic: (observation, sessionId) => {
3076
3198
  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
3199
+ const current = contextSession(peer); if (!current) return;
3200
+ contexts.report(peer, observation.contextUsed !== undefined && observation.contextCapacity !== undefined
3079
3201
  ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
3080
3202
  : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
3081
3203
  },
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); }
3204
+ onTurnFailure: () => { supervisionTurns.delete(peer); },
3205
+ mcpServers,
3206
+ preamble: sessionRoleContract(peer),
3207
+ } } });
3208
+ if (!(owner instanceof AcpPeer)) throw new Error("ACP factory did not return its native transport");
3209
+ recoveryTaskPreface(peer); await ensurePreface(peer);
3210
+ try { await owner.start(); await reconcileNativeStart(peer, owner); }
3088
3211
  catch (error) {
3089
- await qwen.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
3212
+ await owner.stop().catch(() => log("Qwen stop after failed startup was not confirmed"));
3090
3213
  if (error instanceof QwenIdentityError) throw error;
3091
3214
  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
3215
  }
@@ -3220,11 +3343,11 @@ export async function startDaemon(opts: DaemonOptions) {
3220
3343
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
3221
3344
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
3222
3345
  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,
3346
+ 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
3347
  routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
3225
- staySwitch: () => currentRouting(opts.cwd, log),
3348
+ staySwitch: () => activeRouting(),
3226
3349
  onRoute: record => event({ type: "route", peer: "pi", ...record }),
3227
- efficientWaitMs: currentRouting(opts.cwd, log).pi.efficient_wait_ms,
3350
+ efficientWaitMs: activeRouting().pi.efficient_wait_ms,
3228
3351
  underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
3229
3352
  onCooldown: record => {
3230
3353
  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 +3387,7 @@ export async function startDaemon(opts: DaemonOptions) {
3264
3387
  return typeof refused === "string" ? `not sent: ${refused}` : "sent";
3265
3388
  },
3266
3389
  };
3267
- const routing = currentRouting(opts.cwd, log);
3390
+ const routing = activeRouting();
3268
3391
  const pi = new PiPeer("pi", {
3269
3392
  cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
3270
3393
  model: args.model,
@@ -3312,7 +3435,7 @@ export async function startDaemon(opts: DaemonOptions) {
3312
3435
  if (backend !== "auto") return backend === "mlx" ? "mlx/fast" : "dgx/coding";
3313
3436
  const taskId = envs.find(env => env.refs?.task)?.refs?.task;
3314
3437
  const task = taskId ? board.get(Number(taskId)) : undefined;
3315
- const policyBackend = task ? currentRouting(opts.cwd, log).classes[task.class]?.pi_backend : undefined;
3438
+ const policyBackend = task ? activeRouting().classes[task.class]?.pi_backend : undefined;
3316
3439
  if (policyBackend === "mlx") {
3317
3440
  if (config.mlx.enabled !== false) return "mlx/fast";
3318
3441
  const problem = disabledMlxPolicyProblem(config, opts.cwd);
@@ -3380,7 +3503,7 @@ export async function startDaemon(opts: DaemonOptions) {
3380
3503
  return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
3381
3504
  }
3382
3505
  if (peer === "local") {
3383
- const routing = currentRouting(opts.cwd, log);
3506
+ const routing = activeRouting();
3384
3507
  // `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
3385
3508
  const route = args.model ? undefined : (args.route ?? routing.local.route);
3386
3509
  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 +3550,8 @@ export async function startDaemon(opts: DaemonOptions) {
3427
3550
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
3428
3551
  ...(route ? { route } : {}),
3429
3552
  ...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
3430
- hubRoutes: () => currentRouting(opts.cwd, log).hub_routes ?? {},
3431
- staySwitch: () => currentRouting(opts.cwd, log),
3553
+ hubRoutes: () => activeRouting().hub_routes ?? {},
3554
+ staySwitch: () => activeRouting(),
3432
3555
  onRoute: record => event({ type: "route", peer: "local", ...record }),
3433
3556
  onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
3434
3557
  onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
@@ -3471,7 +3594,7 @@ export async function startDaemon(opts: DaemonOptions) {
3471
3594
  }
3472
3595
 
3473
3596
  async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
3474
- const routing = currentRouting(opts.cwd, log);
3597
+ const routing = activeRouting();
3475
3598
  if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
3476
3599
  const models = await omni.models();
3477
3600
  const required = new Set([fixedModel]);
@@ -3655,7 +3778,7 @@ export async function startDaemon(opts: DaemonOptions) {
3655
3778
  instanceId,
3656
3779
  attention: attentionStatus.attentionInput,
3657
3780
  pet,
3658
- status: { peers: peerView, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
3781
+ status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
3659
3782
  boardCapabilities: { backlog: true },
3660
3783
  planningCapabilities: { enabled: true, wipPull: true, split: true },
3661
3784
  releases: tasks.releaseList(USER),
@@ -3690,6 +3813,237 @@ export async function startDaemon(opts: DaemonOptions) {
3690
3813
  };
3691
3814
  }
3692
3815
 
3816
+ /** Set a validated leaf in place: objects captured by policy consumers retain their identity. */
3817
+ function putConfig(target: object, key: string, value: unknown): void {
3818
+ const parts = key.split(".");
3819
+ if (parts.some(part => ["__proto__", "constructor", "prototype"].includes(part))) throw new Error("unsafe configuration path");
3820
+ let node = target as Record<string, unknown>;
3821
+ for (const part of parts.slice(0, -1)) {
3822
+ if (!Object.hasOwn(node, part) || !node[part] || typeof node[part] !== "object" || Array.isArray(node[part])) node[part] = {};
3823
+ node = node[part] as Record<string, unknown>;
3824
+ }
3825
+ if (value === undefined) delete node[parts.at(-1)!];
3826
+ else node[parts.at(-1)!] = structuredClone(value);
3827
+ }
3828
+ function changedRawPaths(before: unknown, after: unknown, prefix = ""): string[] {
3829
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
3830
+ if (object(before) || object(after)) {
3831
+ const old = object(before) ? before : {}, next = object(after) ? after : {};
3832
+ return [...new Set([...Object.keys(old), ...Object.keys(next)])].flatMap(key => changedRawPaths(old[key], next[key], prefix ? `${prefix}.${key}` : key));
3833
+ }
3834
+ return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix];
3835
+ }
3836
+ function declaredPaths(value: unknown, prefix = ""): string[] {
3837
+ // Switchyard dictionaries have opaque keys (including dots); publish each dictionary as one value.
3838
+ if (["routing.targets", "routing.routes", "routing.hub_routes"].includes(prefix)) return [prefix];
3839
+ if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
3840
+ return prefix ? [prefix] : [];
3841
+ }
3842
+ const isMachineKey = (key: string) => ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
3843
+ function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
3844
+ // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
3845
+ const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
3846
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled));
3847
+ return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
3848
+ }
3849
+ function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
3850
+ const fileConfig = loadConfig(opts.cwd, sources.layers);
3851
+ validateEffectiveConfig(fileConfig);
3852
+ const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
3853
+ const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
3854
+ const previews: { key: string; before: string; after: string; effect?: string }[] = [];
3855
+ const paths = new Set<string>();
3856
+ for (const layer of [...sources.trustLayers, ...acceptedSources.trustLayers]) {
3857
+ const prefix = layer.name.endsWith(".toml") ? "routing" : "";
3858
+ for (const path of declaredPaths(layer.raw, prefix)) paths.add(path);
3859
+ }
3860
+ if (!opts.config) for (const change of configChanges(desiredConfig, fileConfig)) paths.add(change.key);
3861
+ for (const key of paths) {
3862
+ if (scope && key !== scope) continue;
3863
+ const route = key.startsWith("routing."), path = (route ? key.slice(8) : key).split(".");
3864
+ const layers = sources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
3865
+ const priorLayers = acceptedSources.trustLayers.filter(layer => layer.name.endsWith(route ? ".toml" : ".json"));
3866
+ const current = [...layers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
3867
+ const prior = [...priorLayers].reverse().find(layer => valueAt(layer.raw, path) !== undefined);
3868
+ 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)));
3869
+ const rawChanged = changedLayers.length > 0;
3870
+ if (opts.config && !rawChanged) continue; // embedding overrides are not file defaults
3871
+ const before = valueAt(route ? desiredRouting : desiredConfig, path);
3872
+ const after = valueAt(route ? sources.routing : opts.config && !current ? embeddedConfig : fileConfig, path);
3873
+ const rawAfter = current ? valueAt(current.raw, path) : undefined;
3874
+ if (!rawChanged && JSON.stringify(before) === JSON.stringify(after)) continue;
3875
+ const machine = !route && isMachineKey(key);
3876
+ const origin = current ?? prior;
3877
+ let tracked = origin ? configTracked(opts.cwd, origin.name) : false;
3878
+ if (!machine && tracked === undefined && !configRepositoryPresent(opts.cwd)) tracked = false;
3879
+ const guard = SETTINGS.find(def => def.store === (route ? "routing" : "config") && def.path.join(".") === path.join("."));
3880
+ 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));
3881
+ if (key.startsWith("roster.") && scope !== key && JSON.stringify(before) !== JSON.stringify(after)) {
3882
+ rejected.push({ key, reason: "use the confirmed ahub roster add/remove setting; a full reload cannot bypass peer lifecycle" });
3883
+ continue;
3884
+ }
3885
+ let settledHolderChange = false, effect: string | undefined;
3886
+ if (key === "planner" || key === "roles" || key.startsWith("roles.")) {
3887
+ const prospective = structuredClone(candidate);
3888
+ putConfig(prospective, path.join("."), after);
3889
+ const oldHolders = roleHolders(candidate), newHolders = roleHolders(prospective);
3890
+ settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
3891
+ if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
3892
+ }
3893
+ 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) };
3894
+ const decision = authorizeConfigLeaf(input);
3895
+ if (!decision.allowed) {
3896
+ 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" : ""}`;
3897
+ rejected.push({ key, reason: next });
3898
+ if (!scope && authority === "terminal" && authorizeConfigLeaf({ ...input, confirmed: true }).allowed) {
3899
+ const shown = { key, before: before === undefined ? "(unset)" : JSON.stringify(before)!, after: after === undefined ? "(unset)" : JSON.stringify(after)!, ...(effect ? { effect } : {}) };
3900
+ 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";
3901
+ else { guarded.push(key); previews.push(shown); }
3902
+ }
3903
+ continue;
3904
+ }
3905
+ putConfig(route ? routing : candidate, path.join("."), after);
3906
+ if (route) {
3907
+ routing.sources ??= {};
3908
+ const origin = sources.routing.sources?.[path.join(".")];
3909
+ if (origin) routing.sources[path.join(".")] = origin; else delete routing.sources[path.join(".")];
3910
+ }
3911
+ if (!route && path[0] === "permission_modes" && path[1]) {
3912
+ candidate.permission_default_sources ??= {};
3913
+ const source = fileConfig.permission_default_sources?.[path[1]];
3914
+ if (source) candidate.permission_default_sources[path[1]] = source;
3915
+ else delete candidate.permission_default_sources[path[1]];
3916
+ }
3917
+ accepted.push(key);
3918
+ }
3919
+ // Describe the guarded transaction against all safe changes already admitted by this plan.
3920
+ const rolePreviews = previews.filter(item => item.key === "planner" || item.key === "roles" || item.key.startsWith("roles."));
3921
+ if (rolePreviews.length) {
3922
+ const prospective = structuredClone(candidate);
3923
+ for (const item of rolePreviews) putConfig(prospective, item.key, item.after === "(unset)" ? undefined : JSON.parse(item.after));
3924
+ const before = roleHolders(candidate), after = roleHolders(prospective);
3925
+ const effect = `planner ${JSON.stringify(before.planners)} -> ${JSON.stringify(after.planners)}; conductor ${JSON.stringify(before.conductors)} -> ${JSON.stringify(after.conductors)}`;
3926
+ for (const item of rolePreviews) item.effect = effect;
3927
+ }
3928
+ guarded.sort(); previews.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
3929
+ 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) {
3930
+ for (const item of rejected) if (guarded.includes(item.key)) item.reason += "; combined confirmation preview exceeds its 128 KiB bound; split this edit";
3931
+ guarded.length = 0; previews.length = 0;
3932
+ }
3933
+ const changes = configChanges({ ...config, roles: policyRoles }, candidate);
3934
+ const running = structuredClone({ ...config, roles: policyRoles });
3935
+ for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
3936
+ validateEffectiveConfig(running);
3937
+ conductorPeer(running.roles);
3938
+ for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
3939
+ const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
3940
+ if (problem) throw new Error(problem);
3941
+ return { sources, fileConfig, candidate, running, routing, changes, accepted, rejected, guarded, previews };
3942
+ }
3943
+ /** Acknowledging one field never hides another pending edit in the same file. */
3944
+ function acknowledgeConfiguration(sources: ConfigSources, paths: readonly string[]): void {
3945
+ const layers = acceptedSources.trustLayers.map(layer => ({ ...layer, raw: structuredClone(layer.raw) }));
3946
+ for (const name of CONFIG_SOURCE_FILES) {
3947
+ let layer = layers.find(layer => layer.name === name);
3948
+ const relevant = paths.filter(key => key.startsWith("routing.") === name.endsWith(".toml"));
3949
+ if (!relevant.length) continue;
3950
+ if (!layer) { layer = { name, raw: {} }; layers.push(layer); }
3951
+ const next = sources.trustLayers.find(layer => layer.name === name)?.raw;
3952
+ for (const key of relevant) {
3953
+ const path = key.startsWith("routing.") ? key.slice(8) : key;
3954
+ const raw = valueAt(next, path.split("."));
3955
+ const winning = [...sources.trustLayers].reverse().find(source => source.name.endsWith(name.endsWith(".toml") ? ".toml" : ".json") && valueAt(source.raw, path.split(".")) !== undefined);
3956
+ if (raw !== undefined && winning?.name !== name) continue; // a shadowed edit was never authorized by its no-op effective value
3957
+ putConfig(layer.raw, path, raw);
3958
+ }
3959
+ }
3960
+ acceptedSources = { ...sources, trustLayers: layers, layers: layers.filter((layer): layer is typeof layer & { name: "config.json" | "config.local.json" } => layer.name.endsWith(".json")), routing: desiredRouting };
3961
+ }
3962
+ function configWriteCheck(scratch: string, authority: SettingAuthority, scope?: string): HubConfig {
3963
+ const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
3964
+ texts["config.json"] = next["config.json"];
3965
+ texts["config.local.json"] = next["config.local.json"];
3966
+ const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
3967
+ const refusal = prepared.rejected.find(item => item.key === scope);
3968
+ if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
3969
+ return prepared.fileConfig;
3970
+ }
3971
+ function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
3972
+ const texts = configSourceTexts(opts.cwd);
3973
+ texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
3974
+ const prepared = prepareConfiguration(parseConfigSources(texts), authority, scope);
3975
+ const refusal = prepared.rejected.find(item => item.key === scope);
3976
+ if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
3977
+ }
3978
+ function runConfigWatch(): void {
3979
+ if (stopping) return;
3980
+ if (rosterChanging) { configWatchPending = true; return; }
3981
+ void reloadConfig("ordinary");
3982
+ }
3983
+ /** The only publication path; named writes are scoped, full guarded reloads need person confirmation. */
3984
+ async function reloadConfig(authority: SettingAuthority, scope?: string, confirm?: unknown): Promise<Record<string, unknown>> {
3985
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding configuration reload" };
3986
+ let fingerprint = "unavailable", published = false;
3987
+ try {
3988
+ const texts = configSourceTexts(opts.cwd);
3989
+ fingerprint = configFingerprint(texts);
3990
+ 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" };
3991
+ const sources = parseConfigSources(texts);
3992
+ const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
3993
+ if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
3994
+ if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
3995
+ const { changes, candidate } = prepared;
3996
+ const routingChanges = changedRawPaths({ ...runningRouting, sources: undefined }, { ...prepared.routing, sources: undefined }, "routing");
3997
+ if (prepared.running.snapshots.enabled && repo && !turnLog) turnLog = new Turns(join(opts.stateDir, "hub.db"));
3998
+ const priorDesired = JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers]);
3999
+ const rolesChanged = changes.some(change => change.key === "roles" || change.key.startsWith("roles.") || change.key === "planner");
4000
+ const beforeConductor = conductorPeer(config.roles);
4001
+ published = true;
4002
+ for (const change of changes) {
4003
+ if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
4004
+ putConfig(config, change.key, change.after);
4005
+ }
4006
+ if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
4007
+ for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
4008
+ for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
4009
+ }
4010
+ config.permission_default_sources = candidate.permission_default_sources;
4011
+ for (const peer of changedPermissionDefaults) applyPendingPermissionDefault(peer);
4012
+ if (changes.some(change => change.key === "task_sweep" || change.key.startsWith("task_sweep."))) rescheduleSweep();
4013
+ 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 });
4014
+ if (changes.some(change => change.key === "watchdog_ms")) for (const peer of bus.peers.values()) if (peer instanceof BasePeer) peer.configureWatchdog(config.watchdog_ms);
4015
+ Object.assign(limits, config.limits);
4016
+ turnFreeNow = undefined;
4017
+ runningRouting = { ...prepared.routing, pi: runningRouting.pi };
4018
+ desiredConfig = structuredClone(candidate);
4019
+ desiredRouting = structuredClone(prepared.routing);
4020
+ acknowledgeConfiguration(sources, prepared.accepted);
4021
+ if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4022
+ pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4023
+ pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
4024
+ storedConfigCache = undefined;
4025
+ if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
4026
+ bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
4027
+ }
4028
+ if (confirm !== undefined) reloadConfirmation = undefined;
4029
+ if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
4030
+ const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
4031
+ const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
4032
+ if (failure && lastReloadFailure !== failure) { try { notify(`configuration keys retained: ${sanitize(refusal).slice(0, 1200)}`); } catch { log("configuration keys retained; notice unavailable"); } }
4033
+ lastReloadFailure = failure;
4034
+ writeStatus();
4035
+ const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
4036
+ return { ok: true, applied, pendingRestart, rejected: prepared.rejected,
4037
+ ...(prepared.guarded.length ? { confirmation: { token: reloadConfirmation!.token, keys: reloadConfirmation!.keys, changes: prepared.previews } } : {}),
4038
+ 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") };
4039
+ } catch (error) {
4040
+ const message = sanitize(error instanceof Error ? error.message : "configuration reload could not be confirmed").slice(0, 600);
4041
+ const failure = `${fingerprint}:${message}`;
4042
+ 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"); } }
4043
+ return { ok: false, error: message, ...(published ? { applied: true } : {}), pendingRestart };
4044
+ }
4045
+ }
4046
+
3693
4047
  // #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
3694
4048
  let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
3695
4049
  /** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
@@ -3702,18 +4056,6 @@ export async function startDaemon(opts: DaemonOptions) {
3702
4056
  }
3703
4057
  return storedConfigCache.config;
3704
4058
  };
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
4059
  const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
3718
4060
  const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
3719
4061
  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 +4065,14 @@ export async function startDaemon(opts: DaemonOptions) {
3723
4065
  try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
3724
4066
  catch { return {}; }
3725
4067
  }
3726
- function settingsView(): { rows: SettingRow[]; undo?: string } {
4068
+ function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
3727
4069
  const stored = storedConfig();
3728
4070
  // What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
3729
4071
  // merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
3730
4072
  let routingProblem: string | undefined;
3731
4073
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
3732
4074
  const rows = settingRows({
3733
- cwd: opts.cwd, routing: currentRouting(opts.cwd, log), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4075
+ cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
3734
4076
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
3735
4077
  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
4078
  roles: roleState,
@@ -3742,11 +4084,11 @@ export async function startDaemon(opts: DaemonOptions) {
3742
4084
  });
3743
4085
  const undo = pendingUndo(opts.stateDir);
3744
4086
  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 } : {}) };
4087
+ return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
3746
4088
  }
3747
4089
  /** 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 } : {}) });
4090
+ const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
4091
+ event({ type: "settings", key, from, to, source, ...(undo ? { undo: true } : {}), ...(reason ? { reason } : {}) });
3750
4092
  try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
3751
4093
  };
3752
4094
  const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
@@ -3782,7 +4124,7 @@ export async function startDaemon(opts: DaemonOptions) {
3782
4124
  ? `Stop ${peer}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`
3783
4125
  : undefined;
3784
4126
  }
3785
- async function changeRosterSetting(def: SettingDef, value: SettingValue, source: "dashboard" | "terminal", confirm: unknown, now: boolean, undo = false): Promise<Record<string, unknown>> {
4127
+ 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
4128
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3787
4129
  const peer = def.path[1]!;
3788
4130
  let saved = false;
@@ -3797,13 +4139,13 @@ export async function startDaemon(opts: DaemonOptions) {
3797
4139
  }
3798
4140
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
3799
4141
  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));
4142
+ if (undo) undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, `roster.${peer}`), (_routing, overlay) => routingWriteCheck(authority, overlay), undoIdentity);
4143
+ else writeConfigSetting(opts.cwd, opts.stateDir, def, value, scratch => configWriteCheck(scratch, authority, `roster.${peer}`));
3802
4144
  // Apply exactly the authorized key. Other file edits stay pending until the next hub start.
3803
4145
  config.roster = next;
3804
4146
  settlePlanner(); // #455: an automatic planner follows the roster
3805
4147
  saved = true;
3806
- recordSetting(def.key, before, after, source, undo);
4148
+ recordSetting(def.key, before, after, source, undo, reason);
3807
4149
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
3808
4150
  if (!after) {
3809
4151
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
@@ -3825,30 +4167,40 @@ export async function startDaemon(opts: DaemonOptions) {
3825
4167
  const owner = bus.peers.get(peer);
3826
4168
  if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
3827
4169
  }
4170
+ const reloaded = await reloadConfig(authority, `roster.${peer}`);
4171
+ if (reloaded.ok !== true) return { ...reloaded, saved: true };
3828
4172
  writeStatus();
3829
4173
  const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
3830
4174
  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
4175
  } catch (error) {
3832
4176
  if (saved) writeStatus();
3833
4177
  return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
3834
- } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); } }
4178
+ } finally { if (rosterChanging === peer) { rosterChanging = undefined; bus.rosterChanged(peer); if (configWatchPending && !stopping) { configWatchPending = false; clearTimeout(reloadDebounce); reloadDebounce = setTimeout(runConfigWatch, 100); reloadDebounce.unref?.(); } } }
3835
4179
  }
3836
4180
  /** A checked definition and value, or the refusal. Every settings write and preview starts here. */
3837
4181
  function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
3838
4182
  const def = settingDef(key);
3839
4183
  if (typeof def === "string") return { error: def };
3840
- const routing = currentRouting(opts.cwd, log);
4184
+ const routing = activeRouting();
3841
4185
  try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
3842
4186
  catch (error) { return { error: (error as Error).message }; }
3843
4187
  }
3844
- async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown): Promise<Record<string, unknown>> {
4188
+ const settingReason = (reason: unknown): string | undefined => {
4189
+ if (reason === undefined) return undefined;
4190
+ if (typeof reason !== "string" || !reason.trim() || Buffer.byteLength(reason) > 400) throw new Error("setting reason must contain 1..400 UTF-8 bytes");
4191
+ return sanitize(reason.trim());
4192
+ };
4193
+ async function settingsSet(key: unknown, raw: unknown, authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown): Promise<Record<string, unknown>> {
4194
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
4195
+ let reason: string | undefined;
4196
+ try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
3845
4197
  const input = settingInput(key, raw);
3846
4198
  if ("error" in input) return { ok: false, error: input.error };
3847
4199
  const { def, value } = input;
3848
4200
  const refusal = settingRefusal(def, value, authority);
3849
4201
  if (refusal) return { ok: false, error: refusal };
3850
4202
  const source = settingSource(authority);
3851
- if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true);
4203
+ if (def.group === "Roster") return changeRosterSetting(def, value, source, confirm, now === true, false, authority, reason);
3852
4204
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3853
4205
  const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
3854
4206
  // The console asks for y before never-ask; here the person types the peer it is for.
@@ -3859,53 +4211,63 @@ export async function startDaemon(opts: DaemonOptions) {
3859
4211
  const from = permissionMode(peer!);
3860
4212
  const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
3861
4213
  if (result.ok !== true) return result;
3862
- recordSetting(def.key, from, permissionMode(peer!), source);
4214
+ recordSetting(def.key, from, permissionMode(peer!), source, false, reason);
3863
4215
  return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
3864
4216
  }
4217
+ let saved = false;
3865
4218
  try {
3866
4219
  if (def.store === "routing") {
3867
- const from = routingValue(currentRouting(opts.cwd, log), def);
3868
- const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, routingCheck);
4220
+ const from = routingValue(activeRouting(), def);
4221
+ const next = writeRoutingSetting(opts.cwd, opts.stateDir, def, value, (_routing, overlay) => routingWriteCheck(authority, overlay, def.key));
3869
4222
  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)` };
4223
+ saved = true;
4224
+ const result = await reloadConfig(authority, def.key);
4225
+ if (result.ok !== true) return { ...result, saved: true };
4226
+ recordSetting(def.key, from, routingValue(activeRouting(), def), source, false, reason);
4227
+ return { ok: true, text: `${def.key}: ${settingText(routingValue(activeRouting(), def))} (in force now)` };
3872
4228
  }
3873
- // Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
3874
4229
  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
4230
  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;
4231
+ 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` };
4232
+ saved = true;
4233
+ const result = await reloadConfig(authority, def.path.join("."));
4234
+ if (result.ok !== true) return { ...result, saved: true };
3878
4235
  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"})` };
4236
+ recordSetting(def.key, from, to, source, false, reason);
4237
+ 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) }); }
4238
+ const timing = def.applies === "live" ? "in force now" : def.applies === "restart" ? "pending restart"
4239
+ : ["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";
4240
+ return { ok: true, text: `${def.key}: ${settingText(to)} (${timing})` };
3885
4241
  } catch (error) {
3886
- return { ok: false, error: (error as Error).message };
4242
+ return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
3887
4243
  }
3888
4244
  }
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>> {
4245
+
4246
+ async function settingsUndo(authority: SettingAuthority, confirm?: unknown, now?: unknown, rawReason?: unknown, expectedKey?: unknown, expectedDigest?: unknown): Promise<Record<string, unknown>> {
4247
+ if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding settings" };
4248
+ let reason: string | undefined;
4249
+ try { reason = settingReason(rawReason); } catch (error) { return { ok: false, error: (error as Error).message }; }
3893
4250
  // The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
3894
4251
  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);
4252
+ const identity = pendingUndoIdentity(opts.stateDir);
4253
+ if (expectedKey !== undefined || expectedDigest !== undefined) {
4254
+ 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" };
4255
+ if (!identity || identity.key !== expectedKey || identity.undoDigest !== expectedDigest) return { ok: false, error: "undo record changed since preview; preview again; nothing was undone" };
4256
+ }
4257
+ const def = settingDef(identity?.key);
4258
+ if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
4259
+ if (def.group === "Roster") return changeRosterSetting(def, null, settingSource(authority), confirm, now === true, true, authority, reason, identity);
3898
4260
  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));
4261
+ const read = (): SettingValue => def.store === "routing" ? routingValue(activeRouting(), def) : settingScalar(valueAt(storedConfig(), def.path));
3900
4262
  // never-ask is set with its confirmation, never put back by an undo that carries none.
3901
4263
  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
4264
  try {
3903
4265
  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) });
4266
+ undoSetting(opts.cwd, opts.stateDir, scratch => configWriteCheck(scratch, authority, def.path.join(".")), (_routing, overlay) => routingWriteCheck(authority, overlay, def.key), identity);
4267
+ const result = await reloadConfig(authority, def.store === "routing" ? def.key : def.path.join("."));
4268
+ if (result.ok !== true) return { ...result, saved: true };
4269
+ recordSetting(def.key, from, read(), settingSource(authority), true, reason);
4270
+ 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
4271
  return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
3910
4272
  } catch (error) {
3911
4273
  return { ok: false, error: (error as Error).message };
@@ -3919,21 +4281,21 @@ export async function startDaemon(opts: DaemonOptions) {
3919
4281
  if (def.group === "Roster") {
3920
4282
  try {
3921
4283
  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 }) };
4284
+ 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
4285
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
3924
4286
  }
3925
4287
  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
4288
  if (def.group === "Permissions") {
3927
4289
  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"])] };
4290
+ 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
4291
  }
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"}`] };
4292
+ 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
4293
  try {
3932
4294
  const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
3933
4295
  const open = board.list().filter((task) => !isClosedTaskState(task.state));
3934
4296
  // A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
3935
4297
  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"])],
4298
+ 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
4299
  tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
3938
4300
  ...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
3939
4301
  } catch (error) {
@@ -3941,6 +4303,14 @@ export async function startDaemon(opts: DaemonOptions) {
3941
4303
  }
3942
4304
  }
3943
4305
 
4306
+ function settingsUndoPreview(): Record<string, unknown> {
4307
+ const identity = pendingUndoIdentity(opts.stateDir), def = settingDef(identity?.key);
4308
+ if (typeof def === "string" || !identity) return { ok: false, error: "nothing to undo" };
4309
+ const value = restoredValue(opts.cwd, opts.stateDir, def), preview = settingsPreview(def.key, value ?? null);
4310
+ if (pendingUndoIdentity(opts.stateDir)?.undoDigest !== identity.undoDigest) return { ok: false, error: "undo record changed during preview; preview again" };
4311
+ 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."] };
4312
+ }
4313
+
3944
4314
  async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
3945
4315
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
3946
4316
  // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
@@ -4019,11 +4389,13 @@ export async function startDaemon(opts: DaemonOptions) {
4019
4389
  return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
4020
4390
  }
4021
4391
  case "setting":
4022
- return settingsSet(a.key, a.value, authority, a.confirm, a.now);
4392
+ return settingsSet(a.key, a.value, authority, a.confirm, a.now, a.reason);
4023
4393
  case "setting_undo":
4024
- return settingsUndo(authority, a.confirm, a.now);
4394
+ return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
4025
4395
  case "setting_preview":
4026
- return settingsPreview(a.key, a.value);
4396
+ return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
4397
+ case "setting_reload":
4398
+ return reloadConfig(authority);
4027
4399
  default:
4028
4400
  return bad;
4029
4401
  }
@@ -4614,7 +4986,7 @@ export async function startDaemon(opts: DaemonOptions) {
4614
4986
  }
4615
4987
  case "watch": {
4616
4988
  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" });
4989
+ if (typeof msg.peer !== "string" || !definitionOf(msg.peer)?.capabilities?.watch) return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
4618
4990
  const peer = msg.peer as "kimi" | "qwen";
4619
4991
  // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
4620
4992
  const kimiOwner = bus.peers.get("kimi");
@@ -4687,16 +5059,19 @@ export async function startDaemon(opts: DaemonOptions) {
4687
5059
  case "settings_get":
4688
5060
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4689
5061
  return void reply({ ok: true, ...settingsView(), roster: rosterView() });
5062
+ case "settings_reload":
5063
+ if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
5064
+ return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
4690
5065
  case "settings_preview":
4691
5066
  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));
5067
+ return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
4693
5068
  case "settings_set":
4694
5069
  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" }));
5070
+ 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
5071
  return;
4697
5072
  case "settings_undo":
4698
5073
  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" }));
5074
+ 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
5075
  case "kimi_launch_end": {
4701
5076
  if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
4702
5077
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
@@ -4753,7 +5128,7 @@ export async function startDaemon(opts: DaemonOptions) {
4753
5128
  board,
4754
5129
  isPii: (t) => tasks.isPii(t),
4755
5130
  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"),
5131
+ isPiiText: (q) => activeRouting().constraints.pii === "local_only" && detectSignals({ title: q, detail: "", refs: {} }, activeRouting(), opts.cwd).includes("pii"),
4757
5132
  ...(config.memory.enabled ? { memory } : {}),
4758
5133
  // #258: entries that match the question, then the graph neighbourhood of what they are about.
4759
5134
  ...(memoryStore ? { native: (keys: string[]) => {
@@ -4838,6 +5213,8 @@ export async function startDaemon(opts: DaemonOptions) {
4838
5213
  log("hub stopping");
4839
5214
  writeStatus();
4840
5215
  dashboard?.stop();
5216
+ stopConfigWatch();
5217
+ clearInterval(sweepTimer);
4841
5218
  for (const i of intervals) clearInterval(i);
4842
5219
  for (const pending of checkpointWaits.values()) pending.done(undefined);
4843
5220
  for (const p of permissions.values()) p.done(undefined);
@@ -4902,6 +5279,18 @@ export async function startDaemon(opts: DaemonOptions) {
4902
5279
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
4903
5280
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
4904
5281
  ready = true;
5282
+ for (const name of CONFIG_SOURCE_FILES) {
5283
+ const path = join(opts.cwd, ".agenthub", name);
5284
+ const listener = (current: import("node:fs").Stats, previous: import("node:fs").Stats) => {
5285
+ if (stopping || current.mtimeMs === previous.mtimeMs && current.size === previous.size && current.ino === previous.ino && current.nlink === previous.nlink) return;
5286
+ clearTimeout(reloadDebounce);
5287
+ reloadDebounce = setTimeout(runConfigWatch, 100);
5288
+ reloadDebounce.unref?.();
5289
+ };
5290
+ watchFile(path, { persistent: false, interval: 100 }, listener);
5291
+ configWatchers.push({ path, listener });
5292
+ }
5293
+ startupCleanup.push(stopConfigWatch);
4905
5294
  // #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
4906
5295
  try { const settled = tasks.reconcileMemory(); if (settled) log(`hub memory: settled ${settled} approved task${settled === 1 ? "" : "s"} a stop had left unsettled`); }
4907
5296
  catch (e) { log(`hub memory reconciliation failed: ${(e as Error).message}`); }
@@ -4927,7 +5316,7 @@ export async function startDaemon(opts: DaemonOptions) {
4927
5316
  });
4928
5317
  }
4929
5318
  // 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");
5319
+ const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => p.peer === "pi");
4931
5320
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
4932
5321
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
4933
5322
  } finally {