@staix/agent-hub 0.12.30 → 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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
2
+ import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
3
+ import { isDenied } from "../local/deny.ts";
4
+ import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
5
+ import { renderHeldHint } from "./delivery-hints.ts";
1
6
  import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
2
7
  import { MemoryObservations } from "./memory-observations.ts";
3
8
  import { createStalenessProbe } from "./memory-staleness.ts";
@@ -14,22 +19,26 @@ import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from
14
19
  import { ActivityRing } from "./activity.ts";
15
20
  import { JcodePeer } from "../adapters/jcode.ts";
16
21
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
17
- import { loadSdlc } from "./sdlc.ts";
22
+ import { loadSdlc, sdlcGh } from "./sdlc.ts";
18
23
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
19
- import { KNOWN_PEERS, OPTIONAL_PEERS, SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
24
+ import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer } from "./peer-catalog.ts";
20
25
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
21
- import { buildKimiLaunch, buildQwenLaunch } from "../cli/launch.ts";
26
+ import { buildKimiLaunch } from "../cli/launch.ts";
27
+ import { definitionOf } from "../adapters/registry.ts";
22
28
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
23
29
  import type { ToolObservation } from "../models/route/signals.ts";
24
30
  import { createHash, randomBytes, randomUUID } from "node:crypto";
25
- 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";
26
32
  import { join, relative, resolve } from "node:path";
27
33
  import { homedir } from "node:os";
28
34
  import { spawn } from "node:child_process";
29
35
  import { childEnv } from "./child-process.ts";
30
36
  import { runCheck } from "./checks.ts";
31
37
  import { DEFAULT_TASK_SWEEP, taskSweepConfig, type TaskSweepConfig } from "./task-sweep.ts";
32
- 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";
33
42
  import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
34
43
  import { ExecutionBudget } from "./execution-budget.ts";
35
44
  import { claudeReportedTokens, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
@@ -42,7 +51,7 @@ import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
42
51
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
43
52
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
44
53
  import { processSignature } from "../pi/process-signature.ts";
45
- import { recordPiReplacement, validatePiSession } from "../pi/session.ts";
54
+ import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
46
55
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
47
56
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
48
57
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
@@ -51,16 +60,16 @@ import { Capture, skipTools } from "../memory/capture.ts";
51
60
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
52
61
  import { Sidecar } from "../switchyard/sidecar.ts";
53
62
  import { Briefs } from "../memory/brief.ts";
54
- import { Board, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
63
+ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
55
64
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
56
65
  import { HUB } from "./envelope.ts";
57
66
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
58
67
  import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
59
68
  import { basename, isAbsolute } from "node:path";
60
69
  import { fileURLToPath } from "node:url";
61
- import { realPath } from "./project.ts";
70
+ import { realPath, hubHome } from "./project.ts";
62
71
  import type { BusEvent } from "./bus.ts";
63
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
72
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
64
73
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
65
74
  import { SupervisionFeed } from "./supervision.ts";
66
75
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -68,13 +77,13 @@ import { launcherPreview } from "../cli/preview.ts";
68
77
  import { AssignmentUndeliveredError, Tasks } from "./tasks.ts";
69
78
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
70
79
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
71
- import { currentRouting, detectSignals, OVERLAY_FILE, parseRouting, routingText, type Routing } from "./routing.ts";
72
- 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";
73
82
  import { Bus } from "./bus.ts";
74
83
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
75
84
  import { DeliveryJournal } from "./delivery-journal.ts";
76
85
  import { startDashboard, type DashboardSession } from "./ui.ts";
77
- import { PROTOCOL, stateDirFor } from "./control-client.ts";
86
+ import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
78
87
  import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
79
88
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
80
89
  import { MemoryClient, workerUrl } from "../memory/client.ts";
@@ -90,6 +99,8 @@ import type { JournalDelivery } from "./delivery-journal.ts";
90
99
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
91
100
  import { changedPaths, hasTree, repoOf, snapshot, Turns, type TurnRecord } from "./snapshots.ts";
92
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";
93
104
 
94
105
  import { isStartablePeer, peerStartConfig, PEER_START_RETRY_MS, STARTABLE_PEERS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
95
106
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
@@ -104,7 +115,7 @@ import { syncMemory } from "@staix/agent-mem";
104
115
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
105
116
  import { memoryProjectKey } from "@staix/agent-mem";
106
117
  import { nativeRecall } from "../memory/recall.ts";
107
- import { promotionScreen, withoutPii } from "./memory-policy.ts";
118
+ import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
108
119
 
109
120
  export interface HubConfig {
110
121
  watchdog_ms: number;
@@ -125,6 +136,8 @@ export interface HubConfig {
125
136
  memory: { enabled: boolean; worker_url?: string; inject_tokens: number; brief_items: number; native?: boolean; short_ttl_days?: number; sync_interval_min?: number; suggest_edges?: boolean };
126
137
  roster: PeerRoster;
127
138
  roles: Record<string, string[]>;
139
+ /** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
140
+ planner?: string;
128
141
  profile?: ProfileName;
129
142
  profile_overrides?: Record<string, unknown>;
130
143
  pii?: { paths: string[] };
@@ -147,7 +160,7 @@ export interface HubConfig {
147
160
  /** Between-turn task escalation, disabled unless explicitly enabled (#186). */
148
161
  task_sweep: TaskSweepConfig;
149
162
  /** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
150
- checks: { timeout_s: number; [cls: string]: string | number };
163
+ checks: { timeout_s: number; runner?: "container"; [cls: string]: string | number | undefined };
151
164
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
152
165
  snapshots: { enabled: boolean; keep: number };
153
166
  /** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
@@ -228,17 +241,24 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
228
241
 
229
242
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
230
243
  const CONFIG_FILES = ["config.json", "config.local.json"] as const;
244
+ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
231
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"];
232
246
 
233
247
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
234
- export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"] } {
248
+ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
235
249
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
236
250
  const conductor = { ...DEFAULT_CONFIG.conductor };
251
+ let planner: string | undefined;
237
252
  for (const name of CONFIG_FILES) {
238
253
  let file: Record<string, unknown>;
239
- 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")); }
240
256
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
241
257
  if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
258
+ if (file.planner !== undefined) {
259
+ if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
260
+ planner = file.planner as string;
261
+ }
242
262
  if (file.conductor !== undefined) {
243
263
  if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
244
264
  const block = file.conductor as Record<string, unknown>;
@@ -253,7 +273,7 @@ export function loadConductorPolicy(cwd: string): { roles: Record<string, string
253
273
  }
254
274
  }
255
275
  conductorPeer(roles);
256
- return { roles, conductor };
276
+ return { roles, conductor, ...(planner ? { planner } : {}) };
257
277
  }
258
278
 
259
279
  /** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
@@ -267,13 +287,14 @@ function syncMinutes(v: unknown): number {
267
287
  return v;
268
288
  }
269
289
 
270
- export function loadConfig(cwd: string): HubConfig {
290
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubConfig {
271
291
  const ignored: string[] = [];
272
292
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
273
293
  const files = CONFIG_FILES.flatMap((name) => {
274
294
  let file: Record<string, any>; // parsed JSON, checked field by field below
295
+ if (layers && !layers.some(layer => layer.name === name)) return [];
275
296
  try {
276
- 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"));
277
298
  } catch (error) {
278
299
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
279
300
  throw error;
@@ -328,7 +349,7 @@ export function loadConfig(cwd: string): HubConfig {
328
349
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
329
350
  }
330
351
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
331
- const conductorPolicy = loadConductorPolicy(cwd);
352
+ const conductorPolicy = loadConductorPolicy(cwd, layers);
332
353
  if (!mlx.enabled && file.pi?.backend === "mlx") throw new Error("Pi backend mlx conflicts with mlx.enabled=false");
333
354
  if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
334
355
  if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
@@ -345,6 +366,7 @@ export function loadConfig(cwd: string): HubConfig {
345
366
  ...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
346
367
  roster: peerRosterConfig(file.roster),
347
368
  roles: conductorPolicy.roles,
369
+ planner: conductorPolicy.planner,
348
370
  conductor: conductorPolicy.conductor,
349
371
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
350
372
  context: { ...DEFAULT_CONTEXT, ...file.context },
@@ -358,7 +380,7 @@ export function loadConfig(cwd: string): HubConfig {
358
380
  },
359
381
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
360
382
  task_sweep: taskSweepConfig(file.task_sweep),
361
- checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
383
+ checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "container" ? "container" : undefined },
362
384
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
363
385
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
364
386
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
@@ -423,6 +445,12 @@ export interface DaemonOptions {
423
445
  researchGraceMs?: number;
424
446
  /** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
425
447
  memorySync?: typeof syncMemory;
448
+ /** Host-only test/embedding transports. Never read from config or tool input. */
449
+ boundedTools?: {
450
+ specAvailable?: typeof specAvailable;
451
+ fetchSpec?: typeof fetchSpec;
452
+ runner?: (options: RunnerOptions) => Pick<ContainerRunner, "available" | "start" | "result" | "consumeOutput" | "cancel" | "sweep" | "stopAll" | "markStale"> & Partial<Pick<ContainerRunner, "unknownReceipts" | "maintenanceUnavailable">>;
453
+ };
426
454
  }
427
455
 
428
456
  interface Client {
@@ -544,7 +572,9 @@ export async function startDaemon(opts: DaemonOptions) {
544
572
  try {
545
573
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
546
574
  const lifecycleConfig = loadSdlc(opts.cwd);
547
- 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 } };
548
578
  let rosterChanging: string | undefined;
549
579
  let kimiSessionMode: StartMode | undefined;
550
580
  let kimiAdmissionId: string | undefined;
@@ -560,6 +590,39 @@ export async function startDaemon(opts: DaemonOptions) {
560
590
  const logFile = join(opts.stateDir, "hub.log");
561
591
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
562
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;
609
+ // #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
610
+ // place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
611
+ // the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
612
+ let policyRoles = structuredClone(config.roles);
613
+ let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
614
+ let plannerLogged = "";
615
+ const settlePlanner = () => {
616
+ const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
617
+ for (const key of Object.keys(config.roles)) delete config.roles[key];
618
+ Object.assign(config.roles, roles);
619
+ plannerSource = source;
620
+ const line = `${plannerPeers().join(", ") || "nobody"} (${source})`;
621
+ if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
622
+ };
623
+ const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
624
+ if (config.planner !== undefined && !(PLANNER_PEERS as readonly string[]).includes(config.planner)) log(`planner: ${JSON.stringify(config.planner)} is not one of ${PLANNER_PEERS.join(", ")}; ignored`);
625
+ settlePlanner();
563
626
  const catalog = loadRoleCatalog(opts.cwd);
564
627
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
565
628
  const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
@@ -579,16 +642,16 @@ export async function startDaemon(opts: DaemonOptions) {
579
642
  try { configured = configuredRoles(peer); } catch { note = "catalog selection is incompatible or unavailable"; }
580
643
  const prepared = preparedSessionRoles.get(peer);
581
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"; }
582
648
  const value = active ? prepared ?? null : configured;
583
- 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." } : {}) };
584
- }
585
- function validateRoleSetting(scratch: string, peer: string) {
586
- const candidate = loadConfig(scratch);
587
- selectedRoles(peer, config.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(candidate.catalog_roles) }, catalog);
588
- 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." } : {}) };
589
650
  }
651
+ const prepareRoleSelection = (peer: string) => { roleAssignments[peer] = catalogAssignments(config.catalog_roles)[peer] ?? resolvedProfile.catalog_roles[peer] ?? []; };
590
652
  function sessionRoleContract(peer: string): string {
591
653
  try {
654
+ prepareRoleSelection(peer);
592
655
  const selected = selectedRoles(peer, config.roles, roleAssignments, catalog);
593
656
  preparedSessionRoles.set(peer, selected.map(role => role.id));
594
657
  preparedSessionRoleIdentities.set(peer, selected.map(roleIdentity));
@@ -694,8 +757,11 @@ export async function startDaemon(opts: DaemonOptions) {
694
757
 
695
758
  // A session record left by a run that never shut down means it crashed (issue #37). A controlled restart has its own.
696
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";
697
763
  if (config.mlx.enabled === false) {
698
- 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)];
699
765
  for (const launch of launches) {
700
766
  const problem = mlxLaunchProblem(config, (launch ?? {}) as { backend?: unknown; model?: unknown });
701
767
  if (problem) throw new Error(`recorded Pi recovery: ${problem}`);
@@ -704,7 +770,7 @@ export async function startDaemon(opts: DaemonOptions) {
704
770
  // A controlled restart's source may have been cut short before it removed its record: this run is not a crash, and
705
771
  // a record left now would make the next ordinary start look like one.
706
772
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
707
- 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
708
774
  const piAutoStart = () => onRoster("pi") && config.pi.auto_start;
709
775
  const startedAt = Date.now();
710
776
  /** What crash recovery did or asks the user to do, for `ahub status`. */
@@ -718,12 +784,12 @@ export async function startDaemon(opts: DaemonOptions) {
718
784
  if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
719
785
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
720
786
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
721
- const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important"): boolean => {
787
+ const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run"): boolean => {
722
788
  if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
723
789
  const list = config.capabilities[peer];
724
790
  return Array.isArray(list) && list.includes(cap);
725
791
  };
726
- const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important"];
792
+ const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important", "spec_read", "check_run"];
727
793
  for (const [peer, list] of Object.entries(config.capabilities)) {
728
794
  if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
729
795
  else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
@@ -738,7 +804,8 @@ export async function startDaemon(opts: DaemonOptions) {
738
804
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
739
805
  // together. Cached until the board changes: it is asked per recipient of every message.
740
806
  let turnFreeNow: boolean | undefined;
741
- const turnFree = (): boolean => (turnFreeNow ??= coordination === "turn-free" && !board.list().some((t) => t.state !== "approved" && 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)));
742
809
  /**
743
810
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
744
811
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
@@ -783,10 +850,10 @@ export async function startDaemon(opts: DaemonOptions) {
783
850
  const hubStartedAt = Date.now();
784
851
  // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
785
852
  let relevantNotice: (peer: PeerId, env: Envelope) => boolean = () => true;
786
- const staleOff = config.experiments?.stale_notices === "deliver";
787
- 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)");
788
855
  const requestedPeerStops = new Set<string>();
789
- 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?.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,
790
857
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
791
858
  startupCleanup.push(() => bus.closeJournal());
792
859
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -842,7 +909,7 @@ export async function startDaemon(opts: DaemonOptions) {
842
909
  memoryNotice(`hub native memory disabled: ${reason}`);
843
910
  };
844
911
  if (config.memory.native) {
845
- 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) => {
846
913
  const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
847
914
  return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
848
915
  }) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
@@ -867,7 +934,7 @@ export async function startDaemon(opts: DaemonOptions) {
867
934
  * Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
868
935
  * policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
869
936
  */
870
- 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 ?? []);
871
938
  let candidates: CandidateStore | undefined;
872
939
  if (memoryStore) {
873
940
  try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
@@ -978,7 +1045,7 @@ export async function startDaemon(opts: DaemonOptions) {
978
1045
  const onCampus = async (): Promise<boolean> => (await omni.onCampus()) && !(sidecar?.upstream && omni.isAccessHost(sidecar.upstream));
979
1046
  let sidecar: Sidecar | undefined; // L2, started by the first hub-owned model call, stopped with the hub
980
1047
  let sidecarRouting = "";
981
- 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 });
982
1049
 
983
1050
  let dashboard: ReturnType<typeof startDashboard> | undefined;
984
1051
  const uiEvents: { seq: number; event: BusEvent }[] = [];
@@ -1000,7 +1067,7 @@ export async function startDaemon(opts: DaemonOptions) {
1000
1067
  conductor: () => conductorPeer(config.roles) ?? undefined,
1001
1068
  scope: () => config.conductor?.feed ?? "own",
1002
1069
  tasks: () => board.list(), publicTitle: (task) => tasks.publicTitle(task), isPrivate: (task) => tasks.isPii(task),
1003
- approvalAgeMs: (config.conductor?.approval_wait_s ?? 30) * 1000,
1070
+ approvalAgeMs: () => (config.conductor?.approval_wait_s ?? 30) * 1000,
1004
1071
  readRound: (peer) => conductorHolds.readRound(peer), writeRound: (peer, signature) => conductorHolds.writeRound(peer, signature),
1005
1072
  emit: (notice) => {
1006
1073
  if (stopping || recoveryActive()) return false;
@@ -1011,10 +1078,11 @@ export async function startDaemon(opts: DaemonOptions) {
1011
1078
  const refreshConductorPolicy = () => {
1012
1079
  if (opts.config) return; // explicit embedding/test configuration remains its authority
1013
1080
  const before = conductorPeer(config.roles);
1014
- const policy = loadConductorPolicy(opts.cwd);
1015
- for (const key of Object.keys(config.roles)) delete config.roles[key];
1016
- Object.assign(config.roles, policy.roles);
1081
+ const policy = { roles: policyRoles, planner: config.planner, conductor: config.conductor };
1082
+ policyRoles = structuredClone(policy.roles);
1083
+ config.planner = policy.planner;
1017
1084
  config.conductor = policy.conductor;
1085
+ settlePlanner();
1018
1086
  const after = conductorPeer(config.roles);
1019
1087
  if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
1020
1088
  if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
@@ -1023,8 +1091,8 @@ export async function startDaemon(opts: DaemonOptions) {
1023
1091
  startupCleanup.push(() => executionBudget.close());
1024
1092
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
1025
1093
  const allowedChecks = new Set([...CLASSES, ...Object.keys(resolvedProfile.classes)]);
1026
- const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => allowedChecks.has(k) && typeof v === "string" && v.trim())) as Record<string, string>;
1027
- const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
1094
+ const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => k !== "runner" && allowedChecks.has(k) && typeof v === "string" && v.trim())) as Record<string, string>;
1095
+ const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && k !== "runner" && !(k in checkCommands));
1028
1096
  if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
1029
1097
  // Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
1030
1098
  for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
@@ -1034,7 +1102,96 @@ export async function startDaemon(opts: DaemonOptions) {
1034
1102
  const runningChecks = new Set<() => void>();
1035
1103
  let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
1036
1104
  const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
1105
+ // Bounded capabilities (#418): inactive sandboxed peers need no host account probe.
1106
+ // A config-file daemon probes configured Pi/runner at startup; embeddings opt in or probe lazily on use.
1107
+ let specState: Awaited<ReturnType<typeof specAvailable>> = { available: false, reason: "gh unavailable: not probed" };
1108
+ let specProbe: Promise<void> | undefined, specProbeSettled = false;
1109
+ const ensureSpec = (refresh = false): Promise<void> => {
1110
+ if (specProbe && (!refresh || !specProbeSettled)) return specProbe;
1111
+ specProbeSettled = false;
1112
+ specProbe = Promise.resolve().then(() => (opts.boundedTools?.specAvailable ?? specAvailable)({ projectDir: opts.cwd }))
1113
+ .then(value => { specState = value; }, () => { specState = { available: false, reason: "gh unavailable" }; })
1114
+ .finally(() => { specProbeSettled = true; });
1115
+ return specProbe;
1116
+ };
1117
+ const checkPending = new Set<string>();
1118
+ const checkConnections = new WeakMap<object, string>();
1119
+ const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
1120
+ const connectionGeneration = (id: string): string | undefined => {
1121
+ const peer = bus.peers.get(id);
1122
+ if (!peer || peer.state === "offline") return undefined;
1123
+ let token = checkConnections.get(peer);
1124
+ if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1125
+ const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1126
+ if (peer instanceof PiPeer && native === undefined) return undefined;
1127
+ return `${instanceId}:${token}:${String(native ?? "")}`;
1128
+ };
1129
+ const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
1130
+ const task = board.get(receipt.task);
1131
+ return !!task && task.owner === receipt.owner && task.state === "in_progress" && task.updated === receipt.updated && task.history.length === receipt.revision
1132
+ && connectionGeneration(receipt.owner) === receipt.generation && may(receipt.owner, "check_run") && membershipAllowed(receipt.owner) && !tasks.isPii(task)
1133
+ && !turns.get(receipt.owner)?.private && !contextPii(receipt.owner);
1134
+ };
1135
+ const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
1136
+ && !mentionsPrivatePath(text, [], [...(config.pii?.paths ?? []), ...config.local.deny], opts.cwd);
1137
+ const publicCheck = (receipt: RunReceipt) => {
1138
+ const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
1139
+ return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
1140
+ excluded: receipt.excluded, runner: receipt.runner, image: receipt.image, platform: receipt.platform,
1141
+ check: receipt.check, ...(receipt.files ? { files: receipt.files } : {}), exit: receipt.exit, timed_out: receipt.timed_out,
1142
+ cancelled: receipt.cancelled, interrupted: receipt.interrupted, duration_ms: receipt.duration_ms,
1143
+ tail: safeTail ? receipt.tail : "", output_bytes: receipt.output_bytes, truncated: receipt.truncated,
1144
+ output_withheld: receipt.output_withheld || !safeTail || !safeError, ...(safeError && receipt.error ? { error: receipt.error } : {}) };
1145
+ };
1146
+ const checkOptions: RunnerOptions = {
1147
+ projectDir: opts.cwd, stateDir: opts.stateDir, storageDir: join(hubHome(), "checks"),
1148
+ projectKey: createHash("sha256").update(realPath(opts.cwd)).digest("hex").slice(0, 24),
1149
+ enabled: () => config.checks.runner === "container",
1150
+ isDenied: path => isDenied(path, config.local.deny) || matchesPolicy(opts.cwd, path, config.pii?.paths ?? [], true),
1151
+ isCurrent: currentCheck,
1152
+ onResult: async receipt => {
1153
+ if (!currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
1154
+ // Only a screened, current-owner result is delivered; no tail enters capture or diagnostic logs.
1155
+ const view = publicCheck(receipt);
1156
+ checkBindings.set(receipt.run, { task: receipt.task, owner: receipt.owner, generation: receipt.generation, updated: receipt.updated, revision: receipt.revision });
1157
+ while (checkBindings.size > 200) checkBindings.delete(checkBindings.keys().next().value!);
1158
+ if (stopping || !currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
1159
+ bus.publish(newEnvelope(HUB, sanitize(JSON.stringify(view)), { to: [receipt.owner], kind: "task", refs: { task: String(receipt.task), boundedCheck: receipt.run } }));
1160
+ log(`check ${receipt.run}: exit ${receipt.exit ?? "unknown"}, bytes ${receipt.output_bytes}`);
1161
+ },
1162
+ };
1163
+ const checkRunner = opts.boundedTools?.runner?.(checkOptions) ?? new ContainerRunner(checkOptions);
1164
+ const runnerRefreshAbort = new AbortController();
1165
+ startupCleanup.push(() => { runnerRefreshAbort.abort(); void checkRunner.stopAll(); });
1166
+ let runnerState = await checkRunner.available(runnerRefreshAbort.signal);
1167
+ const orphanChecks = (await checkRunner.sweep()).map(line => boundedSafe(line) ? sanitize(line) : "orphan check: details withheld by privacy policy");
1168
+ if (opts.boundedTools?.specAvailable || !opts.config && (config.pi.enabled || config.checks.runner === "container")) await ensureSpec();
1169
+ let runnerStateAt = runnerState.checkedAt ?? Date.now(), refreshingRunner: Promise<void> | undefined;
1170
+ const refreshRunner = (): Promise<void> => {
1171
+ if (stopping || runnerRefreshAbort.signal.aborted) return Promise.resolve();
1172
+ if (Date.now() - runnerStateAt < 60_000) return Promise.resolve();
1173
+ if (refreshingRunner) return refreshingRunner;
1174
+ refreshingRunner = checkRunner.available(runnerRefreshAbort.signal).then(value => {
1175
+ if (stopping || runnerRefreshAbort.signal.aborted) return;
1176
+ runnerState = value; runnerStateAt = value.checkedAt ?? Date.now();
1177
+ }, () => {
1178
+ if (stopping || runnerRefreshAbort.signal.aborted) return;
1179
+ runnerState = { available: false, reason: "container unavailable" }; runnerStateAt = Date.now();
1180
+ }).finally(() => { refreshingRunner = undefined; });
1181
+ return refreshingRunner;
1182
+ };
1183
+ const peerCapabilities = () => {
1184
+ // Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
1185
+ if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
1186
+ return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
1187
+ if (peer !== "pi" && peer !== "local") return [peer, { tests: true, spec: true }];
1188
+ return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"),
1189
+ testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
1190
+ specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
1191
+ }));
1192
+ };
1037
1193
  const tasks: Tasks = new Tasks({
1194
+ capabilities: peerCapabilities, repository: memoryRepo,
1038
1195
  recordPlanning: change => event({ type: "planning", ...change }),
1039
1196
  wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
1040
1197
  profile: profileName(config.profile),
@@ -1043,17 +1200,17 @@ export async function startDaemon(opts: DaemonOptions) {
1043
1200
  catalog,
1044
1201
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1045
1202
  projectedReview: task => reviewProjection(task),
1046
- sdlc: { config: () => lifecycleConfig },
1203
+ sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1047
1204
  board,
1048
1205
  bus,
1049
1206
  executionBudget,
1050
- 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
1051
1208
  cwd: opts.cwd,
1052
1209
  project: chain.at(-1)!,
1053
1210
  ...(config.memory.enabled ? { memory } : {}),
1054
1211
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1055
1212
  memoryCheck: checkMemory,
1056
- ...(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,
1057
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,
1058
1215
  (peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
1059
1216
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
@@ -1088,14 +1245,19 @@ export async function startDaemon(opts: DaemonOptions) {
1088
1245
  },
1089
1246
  triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
1090
1247
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
1091
- 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 }),
1092
1249
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
1093
1250
  quota: (): ReturnType<Budget["headroom"]> => budget.headroom(), // budget is built below; this runs at assignment time
1094
1251
  review: config.review,
1095
1252
  sweep: taskSweepConfig(config.task_sweep),
1096
1253
  sweepHeld: () => stopping || recoveryActive(),
1254
+ cancellationHeld: () => stopping || recoveryActive(),
1255
+ offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1256
+ offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer instanceof WsPeer && peer.pullOnly; },
1257
+ stallNotice: stall => { supervision.taskStalled(stall); },
1097
1258
  roles: config.roles,
1098
1259
  turnFree,
1260
+ coordination: () => config.coordination === "turn-free",
1099
1261
  capable: (peer) => capable.has(peer),
1100
1262
  idle: (peer) => idle(peer),
1101
1263
  treeHash: (paths, windows) => facts.tree(paths, windows),
@@ -1116,6 +1278,21 @@ export async function startDaemon(opts: DaemonOptions) {
1116
1278
  held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
1117
1279
  });
1118
1280
  relevantNotice = (peer, env) => {
1281
+ if (env.from === HUB && env.refs?.boundedCheck) {
1282
+ const run = env.refs.boundedCheck, binding = checkBindings.get(run);
1283
+ let outputSafe = false;
1284
+ try {
1285
+ const value = JSON.parse(env.body) as { tail?: unknown; error?: unknown };
1286
+ outputSafe = typeof value.tail === "string" && boundedSafe(value.tail)
1287
+ && (value.error === undefined || typeof value.error === "string" && boundedSafe(value.error));
1288
+ } catch { /* An unknown projection never reaches a model. */ }
1289
+ if (!binding || binding.owner !== peer || !currentCheck(binding) || !outputSafe) {
1290
+ checkBindings.delete(run);
1291
+ void checkRunner.markStale(run).catch(() => {});
1292
+ return false;
1293
+ }
1294
+ return true;
1295
+ }
1119
1296
  if (env.from !== HUB || !env.refs?.supervision) return tasks.relevant(peer, env);
1120
1297
  if (conductorPeer(config.roles) !== peer || config.conductor.feed === "off") return false;
1121
1298
  const key = env.refs.supervisionKey ?? "";
@@ -1139,7 +1316,7 @@ export async function startDaemon(opts: DaemonOptions) {
1139
1316
  const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
1140
1317
  const progress = new ProgressObserver({
1141
1318
  tasks: () => board.list(),
1142
- isPrivate: () => board.list().some(task => task.state !== "approved" && tasks.isPii(task)),
1319
+ isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
1143
1320
  inference: { escalate: (conversation, turn) => inference?.escalate(conversation, turn) ?? Promise.resolve(undefined) },
1144
1321
  emit: conductorProgressSink(event, supervision),
1145
1322
  notify,
@@ -1322,7 +1499,7 @@ export async function startDaemon(opts: DaemonOptions) {
1322
1499
  return undefined;
1323
1500
  };
1324
1501
  const activityRings = { kimi: new ActivityRing(), qwen: new ActivityRing() };
1325
- const contextPii = (peer: PeerId) => board.list().some(t => t.state !== "approved" && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
1502
+ const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
1326
1503
  const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
1327
1504
  // One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
1328
1505
  if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
@@ -1416,7 +1593,6 @@ export async function startDaemon(opts: DaemonOptions) {
1416
1593
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
1417
1594
  const runId = Date.now().toString(36);
1418
1595
  let turnSeq = 0;
1419
- const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
1420
1596
  const pendingDeliveryTask = new Map<PeerId, number>();
1421
1597
  const taskAttribution = (peer: PeerId, delivery = turns.get(peer)?.deliveryTask): TaskAttribution => {
1422
1598
  const result = attribute(delivery, board.list("in_progress").filter(t => t.owner === peer).map(t => t.id));
@@ -1444,11 +1620,11 @@ export async function startDaemon(opts: DaemonOptions) {
1444
1620
  event({ type: "supervision_turn", peer, turn: receipt.id, ...(receipt.tokens === undefined ? {} : { tokens: receipt.tokens }), ms: Math.max(0, Date.now() - receipt.at) });
1445
1621
  };
1446
1622
  // Per-turn snapshots (issue #33): a git tree at both turn boundaries, recorded in hub.db for `ahub turns` and `ahub undo`.
1447
- const repo = config.snapshots.enabled ? factsRepo : undefined;
1448
- const turnLog = repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
1449
- 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());
1450
1626
  /** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
1451
- const holdsPii = (peer: PeerId) => board.list().some((t) => t.owner === peer && t.state !== "approved" && t.state !== "in_review" && tasks.isPii(t));
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));
1452
1628
  const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
1453
1629
  if (turns.get(peer)?.private || contextPii(peer) || envs.some(env => env.private)) return;
1454
1630
  for (const env of envs) {
@@ -1460,7 +1636,7 @@ export async function startDaemon(opts: DaemonOptions) {
1460
1636
  if (row && tasks.isPii(row)) return;
1461
1637
  return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
1462
1638
  }) : undefined;
1463
- bus.onMemoryDelivery = (peer, id, out, field) => memoryObservations?.delivery(peer, id, out, field);
1639
+ bus.onMemoryDelivery = (peer, id, out, field) => { if (!out.some(env => env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
1464
1640
  const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
1465
1641
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
1466
1642
  memoryObservations?.cite(peer, text, id, envs);
@@ -1470,7 +1646,8 @@ export async function startDaemon(opts: DaemonOptions) {
1470
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
1471
1647
  * in snapshots.ts.
1472
1648
  */
1473
- 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 };
1474
1651
  const t0 = performance.now();
1475
1652
  let tree: string | undefined;
1476
1653
  try {
@@ -1482,11 +1659,11 @@ export async function startDaemon(opts: DaemonOptions) {
1482
1659
  return { tree, ms: Math.round(performance.now() - t0) };
1483
1660
  };
1484
1661
  function privateSnapshotWindow(): boolean {
1485
- return [...turns.values()].some(turn => turn.private) || board.list().some(task => task.state !== "approved" && tasks.isPii(task));
1662
+ return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1486
1663
  }
1487
1664
  function reviewProjection(task: Task): string | undefined {
1488
1665
  if (!projections.enabled || tasks.isPii(task)) return undefined;
1489
- 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)";
1490
1667
  if (privateSnapshotWindow()) return "(projected review unavailable: private snapshot window)";
1491
1668
  const created = taskProposalAt(task);
1492
1669
  const from = created === undefined ? undefined : turnLog.taskStart(task.id, created);
@@ -1525,6 +1702,7 @@ export async function startDaemon(opts: DaemonOptions) {
1525
1702
  const contextTailSeen = new Map<PeerId, string>();
1526
1703
  const intervals = [
1527
1704
  setInterval(() => {
1705
+ void refreshRunner().catch(() => {});
1528
1706
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
1529
1707
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
1530
1708
  for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
@@ -1587,10 +1765,21 @@ export async function startDaemon(opts: DaemonOptions) {
1587
1765
  }
1588
1766
  }, 5_000),
1589
1767
  ];
1590
- const taskSweep = taskSweepConfig(config.task_sweep);
1591
- if (taskSweep.enabled) intervals.push(setInterval(() => {
1592
- void tasks.sweep(opts.taskSweepNow?.() ?? Date.now()).catch(() => notify("task idle sweep failed; inspect hub task history before manual action"));
1593
- }, 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));
1594
1783
  for (const i of intervals) i.unref?.();
1595
1784
  startupCleanup.push(() => { for (const i of intervals) clearInterval(i); });
1596
1785
 
@@ -1607,7 +1796,7 @@ export async function startDaemon(opts: DaemonOptions) {
1607
1796
  // names are answered by the hub; every call still passes daemon role/capability checks before its effects.
1608
1797
  const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name), ...CONDUCTOR_TOOLS.map((t) => t.name)];
1609
1798
  const HUB_TOOL_TITLES = new Set(HUB_TOOLS.map((name) => `mcp__agent-hub__${name}`));
1610
- const toolEnv = (peer: PeerId) => ({ AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
1799
+ const toolEnv = (peer: PeerId, deliveryOnly = false) => ({ ...(deliveryOnly ? { AGENTHUB_DELIVERY_ONLY: "true" } : {}), AGENTHUB_MODE: "tools", AGENTHUB_PEER_ID: peer, AGENTHUB_STATE_DIR: opts.stateDir, AGENTHUB_PROJECT_DIR: opts.cwd });
1611
1800
 
1612
1801
  /** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
1613
1802
  // A task op can write the board across awaits (triage, briefs, the dependents an approval releases): a recovery
@@ -1627,11 +1816,21 @@ export async function startDaemon(opts: DaemonOptions) {
1627
1816
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
1628
1817
  auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
1629
1818
  }
1630
- return text;
1819
+ const hint = args[3] ? ownHeldHint(by) : "";
1820
+ return hint ? `${text}\n\n${hint}` : text;
1821
+ } catch (error) {
1822
+ // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
1823
+ const hint = args[3] ? ownHeldHint(by) : "";
1824
+ if (hint && error instanceof Error) error.message += `\n\n${hint}`;
1825
+ throw error;
1631
1826
  } finally {
1632
1827
  taskOpsInFlight--;
1633
1828
  }
1634
1829
  };
1830
+ function ownHeldHint(peer: PeerId): string {
1831
+ try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer) instanceof LocalPeer || bus.peers.get(peer) instanceof AcpPeer)); }
1832
+ catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
1833
+ }
1635
1834
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
1636
1835
  const peerArg = (v: unknown, name: string): PeerId | undefined => {
1637
1836
  if (v == null || v === "") return undefined;
@@ -1640,6 +1839,12 @@ export async function startDaemon(opts: DaemonOptions) {
1640
1839
  return id;
1641
1840
  };
1642
1841
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
1842
+ if (BOUNDED_TASK_TOOLS.has(op)) {
1843
+ const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
1844
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
1845
+ || (keys.includes("id") ? !Number.isSafeInteger(a.id) || a.id < 1 : typeof a.run !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(a.run))) throw new PreEffectToolRefusal("invalid bounded task request");
1846
+ if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
1847
+ }
1643
1848
  if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
1644
1849
  if (op === "hub_memory_correct") correctionInput(a);
1645
1850
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
@@ -1660,7 +1865,7 @@ export async function startDaemon(opts: DaemonOptions) {
1660
1865
  // console reads a PII task's text deliberately, with `ahub task show <id>`.
1661
1866
  const onPrem = inProcess && by === "local" && piiTurn;
1662
1867
  const line = (t: { id: number; state: string; owner: PeerId | null; reviewer: PeerId | null }) => { const task = board.get(t.id); return task && by === USER ? tasks.resultLine(task) : `task #${t.id}: ${t.state}, owner ${t.owner ?? "none"}, reviewer ${t.reviewer ?? "none"}`; };
1663
- const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct", what: string) => {
1868
+ const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct" | "spec_read" | "check_run", what: string) => {
1664
1869
  if (may(by, cap)) return;
1665
1870
  log(`capabilities: ${by} may not ${what} (${op})`);
1666
1871
  throw new PreEffectToolRefusal(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
@@ -1678,7 +1883,88 @@ export async function startDaemon(opts: DaemonOptions) {
1678
1883
  }
1679
1884
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
1680
1885
  if (op === "hub_remember") need("remember", "save notes to shared memory");
1886
+ if (op === "hub_task_spec") {
1887
+ need("spec_read", "read assigned specifications");
1888
+ const authorized = () => {
1889
+ const task = board.get(a.id);
1890
+ if (!task || task.state === "backlog" || task.owner !== by && task.reviewer !== by || tasks.isPii(task) || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("spec unavailable");
1891
+ return task;
1892
+ };
1893
+ const task = authorized(), generation = connectionGeneration(by), updated = task.updated, revision = task.history.length;
1894
+ const deadline = Date.now() + 25_000; // retain a response margin inside the control client's 30-second cap
1895
+ const issues = specRefs(task, memoryRepo);
1896
+ if (!issues.length) throw new PreEffectToolRefusal("no assigned spec references");
1897
+ await ensureSpec();
1898
+ authorized();
1899
+ if (!specState.available) throw new PreEffectToolRefusal(specState.reason ?? "gh unauthenticated");
1900
+ const specs = [];
1901
+ for (const issue of issues) {
1902
+ const remaining = deadline - Date.now();
1903
+ if (remaining <= 0) throw new PreEffectToolRefusal("timeout");
1904
+ specs.push(await (opts.boundedTools?.fetchSpec ?? fetchSpec)(memoryRepo, issue, { screen: boundedSafe, projectDir: opts.cwd, timeoutMs: Math.min(15_000, remaining) }));
1905
+ const current = authorized();
1906
+ if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "spec_read")) throw new PreEffectToolRefusal("spec unavailable");
1907
+ }
1908
+ const result = JSON.stringify({ specs });
1909
+ if (Buffer.byteLength(result) > 80 * 1024) throw new PreEffectToolRefusal("too large");
1910
+ if (!specs.every(spec => boundedSafe(`${spec.title}\n${spec.body}`))) throw new PreEffectToolRefusal("spec withheld by privacy policy");
1911
+ return result;
1912
+ }
1913
+ if (op === "hub_check_start") {
1914
+ need("check_run", "run isolated checks");
1915
+ if (stopping || recoveryActive()) throw new PreEffectToolRefusal("runner unavailable: recovery or shutdown");
1916
+ const authorized = () => {
1917
+ const task = board.get(a.id);
1918
+ if (!task || task.owner !== by || task.state !== "in_progress" || tasks.isPii(task) || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("check unavailable");
1919
+ return task;
1920
+ };
1921
+ const task = authorized(), updated = task.updated, revision = task.history.length, generation = connectionGeneration(by);
1922
+ if (!generation) throw new PreEffectToolRefusal("check requires an attached owner");
1923
+ if (a.check !== "test" && a.check !== "typecheck"
1924
+ || a.check === "test" && (!Array.isArray(a.files) || a.files.length < 1 || a.files.length > 32 || a.files.some((file: unknown) => typeof file !== "string" || !/^test\/[A-Za-z0-9._/-]+\.test\.ts$/.test(file) || file.split("/").some(part => !part || part === "." || part === "..")))
1925
+ || a.check === "typecheck" && a.files !== undefined) throw new PreEffectToolRefusal("invalid check request");
1926
+ await refreshRunner();
1927
+ const current = authorized();
1928
+ if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "check_run") || stopping || recoveryActive()) throw new PreEffectToolRefusal("check unavailable");
1929
+ if (!runnerState.available) throw new PreEffectToolRefusal(`runner unavailable: ${runnerState.reason ?? "unknown"}`);
1930
+ const ref = current.refs.commit ?? current.refs.branch;
1931
+ if (!ref) throw new PreEffectToolRefusal("check requires task branch or commit");
1932
+ const handle = checkRunner.start({ task: current.id, owner: by, generation, updated, revision: current.history.length, ref, check: a.check, ...(a.files === undefined ? {} : { files: a.files }) });
1933
+ checkPending.add(handle.run);
1934
+ void handle.done.finally(() => checkPending.delete(handle.run)).catch(() => {});
1935
+ return JSON.stringify({ run: handle.run });
1936
+ }
1937
+ if (op === "hub_check_result" || op === "hub_check_cancel") {
1938
+ need("check_run", "read or cancel isolated checks");
1939
+ let receipt = await checkRunner.result(a.run);
1940
+ const task = receipt ? board.get(receipt.task) : undefined;
1941
+ if (!receipt || by !== USER && (receipt.owner !== by || !task || tasks.isPii(task))) throw new PreEffectToolRefusal("check unavailable");
1942
+ if (receipt.status === "stale" && by !== USER) throw new PreEffectToolRefusal("check unavailable: stale binding");
1943
+ if (!currentCheck(receipt)) {
1944
+ await checkRunner.markStale(receipt.run); receipt = { ...receipt, status: "stale" };
1945
+ // markStale persists and can abort a run: a refusal after it is never proof of absent effects.
1946
+ if (by !== USER) throw new Error("check unavailable: stale binding");
1947
+ }
1948
+ if (op === "hub_check_cancel") {
1949
+ await checkRunner.cancel(receipt.run);
1950
+ const actual = await checkRunner.result(receipt.run);
1951
+ if (!actual) throw new Error("check receipt unavailable after cancellation");
1952
+ return JSON.stringify({ run: actual.run, status: actual.status, cancelled: actual.cancelled });
1953
+ }
1954
+ if (by !== USER && !currentCheck(receipt)) throw new PreEffectToolRefusal("check unavailable: stale binding");
1955
+ if (by === receipt.owner) await checkRunner.consumeOutput(receipt.run);
1956
+ if (by !== USER && (!currentCheck(receipt) || turns.get(by)?.private || contextPii(by))) throw new Error("check unavailable: stale binding");
1957
+ // Policy can change during output cleanup, so form the final projection again.
1958
+ return JSON.stringify(publicCheck(receipt));
1959
+ }
1681
1960
  switch (op) {
1961
+ case "hub_delivery_retry": {
1962
+ if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
1963
+ if (bus.peers.get(by) instanceof LocalPeer || bus.peers.get(by) instanceof AcpPeer) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
1964
+ if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
1965
+ bus.retryDelivery(by, a.delivery_id);
1966
+ return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
1967
+ }
1682
1968
  case "hub_backlog_list":
1683
1969
  return JSON.stringify(tasks.backlogList(by));
1684
1970
  case "hub_release_list":
@@ -1710,6 +1996,14 @@ export async function startDaemon(opts: DaemonOptions) {
1710
1996
  if (silent && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
1711
1997
  return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
1712
1998
  }
1999
+ case "hub_task_cancel": {
2000
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
2001
+ || !Number.isSafeInteger(a.id) || a.id < 1 || typeof a.reason !== "string" || !a.reason.trim() || a.reason.length > 2000
2002
+ || a.superseded_by !== undefined && (typeof a.superseded_by !== "string" || !a.superseded_by.trim() || a.superseded_by.length > 2000)
2003
+ || a.revision !== undefined && (!Number.isSafeInteger(a.revision) || a.revision < 0)) throw new PreEffectToolRefusal("invalid cancellation request");
2004
+ if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("cancellation unavailable during a PII turn");
2005
+ return JSON.stringify(tasks.publicView(await tasks.cancel(a.id, by, { reason: a.reason, supersededBy: a.superseded_by, expectedFlowRevision: a.revision })));
2006
+ }
1713
2007
  case "hub_task_decline":
1714
2008
  return line(await tasks.decline(by, a.id, a.reason));
1715
2009
  case "hub_task_done": {
@@ -1814,8 +2108,13 @@ export async function startDaemon(opts: DaemonOptions) {
1814
2108
  }
1815
2109
  case "task_escalate":
1816
2110
  return line(await tasks.escalate(USER, a.id, undefined, undefined, true));
1817
- case "route_explain":
1818
- return tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
2111
+ case "route_explain": {
2112
+ const trace = tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass });
2113
+ return [...trace, ...bus.knownPeers().flatMap(peer => {
2114
+ if (trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`))) return [];
2115
+ const hold = queueHold(peer); return hold ? [`${peer} queue: ${hold}`] : [];
2116
+ })].join("\n");
2117
+ }
1819
2118
  case "turn_revert": {
1820
2119
  // `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
1821
2120
  const turn = turnLog?.get(String(a.turn));
@@ -1840,7 +2139,7 @@ export async function startDaemon(opts: DaemonOptions) {
1840
2139
  }
1841
2140
  function recoveryTaskPreface(peer: PeerId): void {
1842
2141
  if (recoveryPhase !== "restored") return;
1843
- const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !["approved"].includes(task.state));
2142
+ const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !isClosedTaskState(task.state));
1844
2143
  if (!open.length) return;
1845
2144
  const lines = open.map((task) => {
1846
2145
  const view = tasks.publicView(task) as { title?: unknown; detail?: unknown; state?: unknown; owner?: unknown; reviewer?: unknown };
@@ -1854,6 +2153,20 @@ export async function startDaemon(opts: DaemonOptions) {
1854
2153
  const permissionChanges = new Map<string, Promise<void>>();
1855
2154
  const permissionModes = new Map<string, PermissionMode>(Object.entries(config.permission_modes).map(([peer, mode]) => [peer, mode === "never-ask" ? "ask" : mode]));
1856
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
+ };
1857
2170
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
1858
2171
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
1859
2172
  const unattendedPeers = new Set<string>();
@@ -1875,6 +2188,7 @@ export async function startDaemon(opts: DaemonOptions) {
1875
2188
  let releasing = false; // gone-owner release (#6) and the ready sweep (#34): one run at a time, and a recovery commit waits for it
1876
2189
  const recoveryReady = () => {
1877
2190
  if (!recoveryActive() || releasing || taskOpsInFlight !== 0 || tasks.checksPending() !== 0 || (piReceipts?.inFlight ?? 0) !== 0 || permissions.size !== 0 || starting.size !== 0 || requestedPeerStops.size !== 0 || rosterChanging !== undefined || !budget.recoverySettled || [...bus.peers.values()].some((peer) => peer.state === "busy" || (peer instanceof PiPeer && !peer.recoveryReady))) return false;
2191
+ if (checkPending.size !== 0) return false;
1878
2192
  if (!recoveryPeerSnapshot) return true;
1879
2193
  const current = recoveryPeers();
1880
2194
  return recoveryPeerSnapshot.every((saved) => {
@@ -2029,7 +2343,7 @@ export async function startDaemon(opts: DaemonOptions) {
2029
2343
  }
2030
2344
  return now;
2031
2345
  };
2032
- const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer) } : {};
2346
+ const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer), heldRows: heldRows(peer) } : {};
2033
2347
  const logPermissionStart = (peer: string) => {
2034
2348
  const requested = defaultSources[peer];
2035
2349
  const selected = permissionMode(peer), pending = pendingPermissionDefaults.has(peer);
@@ -2062,15 +2376,19 @@ export async function startDaemon(opts: DaemonOptions) {
2062
2376
  };
2063
2377
  const status = (includeAttentionItems = false, includeAttentionInput = false) => {
2064
2378
  const taskNeeds = board.statusTasks();
2379
+ const taskStalls = tasks.stalled();
2380
+ const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
2065
2381
  const peerIds = statusPeerIds(taskNeeds);
2066
2382
  const view = {
2067
2383
  ...(crashReport.length ? { crash: crashReport } : {}),
2068
2384
  projectId,
2069
2385
  instanceId,
2070
2386
  version: VERSION,
2387
+ pendingRestart,
2071
2388
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2072
2389
  lifecycleStages: [...lifecycleConfig.stages],
2073
2390
  protocol: PROTOCOL,
2391
+ features: DAEMON_FEATURES,
2074
2392
  stopping,
2075
2393
  pid: process.pid,
2076
2394
  cwd: opts.cwd,
@@ -2082,11 +2400,15 @@ export async function startDaemon(opts: DaemonOptions) {
2082
2400
  ),
2083
2401
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2084
2402
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2085
- roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id) }])),
2403
+ roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2404
+ planner: { peers: plannerPeers(), source: plannerSource },
2086
2405
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
2087
2406
  ...(sidecar ? { switchyard: sidecar.status } : {}),
2088
2407
  ...(modelRelay ? { models: modelRelay.status() } : {}),
2408
+ isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
2409
+ taskCapabilities: peerCapabilities(),
2089
2410
  tasks: board.counts(),
2411
+ taskStalls, pendingTaskOffers,
2090
2412
  ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
2091
2413
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
2092
2414
  };
@@ -2103,6 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
2103
2425
  storageBlocked: !!bus.storageError,
2104
2426
  recovery: recoveryOperationId ? { id: recoveryOperationId, running: true } : undefined,
2105
2427
  tasks: taskNeeds,
2428
+ facts: [...taskStalls.map(stall => ({ kind: "stall" as const, id: stall.task })), ...pendingTaskOffers.map(offer => ({ kind: "offer" as const, id: offer.task, peer: offer.peer }))],
2106
2429
  budget: Object.fromEntries(Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}])),
2107
2430
  };
2108
2431
  const attention = needs(attentionInput, Date.now());
@@ -2122,37 +2445,58 @@ export async function startDaemon(opts: DaemonOptions) {
2122
2445
  const taskChanged = board.onChange;
2123
2446
  board.onChange = (task, entry) => {
2124
2447
  try { taskChanged?.(task, entry); }
2125
- finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
2448
+ finally {
2449
+ try { settleSupersededDeliveries(); }
2450
+ finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
2451
+ }
2126
2452
  };
2127
2453
 
2128
2454
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
2129
2455
  // message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
2130
- function movedDeliveryTasks(row: JournalDelivery): string[] {
2131
- return [...new Set(row.originals.flatMap((env) => {
2132
- const task = env.refs?.task ? board.get(Number(env.refs.task)) : undefined;
2133
- if (!task || (task.owner === row.peer && task.state !== "approved")) return [];
2134
- return [`task ${tasks.publicTitle(task)} ${task.state === "approved" ? "has been approved" : `has moved to ${task.owner ?? "nobody"}`}`];
2135
- }))];
2456
+ function heldRows(peer: PeerId) {
2457
+ return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
2458
+ id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
2459
+ kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
2460
+ context: deliveryTaskContext(row.originals, board, peer),
2461
+ }));
2136
2462
  }
2137
2463
  function queueHold(peer: PeerId): string | undefined {
2138
- const id = bus.queueSummary(peer).heldBy;
2139
- const row = id ? bus.queueShow(id) : undefined;
2140
- if (!row) return undefined;
2141
- const moved = movedDeliveryTasks(row);
2142
- return `held by needs_review ${id}${moved.length ? ` (${moved.join("; ")})` : ""}; ahub queue resolve ${id} --action completed|retry|discard --reason <text>`;
2464
+ const rows = heldRows(peer);
2465
+ if (!rows.length) return undefined;
2466
+ return rows.map(row => `held by needs_review ${row.id} (from ${row.senders.join(",")}; kind ${row.kinds.join(",")}; age ${Math.max(0, Math.floor((Date.now() - row.createdAt) / 1000))}s${row.context ? `; ${row.context}` : ""}); ahub queue resolve ${row.id} --action completed|retry|discard --reason <text>`).join("; ");
2467
+ }
2468
+ let settlingSuperseded = false;
2469
+ function settleSupersededDeliveries() {
2470
+ if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
2471
+ settlingSuperseded = true;
2472
+ try {
2473
+ for (const row of bus.queueList()) {
2474
+ if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
2475
+ const reason = supersededDeliveryReason(row.originals, board, row.peer);
2476
+ if (!reason) continue;
2477
+ try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
2478
+ catch { /* Busy peers, storage failures and changed revisions remain held for the next queue/board change. */ }
2479
+ }
2480
+ } finally { settlingSuperseded = false; }
2143
2481
  }
2144
2482
  const noticedHolds = new Set<string>();
2483
+ let reportingQueues = false;
2145
2484
  bus.onQueues = () => {
2146
- if (stopping) return;
2147
- for (const row of bus.queueList()) {
2148
- if (row.state !== "needs_review" || noticedHolds.has(row.id)) continue;
2149
- noticedHolds.add(row.id);
2150
- supervision.needsReview(row.peer, row.id);
2151
- const moved = movedDeliveryTasks(row);
2152
- notify(`${row.peer} queue held by needs_review ${row.id}${moved.length ? ` (${[...new Set(moved)].join("; ")})` : ""}; inspect: ahub queue show ${row.id}; resolve: ahub queue resolve ${row.id} --action completed|retry|discard --reason <text>`);
2153
- }
2154
- writeStatus();
2485
+ if (stopping || settlingSuperseded || reportingQueues) return;
2486
+ reportingQueues = true;
2487
+ try {
2488
+ settleSupersededDeliveries();
2489
+ for (const peer of bus.knownPeers()) {
2490
+ const rows = heldRows(peer);
2491
+ if (!rows.some(row => !noticedHolds.has(row.id))) continue;
2492
+ for (const row of rows) { noticedHolds.add(row.id); supervision.needsReview(peer, row.id); }
2493
+ notify(`${peer} queue ${queueHold(peer)}`);
2494
+ }
2495
+ writeStatus();
2496
+ } finally { reportingQueues = false; }
2155
2497
  };
2498
+ // Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
2499
+ settleSupersededDeliveries();
2156
2500
 
2157
2501
  // When each peer went offline; a peer never seen attached counts from hub start (issue #6).
2158
2502
  const offlineSince = new Map<PeerId, number>();
@@ -2266,17 +2610,57 @@ export async function startDaemon(opts: DaemonOptions) {
2266
2610
 
2267
2611
  // Session identities of the attached peers, kept current for crash recovery (issue #37); a clean stop removes them.
2268
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
+ }
2269
2645
  const recordSessions = () => {
2270
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
+ }
2271
2651
  const peers = [...bus.peers.values()].filter((p) => p.state !== "offline").map((p) => {
2272
2652
  let meta: Record<string, unknown> = {};
2273
2653
  try { meta = (p as { recoveryMetadata?: () => Record<string, unknown> }).recoveryMetadata?.() ?? {}; } catch { /* not ready yet: the id alone */ }
2274
2654
  return { peer: p.id, meta };
2275
2655
  });
2276
2656
  const text = JSON.stringify(peers);
2277
- if (text === sessionsWritten) return;
2278
- sessionsWritten = text;
2279
- 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}`); }
2280
2664
  };
2281
2665
  const recoverAfterCrash = async (prev: SessionsFile) => {
2282
2666
  const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
@@ -2284,6 +2668,7 @@ export async function startDaemon(opts: DaemonOptions) {
2284
2668
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
2285
2669
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
2286
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; }
2287
2672
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
2288
2673
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
2289
2674
  if (step.peer === "pi" && piAutoStart()) {
@@ -2299,7 +2684,7 @@ export async function startDaemon(opts: DaemonOptions) {
2299
2684
  report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
2300
2685
  continue;
2301
2686
  }
2302
- if (!step.resume || !autoResume) {
2687
+ if (!step.resume || !autoResume()) {
2303
2688
  report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
2304
2689
  continue;
2305
2690
  }
@@ -2324,7 +2709,7 @@ export async function startDaemon(opts: DaemonOptions) {
2324
2709
  // #248: the add-ons it works with, once per attach (names and versions only).
2325
2710
  // A result is written unless a later attachment began meanwhile: a peer that detached before its probe settled
2326
2711
  // still did its work under that attachment.
2327
- if (e.state === "offline") attachedNow.delete(e.peer);
2712
+ if (e.state === "offline") { attachedNow.delete(e.peer); applyPendingPermissionDefault(e.peer); }
2328
2713
  else if (!attachedNow.has(e.peer)) {
2329
2714
  attachedNow.add(e.peer);
2330
2715
  const peer = e.peer, attachment = ++attachments;
@@ -2345,20 +2730,21 @@ export async function startDaemon(opts: DaemonOptions) {
2345
2730
  const pii = holdsPii(e.peer);
2346
2731
  for (const id of turnTasks.keys()) if (!turnLog?.get(id)) turnTasks.delete(id);
2347
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));
2348
- const start = turnLog && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
2349
- 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}`); }
2350
2736
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
2351
- 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 } : {}) });
2352
2738
  event({ type: "turn_start", peer: e.peer, turn: id });
2353
2739
  } else if (!busy && open) {
2354
2740
  turns.delete(e.peer);
2355
2741
  let afterTurn: (() => void) | undefined;
2356
2742
  let files: number | undefined;
2357
2743
  let snapshotMs = open.snapshotMs;
2358
- if (turnLog && (open.private || holdsPii(e.peer))) {
2744
+ if (open.capture && turnLog && (open.private || holdsPii(e.peer))) {
2359
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}`); }
2360
- } else if (turnLog) {
2361
- const end = snap(`the end of ${open.id}`);
2746
+ } else if (open.capture && turnLog) {
2747
+ const end = snap(`the end of ${open.id}`, true);
2362
2748
  const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
2363
2749
  if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
2364
2750
  snapshotMs = (snapshotMs ?? 0) + end.ms;
@@ -2523,7 +2909,7 @@ export async function startDaemon(opts: DaemonOptions) {
2523
2909
  /** pi.auto_start at hub start (#269): in Pi's start mode, and said out loud when it could not be done. */
2524
2910
  const autoStartPi = () => void hubStart("pi", "pi.auto_start").then(
2525
2911
  (result) => { if (result.ok !== true) notify(`pi.auto_start did not start Pi: ${String(result.error)}`); },
2526
- (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()}` : ""}`));
2527
2913
  async function stopPiForReplacement(pi: PiPeer): Promise<void> {
2528
2914
  replacingPi.add(pi);
2529
2915
  try { await pi.stop(); } finally { replacingPi.delete(pi); }
@@ -2544,6 +2930,7 @@ export async function startDaemon(opts: DaemonOptions) {
2544
2930
  const running = startPeerOnce(peer, args).then(async result => {
2545
2931
  if (!result.ok && peer === "codex") unattendedPeers.delete(peer);
2546
2932
  if (!result.ok || result.already) return result;
2933
+ if (peer === "pi") recordSessions();
2547
2934
  return permissionChange(peer, async () => {
2548
2935
  if (peer === "codex") { if (args.unattended) unattendedPeers.add(peer); else unattendedPeers.delete(peer); }
2549
2936
  logPermissionStart(peer); writeStatus(); return result;
@@ -2572,6 +2959,8 @@ export async function startDaemon(opts: DaemonOptions) {
2572
2959
  const mode = startMode(peer), owner = bus.peers.get(peer), can = mode === "tui" ? opener.available() : undefined;
2573
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 }) : {}) };
2574
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`;
2575
2964
  async function hubStart(peer: unknown, by: string): Promise<Record<string, unknown>> {
2576
2965
  if (stopping) return { ok: false, error: "hub is stopping" };
2577
2966
  if (rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
@@ -2579,7 +2968,32 @@ export async function startDaemon(opts: DaemonOptions) {
2579
2968
  if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
2580
2969
  if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
2581
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;
2582
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
+ }
2996
+ if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
2583
2997
  const owner = bus.peers.get(peer);
2584
2998
  if (mode === "headless") {
2585
2999
  // A Pi a person runs in a terminal is theirs: a start made for them never takes it over as a headless one.
@@ -2587,23 +3001,26 @@ export async function startDaemon(opts: DaemonOptions) {
2587
3001
  // like one whose terminal was closed, so a headless start in that window replaces it; record when a launch was
2588
3002
  // issued to tell the two apart.
2589
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" };
2590
- return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless" } : {});
3004
+ return startPeer(peer, peer === "pi" || peer === "kimi" ? { mode: "headless", ...(resume ?? {}) } : {});
2591
3005
  }
2592
3006
  if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
2593
- 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(" ")}`;
2594
3010
  const since = Date.now() - (opening.get(peer) ?? 0);
2595
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 };
2596
3012
  // Reserved before the provider is awaited, so starts that arrive together open one terminal, not one each.
2597
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" }; }
2598
3015
  // Every word is the hub's own: its runtime, its entry point, this project and a peer from the closed list.
2599
- 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 }));
2600
3017
  if (!opened.ok) {
2601
3018
  opening.delete(peer); // nothing was opened: the next request may try again at once
2602
3019
  if (opened.detail) log(`terminal provider for ${peer}: ${opened.detail}`);
2603
3020
  const headless = peer === "claude" ? "" : `, or set its start mode to headless (ahub settings set peers.${peer}.start_mode headless)`;
2604
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 };
2605
3022
  }
2606
- 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 */ }
2607
3024
  return { ok: true, mode, opened: opened.via, command };
2608
3025
  }
2609
3026
 
@@ -2662,6 +3079,8 @@ export async function startDaemon(opts: DaemonOptions) {
2662
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>> {
2663
3080
  let unmute: (() => void) | undefined;
2664
3081
  try {
3082
+ applyPendingPermissionDefault(peer);
3083
+ if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
2665
3084
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
2666
3085
  } finally {
2667
3086
  unmute?.();
@@ -2669,8 +3088,12 @@ export async function startDaemon(opts: DaemonOptions) {
2669
3088
  }
2670
3089
 
2671
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);
2672
3092
  if (peer === "jcode" && args.mode !== undefined && args.mode !== "headless") return { ok: false, error: "Jcode native TUI driving is unverified; use headless" };
2673
- 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
+ }
2674
3097
  if (peer === "kimi") {
2675
3098
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Kimi mode" };
2676
3099
  const owner = bus.peers.get(peer);
@@ -2757,32 +3180,36 @@ export async function startDaemon(opts: DaemonOptions) {
2757
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)"); }
2758
3181
  return { ok: true, policy: "unmanaged" };
2759
3182
  }
2760
- if (peer === "qwen") {
2761
- if (args.mode === "tui") return { ok: false, error: "Qwen TUI is not offered before its native verification; use ahub qwen --mode headless" };
2762
- const launch = buildQwenLaunch(config.qwen_cmd, args.model);
2763
- const qwen = new AcpPeer("qwen", {
2764
- onActivity: item => activityRings.qwen.append(item),
2765
- isPrivateTurn: () => !!turns.get("qwen")?.private || contextPii("qwen"),
2766
- 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,
2767
3193
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
2768
3194
  cwd: opts.cwd, watchdogMs: config.watchdog_ms, onPermission,
2769
3195
  autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
2770
3196
  log: () => log("Qwen runtime diagnostic: details withheld"),
2771
3197
  onUsageDiagnostic: (observation, sessionId) => {
2772
3198
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
2773
- const current = contextSession("qwen"); if (!current) return;
2774
- 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
2775
3201
  ? acpContext({ used: observation.contextUsed, size: observation.contextCapacity }, sessionId, Date.now())
2776
3202
  : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
2777
3203
  },
2778
- onTurnFailure: () => { supervisionTurns.delete("qwen"); },
2779
- mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("qwen")).map(([name, value]) => ({ name, value })) }],
2780
- preamble: sessionRoleContract("qwen"),
2781
- });
2782
- recoveryTaskPreface("qwen"); await ensurePreface("qwen");
2783
- 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); }
2784
3211
  catch (error) {
2785
- 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"));
2786
3213
  if (error instanceof QwenIdentityError) throw error;
2787
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)");
2788
3215
  }
@@ -2828,7 +3255,7 @@ export async function startDaemon(opts: DaemonOptions) {
2828
3255
  current);
2829
3256
  },
2830
3257
  onTurnFailure: () => { supervisionTurns.delete("kimi"); },
2831
- mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi")).map(([name, value]) => ({ name, value })) }],
3258
+ mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi", true)).map(([name, value]) => ({ name, value })) }],
2832
3259
  preamble: sessionRoleContract("kimi"),
2833
3260
  });
2834
3261
  recoveryTaskPreface("kimi");
@@ -2916,11 +3343,11 @@ export async function startDaemon(opts: DaemonOptions) {
2916
3343
  if (!["headless", "tui"].includes(mode) || !["auto", "dgx", "mlx"].includes(backend)) return { ok: false, error: "invalid Pi mode/backend" };
2917
3344
  const problem = mlxLaunchProblem(config, { backend, model: args.model });
2918
3345
  if (problem) return { ok: false, error: problem };
2919
- 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,
2920
3347
  routeSessionKey: () => { const session = bus.peers.get("pi")?.recoveryMetadata?.().sessionId; return typeof session === "string" ? session : undefined; },
2921
- staySwitch: () => currentRouting(opts.cwd, log),
3348
+ staySwitch: () => activeRouting(),
2922
3349
  onRoute: record => event({ type: "route", peer: "pi", ...record }),
2923
- efficientWaitMs: currentRouting(opts.cwd, log).pi.efficient_wait_ms,
3350
+ efficientWaitMs: activeRouting().pi.efficient_wait_ms,
2924
3351
  underBudget: () => { const current = bus.peers.get("pi"); return current instanceof PiPeer && tasks.budgetApplies(current.budgetEnvelopes, "pi"); },
2925
3352
  onCooldown: record => {
2926
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"}`);
@@ -2960,7 +3387,7 @@ export async function startDaemon(opts: DaemonOptions) {
2960
3387
  return typeof refused === "string" ? `not sent: ${refused}` : "sent";
2961
3388
  },
2962
3389
  };
2963
- const routing = currentRouting(opts.cwd, log);
3390
+ const routing = activeRouting();
2964
3391
  const pi = new PiPeer("pi", {
2965
3392
  cwd: opts.cwd, stateDir: opts.stateDir, cmd: config.pi.cmd, mode, backend,
2966
3393
  model: args.model,
@@ -3008,7 +3435,7 @@ export async function startDaemon(opts: DaemonOptions) {
3008
3435
  if (backend !== "auto") return backend === "mlx" ? "mlx/fast" : "dgx/coding";
3009
3436
  const taskId = envs.find(env => env.refs?.task)?.refs?.task;
3010
3437
  const task = taskId ? board.get(Number(taskId)) : undefined;
3011
- const policyBackend = task ? currentRouting(opts.cwd, log).classes[task.class]?.pi_backend : undefined;
3438
+ const policyBackend = task ? activeRouting().classes[task.class]?.pi_backend : undefined;
3012
3439
  if (policyBackend === "mlx") {
3013
3440
  if (config.mlx.enabled !== false) return "mlx/fast";
3014
3441
  const problem = disabledMlxPolicyProblem(config, opts.cwd);
@@ -3076,7 +3503,7 @@ export async function startDaemon(opts: DaemonOptions) {
3076
3503
  return { ok: true, ...(mode === "tui" ? { launch: pi.tuiLaunch } : {}) };
3077
3504
  }
3078
3505
  if (peer === "local") {
3079
- const routing = currentRouting(opts.cwd, log);
3506
+ const routing = activeRouting();
3080
3507
  // `--model` pins a model on OmniRoute and skips L2; `--route` picks another Switchyard route.
3081
3508
  const route = args.model ? undefined : (args.route ?? routing.local.route);
3082
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}"` };
@@ -3123,8 +3550,8 @@ export async function startDaemon(opts: DaemonOptions) {
3123
3550
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, "local", unit),
3124
3551
  ...(route ? { route } : {}),
3125
3552
  ...(sidecar && route && !route.startsWith("hub/") ? { sidecar } : {}),
3126
- hubRoutes: () => currentRouting(opts.cwd, log).hub_routes ?? {},
3127
- staySwitch: () => currentRouting(opts.cwd, log),
3553
+ hubRoutes: () => activeRouting().hub_routes ?? {},
3554
+ staySwitch: () => activeRouting(),
3128
3555
  onRoute: record => event({ type: "route", peer: "local", ...record }),
3129
3556
  onAdvisor: record => event({ type: "advisor", peer: "local", ...record }),
3130
3557
  onRouteOutcome: record => event({ type: "route_outcome", peer: "local", ...record }),
@@ -3167,7 +3594,7 @@ export async function startDaemon(opts: DaemonOptions) {
3167
3594
  }
3168
3595
 
3169
3596
  async function validateLocalChoice(route: string | undefined, fixedModel: string): Promise<void> {
3170
- const routing = currentRouting(opts.cwd, log);
3597
+ const routing = activeRouting();
3171
3598
  if (route && !(route.startsWith("hub/") ? routing.hub_routes?.[route] : routing.routes[route])) throw new Error(`routing.toml has no route "${route}"`);
3172
3599
  const models = await omni.models();
3173
3600
  const required = new Set([fixedModel]);
@@ -3351,7 +3778,7 @@ export async function startDaemon(opts: DaemonOptions) {
3351
3778
  instanceId,
3352
3779
  attention: attentionStatus.attentionInput,
3353
3780
  pet,
3354
- status: { peers: peerView, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
3781
+ status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
3355
3782
  boardCapabilities: { backlog: true },
3356
3783
  planningCapabilities: { enabled: true, wipPull: true, split: true },
3357
3784
  releases: tasks.releaseList(USER),
@@ -3364,7 +3791,7 @@ export async function startDaemon(opts: DaemonOptions) {
3364
3791
  tasks: boardTasks.map((task) => {
3365
3792
  const view = tasks.publicView(task);
3366
3793
  // Refs and history can themselves quote private text. The dashboard needs neither.
3367
- return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
3794
+ return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
3368
3795
  }),
3369
3796
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
3370
3797
  budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[id] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
@@ -3386,6 +3813,237 @@ export async function startDaemon(opts: DaemonOptions) {
3386
3813
  };
3387
3814
  }
3388
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
+
3389
4047
  // #269: settings. One registry (src/hub/settings.ts) serves the dashboard and `ahub settings`; nothing outside it is written.
3390
4048
  let storedConfigCache: { stamp: string; config: HubConfig | undefined } | undefined;
3391
4049
  /** What the config files hold now, as the loader reads them. Re-read only when a file changed: the dashboard polls. */
@@ -3398,16 +4056,6 @@ export async function startDaemon(opts: DaemonOptions) {
3398
4056
  }
3399
4057
  return storedConfigCache.config;
3400
4058
  };
3401
- /** Stored start modes and the scoped instruction policy follow a settings write or undo. */
3402
- const applyStartModes = () => { const stored = storedConfig(); if (stored) { config.peers = stored.peers; config.protect_instruction_files = stored.protect_instruction_files; } };
3403
- /** Apply only the named peer; unrelated hand edits retain their prior publication scope. */
3404
- const applyRoleSelection = (peer: string) => {
3405
- const stored = storedConfig(); if (!stored) return;
3406
- config.catalog_roles = { ...config.catalog_roles };
3407
- const ids = catalogAssignments(stored.catalog_roles)[peer];
3408
- if (ids === undefined) delete config.catalog_roles[peer]; else config.catalog_roles[peer] = ids;
3409
- roleAssignments[peer] = ids ?? resolvedProfile.catalog_roles[peer] ?? [];
3410
- };
3411
4059
  const settingScalar = (value: unknown): SettingValue => typeof value === "string" || typeof value === "boolean" || typeof value === "number" ? value : Array.isArray(value) ? value.map(String) : null;
3412
4060
  const routingValue = (routing: ReturnType<typeof currentRouting>, def: SettingDef): SettingValue =>
3413
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]!]);
@@ -3417,14 +4065,14 @@ export async function startDaemon(opts: DaemonOptions) {
3417
4065
  try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
3418
4066
  catch { return {}; }
3419
4067
  }
3420
- function settingsView(): { rows: SettingRow[]; undo?: string } {
4068
+ function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
3421
4069
  const stored = storedConfig();
3422
4070
  // What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
3423
4071
  // merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
3424
4072
  let routingProblem: string | undefined;
3425
4073
  try { parseRouting(routingText(opts.cwd), readOverlay(opts.cwd)); } catch (error) { routingProblem = (error as Error).message; }
3426
4074
  const rows = settingRows({
3427
- 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 } : {}),
3428
4076
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
3429
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,
3430
4078
  roles: roleState,
@@ -3435,16 +4083,17 @@ export async function startDaemon(opts: DaemonOptions) {
3435
4083
  },
3436
4084
  });
3437
4085
  const undo = pendingUndo(opts.stateDir);
3438
- return { rows, ...(undo ? { undo } : {}) };
4086
+ const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
4087
+ return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
3439
4088
  }
3440
4089
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
3441
- const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
3442
- 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 } : {}) });
3443
4092
  try { notify(`setting ${key}: ${settingText(from)} -> ${settingText(to)} (${undo ? "undo, " : ""}${source})`); } catch { /* the write stands */ }
3444
4093
  };
3445
4094
  const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
3446
4095
  function rosterView() {
3447
- const open = board.list().filter(task => task.state !== "approved");
4096
+ const open = board.list().filter(task => !isClosedTaskState(task.state));
3448
4097
  return [...KNOWN_PEERS, ...OPTIONAL_PEERS.filter(peer => config.roster?.[peer] !== undefined)].map(peer => ({ peer, on: onRoster(peer), startMode: startMode(peer), state: bus.stateOf(peer), queued: bus.queued(peer), tasks: open.filter(task => task.owner === peer || task.reviewer === peer).length }));
3449
4098
  }
3450
4099
  function rosterCandidate(def: SettingDef, value: SettingValue, undo = false): PeerRoster {
@@ -3475,7 +4124,7 @@ export async function startDaemon(opts: DaemonOptions) {
3475
4124
  ? `Stop ${peer}'s existing work in its retained terminal; removal detaches the hub connection and does not stop that terminal's work.`
3476
4125
  : undefined;
3477
4126
  }
3478
- 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>> {
3479
4128
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3480
4129
  const peer = def.path[1]!;
3481
4130
  let saved = false;
@@ -3490,12 +4139,13 @@ export async function startDaemon(opts: DaemonOptions) {
3490
4139
  }
3491
4140
  if (stopping || recoveryActive()) return { ok: false, error: "recovery or shutdown is holding roster changes" };
3492
4141
  rosterChanging = peer;
3493
- if (undo) undoSetting(opts.cwd, opts.stateDir, (scratch) => loadConfig(scratch), routingCheck);
3494
- 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}`));
3495
4144
  // Apply exactly the authorized key. Other file edits stay pending until the next hub start.
3496
4145
  config.roster = next;
4146
+ settlePlanner(); // #455: an automatic planner follows the roster
3497
4147
  saved = true;
3498
- recordSetting(def.key, before, after, source, undo);
4148
+ recordSetting(def.key, before, after, source, undo, reason);
3499
4149
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
3500
4150
  if (!after) {
3501
4151
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
@@ -3517,30 +4167,40 @@ export async function startDaemon(opts: DaemonOptions) {
3517
4167
  const owner = bus.peers.get(peer);
3518
4168
  if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
3519
4169
  }
4170
+ const reloaded = await reloadConfig(authority, `roster.${peer}`);
4171
+ if (reloaded.ok !== true) return { ...reloaded, saved: true };
3520
4172
  writeStatus();
3521
- const stayed = !after ? board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)) : [];
4173
+ const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
3522
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 } : {}) };
3523
4175
  } catch (error) {
3524
4176
  if (saved) writeStatus();
3525
4177
  return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
3526
- } 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?.(); } } }
3527
4179
  }
3528
4180
  /** A checked definition and value, or the refusal. Every settings write and preview starts here. */
3529
4181
  function settingInput(key: unknown, raw: unknown): { def: SettingDef; value: SettingValue } | { error: string } {
3530
4182
  const def = settingDef(key);
3531
4183
  if (typeof def === "string") return { error: def };
3532
- const routing = currentRouting(opts.cwd, log);
4184
+ const routing = activeRouting();
3533
4185
  try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : [...Object.keys(routing.routes), ...Object.keys(routing.hub_routes ?? {})]) }; }
3534
4186
  catch (error) { return { error: (error as Error).message }; }
3535
4187
  }
3536
- 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 }; }
3537
4197
  const input = settingInput(key, raw);
3538
4198
  if ("error" in input) return { ok: false, error: input.error };
3539
4199
  const { def, value } = input;
3540
4200
  const refusal = settingRefusal(def, value, authority);
3541
4201
  if (refusal) return { ok: false, error: refusal };
3542
4202
  const source = settingSource(authority);
3543
- 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);
3544
4204
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3545
4205
  const peer = def.group === "Permissions" ? def.path.at(-1)! : undefined;
3546
4206
  // The console asks for y before never-ask; here the person types the peer it is for.
@@ -3551,53 +4211,63 @@ export async function startDaemon(opts: DaemonOptions) {
3551
4211
  const from = permissionMode(peer!);
3552
4212
  const result = await permissionChange(peer!, () => changePermission(peer!, value, value === "never-ask"));
3553
4213
  if (result.ok !== true) return result;
3554
- recordSetting(def.key, from, permissionMode(peer!), source);
4214
+ recordSetting(def.key, from, permissionMode(peer!), source, false, reason);
3555
4215
  return { ok: true, text: `${def.key}: ${result.permissionMode}`, ...(typeof result.note === "string" ? { note: result.note } : {}) };
3556
4216
  }
4217
+ let saved = false;
3557
4218
  try {
3558
4219
  if (def.store === "routing") {
3559
- const from = routingValue(currentRouting(opts.cwd, log), def);
3560
- 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));
3561
4222
  if (!next) return { ok: true, text: `${def.key}: ${OVERLAY_FILE} already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
3562
- recordSetting(def.key, from, routingValue(next, def), source);
3563
- 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)` };
3564
4228
  }
3565
- // Removing the machine-local value uncovers the shared file's: never-ask there needs the same confirmation.
3566
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` };
3567
4230
  const from = settingScalar(valueAt(storedConfig(), def.path));
3568
- 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` };
3569
- 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 };
3570
4235
  const to = settingScalar(valueAt(storedConfig(), def.path));
3571
- if (def.type === "roles") applyRoleSelection(def.path[1]!); else applyStartModes();
3572
- recordSetting(def.key, from, to, source);
3573
- 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) });
3574
- // Config trust (#17) can drop a machine-local field at load: the write happened, and the person is told it will not count.
3575
- const dropped = ignoredLine(storedConfig()?.ignored, def);
3576
- 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})` };
3577
4241
  } catch (error) {
3578
- return { ok: false, error: (error as Error).message };
4242
+ return { ok: false, error: sanitize((error as Error).message), ...(saved ? { saved: true } : {}) };
3579
4243
  }
3580
4244
  }
3581
- /** A pin the hub could not start with: refused before a write or an undo puts it on disk. */
3582
- // As at hub start: with no project routing.toml only a pin the overlay sets counts, never the shipped template's own.
3583
- 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); };
3584
- 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 }; }
3585
4250
  // The previous version can hold a wider value than the one in force, so undo is never an ordinary session's.
3586
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" };
3587
- const def = settingDef(pendingUndo(opts.stateDir));
3588
- if (typeof def === "string") return { ok: false, error: "nothing to undo" };
3589
- 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);
3590
4260
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
3591
- 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));
3592
4262
  // never-ask is set with its confirmation, never put back by an undo that carries none.
3593
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` };
3594
4264
  try {
3595
4265
  const from = read();
3596
- undoSetting(opts.cwd, opts.stateDir, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch), routingCheck);
3597
- storedConfigCache = undefined;
3598
- if (def.type === "roles") applyRoleSelection(def.path[1]!); else applyStartModes();
3599
- recordSetting(def.key, from, read(), settingSource(authority), true);
3600
- 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) }); }
3601
4271
  return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
3602
4272
  } catch (error) {
3603
4273
  return { ok: false, error: (error as Error).message };
@@ -3611,21 +4281,21 @@ export async function startDaemon(opts: DaemonOptions) {
3611
4281
  if (def.group === "Roster") {
3612
4282
  try {
3613
4283
  const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled);
3614
- 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 }) };
3615
4285
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
3616
4286
  }
3617
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."] };
3618
4288
  if (def.group === "Permissions") {
3619
4289
  const peer = def.path.at(-1)!;
3620
- 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 } : {}) };
3621
4291
  }
3622
- if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : "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"}`] };
3623
4293
  try {
3624
4294
  const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
3625
- const open = board.list().filter((task) => task.state !== "approved");
4295
+ const open = board.list().filter((task) => !isClosedTaskState(task.state));
3626
4296
  // A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
3627
4297
  const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
3628
- 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"])],
3629
4299
  tasks: shown.map((task) => ({ id: task.id, before: tasks.publicExplain(task.id), after: tasks.publicExplain(task.id, after) })),
3630
4300
  ...(open.length > shown.length ? { hidden: open.length - shown.length } : {}) };
3631
4301
  } catch (error) {
@@ -3633,6 +4303,14 @@ export async function startDaemon(opts: DaemonOptions) {
3633
4303
  }
3634
4304
  }
3635
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
+
3636
4314
  async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
3637
4315
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
3638
4316
  // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
@@ -3711,11 +4389,13 @@ export async function startDaemon(opts: DaemonOptions) {
3711
4389
  return { ok: false, error: `${a.peer} did not start; inspect ahub status and hub.log` };
3712
4390
  }
3713
4391
  case "setting":
3714
- 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);
3715
4393
  case "setting_undo":
3716
- return settingsUndo(authority, a.confirm, a.now);
4394
+ return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
3717
4395
  case "setting_preview":
3718
- 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);
3719
4399
  default:
3720
4400
  return bad;
3721
4401
  }
@@ -3735,6 +4415,7 @@ export async function startDaemon(opts: DaemonOptions) {
3735
4415
  // #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
3736
4416
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
3737
4417
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
4418
+ ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
3738
4419
  ...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
3739
4420
  ...(releasing ? ["ready tasks being released"] : []),
3740
4421
  ...([...bus.peers].filter(([, peer]) => peer instanceof PiPeer && peer.state !== "busy" && !peer.recoveryReady).map(([id, peer]) => !membershipAllowed(id) && peer instanceof PiPeer && peer.mode === "tui" ? `${id} is not settled; end the retained Pi TUI, then prepare again` : `${id} is not settled`)),
@@ -3750,10 +4431,19 @@ export async function startDaemon(opts: DaemonOptions) {
3750
4431
  async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
3751
4432
  if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
3752
4433
  if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
4434
+ const incompatibleCancellationTarget = () => {
4435
+ const ids = board.list().filter(task => task.state === "cancelled").map(task => task.id);
4436
+ return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
4437
+ ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
4438
+ };
4439
+ const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
4440
+ if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4441
+
3753
4442
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
3754
4443
  const pi = bus.peers.get("pi");
3755
4444
  if (pi instanceof PiPeer && pi.state === "idle") {
3756
4445
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4446
+ if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3757
4447
  }
3758
4448
  }
3759
4449
  if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
@@ -3769,6 +4459,7 @@ export async function startDaemon(opts: DaemonOptions) {
3769
4459
  recoveryPeerSnapshot = undefined;
3770
4460
  }
3771
4461
  if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
4462
+ { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3772
4463
  recoveryOperationId = op;
3773
4464
  if (!recoveryPhase) recoveryPhase = "preparing";
3774
4465
  armRecoveryLease();
@@ -3776,6 +4467,7 @@ export async function startDaemon(opts: DaemonOptions) {
3776
4467
  budget.setRecoveryHold(true);
3777
4468
  bus.setRecoveryHold(true);
3778
4469
  await bus.fenceRecovery();
4470
+ { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3779
4471
  const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
3780
4472
  if (!blocked.length && permissions.size === 0 && recoveryReady()) {
3781
4473
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
@@ -3823,6 +4515,7 @@ export async function startDaemon(opts: DaemonOptions) {
3823
4515
  peers,
3824
4516
  integrity: integrity(),
3825
4517
  };
4518
+ { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3826
4519
  try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
3827
4520
  recoveryCommitted = true;
3828
4521
  clearTimeout(recoveryLeaseTimer);
@@ -4009,7 +4702,7 @@ export async function startDaemon(opts: DaemonOptions) {
4009
4702
  if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
4010
4703
  });
4011
4704
  }
4012
- return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
4705
+ return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
4013
4706
  }
4014
4707
  if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
4015
4708
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
@@ -4048,11 +4741,19 @@ export async function startDaemon(opts: DaemonOptions) {
4048
4741
  return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
4049
4742
  } catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
4050
4743
  }
4744
+ case "delivery_holds": {
4745
+ if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
4746
+ try {
4747
+ const held = bus.heldDeliveryHints(c.peer);
4748
+ return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
4749
+ } catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
4750
+ }
4051
4751
  case "delivery_receipt": {
4052
4752
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
4053
- if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
4753
+ if (c.role !== "peer" || !(peer instanceof WsPeer) || typeof msg.deliveryId !== "string" || !peer.ownsDelivery(sock, msg.generation, msg.deliveryId) || !["accepted", "needs_review", "failed_safe"].includes(msg.state)) return void reply({ ok: false, error: "invalid delivery receipt" });
4754
+ if (msg.state === "failed_safe" && (!peer.pullOnly || bus.queueShow(msg.deliveryId)?.state !== "dispatching")) return void reply({ ok: false, error: "safe failure requires an unseen push to a pull-only session" });
4054
4755
  const reason = typeof msg.reason === "string" ? sanitize(msg.reason).replace(/\s+/g, " ").trim().slice(0, 300) : "";
4055
- bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state === "needs_review" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
4756
+ bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
4056
4757
  return void reply({ ok: true });
4057
4758
  }
4058
4759
  case "inbox": {
@@ -4285,7 +4986,7 @@ export async function startDaemon(opts: DaemonOptions) {
4285
4986
  }
4286
4987
  case "watch": {
4287
4988
  if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
4288
- 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" });
4289
4990
  const peer = msg.peer as "kimi" | "qwen";
4290
4991
  // Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
4291
4992
  const kimiOwner = bus.peers.get("kimi");
@@ -4358,16 +5059,19 @@ export async function startDaemon(opts: DaemonOptions) {
4358
5059
  case "settings_get":
4359
5060
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4360
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" }));
4361
5065
  case "settings_preview":
4362
5066
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4363
- return void reply(settingsPreview(msg.key, msg.value));
5067
+ return void reply(msg.undo === true ? settingsUndoPreview() : settingsPreview(msg.key, msg.value));
4364
5068
  case "settings_set":
4365
5069
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4366
- 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" }));
4367
5071
  return;
4368
5072
  case "settings_undo":
4369
5073
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
4370
- 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" }));
4371
5075
  case "kimi_launch_end": {
4372
5076
  if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
4373
5077
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
@@ -4424,7 +5128,7 @@ export async function startDaemon(opts: DaemonOptions) {
4424
5128
  board,
4425
5129
  isPii: (t) => tasks.isPii(t),
4426
5130
  onCampus,
4427
- 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"),
4428
5132
  ...(config.memory.enabled ? { memory } : {}),
4429
5133
  // #258: entries that match the question, then the graph neighbourhood of what they are about.
4430
5134
  ...(memoryStore ? { native: (keys: string[]) => {
@@ -4501,6 +5205,7 @@ export async function startDaemon(opts: DaemonOptions) {
4501
5205
  }
4502
5206
  async function stopOnce(): Promise<void> {
4503
5207
  stopping = true;
5208
+ runnerRefreshAbort.abort();
4504
5209
  // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
4505
5210
  try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
4506
5211
  checksClosed = true;
@@ -4508,12 +5213,16 @@ export async function startDaemon(opts: DaemonOptions) {
4508
5213
  log("hub stopping");
4509
5214
  writeStatus();
4510
5215
  dashboard?.stop();
5216
+ stopConfigWatch();
5217
+ clearInterval(sweepTimer);
4511
5218
  for (const i of intervals) clearInterval(i);
4512
5219
  for (const pending of checkpointWaits.values()) pending.done(undefined);
4513
5220
  for (const p of permissions.values()) p.done(undefined);
4514
5221
  try {
4515
5222
  // A peer can still be inside memory recall or its native handshake when kill arrives.
4516
5223
  // Drain those starts before taking the final owned-process snapshot.
5224
+ // Fence/abort the runner now, but slow container teardown must not consume the peer-stop deadline.
5225
+ const runnerStopped = Promise.allSettled([checkRunner.stopAll(), refreshingRunner ?? Promise.resolve()]);
4517
5226
  await Promise.allSettled([...starting.values()]);
4518
5227
  // A fingerprint still being detected belongs in the research records written below; its peer must still be
4519
5228
  // attached when it settles, so this comes before the peers stop (each detection is bounded by its probe deadline).
@@ -4522,6 +5231,7 @@ export async function startDaemon(opts: DaemonOptions) {
4522
5231
  // A peer whose stop rejects must not strand the daemon (issue #56): its failure is
4523
5232
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
4524
5233
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
5234
+ await runnerStopped;
4525
5235
  await modelRelay?.close();
4526
5236
  await egress?.close();
4527
5237
  await piReceipts?.close();
@@ -4566,9 +5276,21 @@ export async function startDaemon(opts: DaemonOptions) {
4566
5276
  writeFileSync(tokenFile, token, { mode: 0o600 });
4567
5277
  chmodSync(tokenFile, 0o600);
4568
5278
  budget.restore(); // pauses recorded by an earlier hub run stay in force; unfinished handoffs wait for peers to attach
4569
- writeStatus(); // #226: no hub.pid any more; status.json carries the pid with its signature
5279
+ bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
4570
5280
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
4571
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);
4572
5294
  // #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).
4573
5295
  try { const settled = tasks.reconcileMemory(); if (settled) log(`hub memory: settled ${settled} approved task${settled === 1 ? "" : "s"} a stop had left unsettled`); }
4574
5296
  catch (e) { log(`hub memory reconciliation failed: ${(e as Error).message}`); }
@@ -4594,7 +5316,7 @@ export async function startDaemon(opts: DaemonOptions) {
4594
5316
  });
4595
5317
  }
4596
5318
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
4597
- const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
5319
+ const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => p.peer === "pi");
4598
5320
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
4599
5321
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
4600
5322
  } finally {