@staix/agent-hub 0.12.30 → 0.12.31

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 (64) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -7
  3. package/docs/agent-notes/adapters.md +2 -1
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +4 -0
  6. package/docs/agent-notes/daemon.md +5 -2
  7. package/docs/agent-notes/local-worker.md +2 -0
  8. package/docs/agent-notes/tasks.md +5 -0
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +129 -22
  12. package/docs/quickstart.md +17 -2
  13. package/docs/security.md +30 -2
  14. package/docs/specs/2026-09-19-agent-hub-design.md +37 -3
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -10
  16. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  17. package/docs/verification/2026-10-17-418-probes.md +21 -0
  18. package/docs/verified.json +13 -13
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +66 -19
  22. package/src/adapters/claude-channel.ts +29 -11
  23. package/src/adapters/local-worker.ts +2 -2
  24. package/src/adapters/pi.ts +5 -0
  25. package/src/cli/attention.ts +5 -1
  26. package/src/cli/console-state.ts +56 -12
  27. package/src/cli/console.ts +19 -6
  28. package/src/cli/identity.ts +3 -2
  29. package/src/cli/jcode-mcp.ts +1 -1
  30. package/src/cli/main.ts +8 -1
  31. package/src/cli/output.ts +13 -2
  32. package/src/cli/upgrade-interactive.ts +2 -1
  33. package/src/cli/upgrade-restore-state.ts +133 -0
  34. package/src/cli/upgrade-runtime.ts +162 -58
  35. package/src/cli/upgrade.ts +49 -24
  36. package/src/hub/board.ts +82 -24
  37. package/src/hub/bus.ts +78 -29
  38. package/src/hub/check-runner.ts +514 -0
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/control-client.ts +5 -1
  41. package/src/hub/daemon.ts +400 -67
  42. package/src/hub/delivery-hints.ts +30 -0
  43. package/src/hub/delivery-journal.ts +185 -12
  44. package/src/hub/delivery-superseded.ts +52 -0
  45. package/src/hub/envelope.ts +1 -1
  46. package/src/hub/host-executable.ts +11 -0
  47. package/src/hub/hub-tools.ts +9 -1
  48. package/src/hub/peer-catalog.ts +3 -0
  49. package/src/hub/routing.ts +35 -16
  50. package/src/hub/settings.ts +2 -1
  51. package/src/hub/spec-read.ts +120 -0
  52. package/src/hub/supervision.ts +21 -6
  53. package/src/hub/task-sweep.ts +7 -6
  54. package/src/hub/tasks.ts +173 -34
  55. package/src/memory/capture.ts +2 -1
  56. package/src/ui/index.html +12 -9
  57. package/src/ui/needs.ts +7 -5
  58. package/src/ui/office-model.ts +4 -1
  59. package/src/ui/task-board.ts +1 -1
  60. package/src/ui/task-progress.ts +6 -2
  61. package/templates/AGENTS.block.md +1 -1
  62. package/templates/profiles/general.json +1 -1
  63. package/templates/profiles/research.json +1 -1
  64. 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";
@@ -16,7 +21,7 @@ import { JcodePeer } from "../adapters/jcode.ts";
16
21
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
17
22
  import { loadSdlc } 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
26
  import { buildKimiLaunch, buildQwenLaunch } from "../cli/launch.ts";
22
27
  import { ProgressObserver, normalizeCodexObservation, normalizeClaudeObservation } from "./progress.ts";
@@ -51,16 +56,16 @@ import { Capture, skipTools } from "../memory/capture.ts";
51
56
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
52
57
  import { Sidecar } from "../switchyard/sidecar.ts";
53
58
  import { Briefs } from "../memory/brief.ts";
54
- import { Board, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
59
+ import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
55
60
  import { Budget, claudeWindows, codexWindows, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
56
61
  import { HUB } from "./envelope.ts";
57
62
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
58
63
  import { closeSync, constants as fsConstants, openSync, readSync, statSync } from "node:fs";
59
64
  import { basename, isAbsolute } from "node:path";
60
65
  import { fileURLToPath } from "node:url";
61
- import { realPath } from "./project.ts";
66
+ import { realPath, hubHome } from "./project.ts";
62
67
  import type { BusEvent } from "./bus.ts";
63
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
68
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
64
69
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
65
70
  import { SupervisionFeed } from "./supervision.ts";
66
71
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -74,7 +79,7 @@ import { Bus } from "./bus.ts";
74
79
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
75
80
  import { DeliveryJournal } from "./delivery-journal.ts";
76
81
  import { startDashboard, type DashboardSession } from "./ui.ts";
77
- import { PROTOCOL, stateDirFor } from "./control-client.ts";
82
+ import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
78
83
  import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
79
84
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
80
85
  import { MemoryClient, workerUrl } from "../memory/client.ts";
@@ -104,7 +109,7 @@ import { syncMemory } from "@staix/agent-mem";
104
109
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
105
110
  import { memoryProjectKey } from "@staix/agent-mem";
106
111
  import { nativeRecall } from "../memory/recall.ts";
107
- import { promotionScreen, withoutPii } from "./memory-policy.ts";
112
+ import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
108
113
 
109
114
  export interface HubConfig {
110
115
  watchdog_ms: number;
@@ -125,6 +130,8 @@ export interface HubConfig {
125
130
  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
131
  roster: PeerRoster;
127
132
  roles: Record<string, string[]>;
133
+ /** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
134
+ planner?: string;
128
135
  profile?: ProfileName;
129
136
  profile_overrides?: Record<string, unknown>;
130
137
  pii?: { paths: string[] };
@@ -147,7 +154,7 @@ export interface HubConfig {
147
154
  /** Between-turn task escalation, disabled unless explicitly enabled (#186). */
148
155
  task_sweep: TaskSweepConfig;
149
156
  /** 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 };
157
+ checks: { timeout_s: number; runner?: "container"; [cls: string]: string | number | undefined };
151
158
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
152
159
  snapshots: { enabled: boolean; keep: number };
153
160
  /** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
@@ -231,14 +238,19 @@ const CONFIG_FILES = ["config.json", "config.local.json"] as const;
231
238
  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
239
 
233
240
  /** 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"] } {
241
+ export function loadConductorPolicy(cwd: string): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
235
242
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
236
243
  const conductor = { ...DEFAULT_CONFIG.conductor };
244
+ let planner: string | undefined;
237
245
  for (const name of CONFIG_FILES) {
238
246
  let file: Record<string, unknown>;
239
247
  try { file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
240
248
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
241
249
  if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
250
+ if (file.planner !== undefined) {
251
+ if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
252
+ planner = file.planner as string;
253
+ }
242
254
  if (file.conductor !== undefined) {
243
255
  if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
244
256
  const block = file.conductor as Record<string, unknown>;
@@ -253,7 +265,7 @@ export function loadConductorPolicy(cwd: string): { roles: Record<string, string
253
265
  }
254
266
  }
255
267
  conductorPeer(roles);
256
- return { roles, conductor };
268
+ return { roles, conductor, ...(planner ? { planner } : {}) };
257
269
  }
258
270
 
259
271
  /** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
@@ -345,6 +357,7 @@ export function loadConfig(cwd: string): HubConfig {
345
357
  ...(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
358
  roster: peerRosterConfig(file.roster),
347
359
  roles: conductorPolicy.roles,
360
+ planner: conductorPolicy.planner,
348
361
  conductor: conductorPolicy.conductor,
349
362
  budget: { ...DEFAULT_CONFIG.budget, wait_max_min: 30, ...file.budget }, // on with any project config (issue #36)
350
363
  context: { ...DEFAULT_CONTEXT, ...file.context },
@@ -358,7 +371,7 @@ export function loadConfig(cwd: string): HubConfig {
358
371
  },
359
372
  tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks, ...(file.tasks?.wip_limits !== undefined ? { wip_limits: planningWipLimits(file.tasks.wip_limits) } : {}) },
360
373
  task_sweep: taskSweepConfig(file.task_sweep),
361
- checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
374
+ checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "container" ? "container" : undefined },
362
375
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
363
376
  research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
364
377
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
@@ -423,6 +436,12 @@ export interface DaemonOptions {
423
436
  researchGraceMs?: number;
424
437
  /** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
425
438
  memorySync?: typeof syncMemory;
439
+ /** Host-only test/embedding transports. Never read from config or tool input. */
440
+ boundedTools?: {
441
+ specAvailable?: typeof specAvailable;
442
+ fetchSpec?: typeof fetchSpec;
443
+ runner?: (options: RunnerOptions) => Pick<ContainerRunner, "available" | "start" | "result" | "consumeOutput" | "cancel" | "sweep" | "stopAll" | "markStale"> & Partial<Pick<ContainerRunner, "unknownReceipts" | "maintenanceUnavailable">>;
444
+ };
426
445
  }
427
446
 
428
447
  interface Client {
@@ -560,6 +579,39 @@ export async function startDaemon(opts: DaemonOptions) {
560
579
  const logFile = join(opts.stateDir, "hub.log");
561
580
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
562
581
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
582
+ // #455: the planner in force. `policyRoles` is what the config files say; settlePlanner derives config.roles from it in
583
+ // place (Tasks holds the same object): an explicit `planner` key is exclusive, and with none and no rostered planner
584
+ // the first rostered PLANNER_PEERS entry plans. Never written to disk: a person pins it with the `planner` setting.
585
+ let policyRoles = structuredClone(config.roles);
586
+ let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
587
+ let plannerLogged = "";
588
+ const settlePlanner = () => {
589
+ const roles = structuredClone(policyRoles);
590
+ const pinned = (PLANNER_PEERS as readonly string[]).includes(config.planner ?? "") ? config.planner! : undefined;
591
+ let source: typeof plannerSource;
592
+ if (pinned) {
593
+ for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
594
+ roles[pinned] = ["planner", ...(roles[pinned] ?? [])];
595
+ source = "setting";
596
+ } else if (Object.entries(roles).some(([peer, list]) => list.includes("planner") && onRoster(peer))) source = "roles";
597
+ else {
598
+ const auto = PLANNER_PEERS.find(onRoster);
599
+ if (auto) { // the off-roster holders give the role up while the automatic planner holds it
600
+ for (const peer of Object.keys(roles)) roles[peer] = roles[peer]!.filter(role => role !== "planner");
601
+ roles[auto] = [...(roles[auto] ?? []), "planner"];
602
+ }
603
+ source = auto ? "automatic" : "none";
604
+ }
605
+ conductorPeer(roles);
606
+ for (const key of Object.keys(config.roles)) delete config.roles[key];
607
+ Object.assign(config.roles, roles);
608
+ plannerSource = source;
609
+ const line = `${plannerPeers().join(", ") || "nobody"} (${source})`;
610
+ if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
611
+ };
612
+ const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
613
+ 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`);
614
+ settlePlanner();
563
615
  const catalog = loadRoleCatalog(opts.cwd);
564
616
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
565
617
  const resolvedProfile = resolveProfile(config.profile, config.profile_overrides);
@@ -718,12 +770,12 @@ export async function startDaemon(opts: DaemonOptions) {
718
770
  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
771
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
720
772
  // 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 => {
773
+ const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run"): boolean => {
722
774
  if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
723
775
  const list = config.capabilities[peer];
724
776
  return Array.isArray(list) && list.includes(cap);
725
777
  };
726
- const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important"];
778
+ const CAPABILITIES = ["propose", "assign", "remember", "memory_correct", "memory_read", "important", "spec_read", "check_run"];
727
779
  for (const [peer, list] of Object.entries(config.capabilities)) {
728
780
  if (!PEER_ID.test(peer)) log(`capabilities.${peer} is not a peer id; ignored (capabilities is an object of lists, one per peer)`);
729
781
  else if (!Array.isArray(list)) log(`capabilities.${peer} is not a list: ${peer} gets no capabilities`);
@@ -738,7 +790,8 @@ export async function startDaemon(opts: DaemonOptions) {
738
790
  // silence without facts would leave overlapping owners blind. Facts and silence ask the same question, so both switch
739
791
  // together. Cached until the board changes: it is asked per recipient of every message.
740
792
  let turnFreeNow: boolean | undefined;
741
- const turnFree = (): boolean => (turnFreeNow ??= coordination === "turn-free" && !board.list().some((t) => t.state !== "approved" && tasks.isPii(t)));
793
+ const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean; deliveryTask?: number; attribution: TaskAttribution; preparedRoles: RoleIdentity[] | null }>();
794
+ const turnFree = (): boolean => ![...turns.values()].some(turn => turn.private) && (turnFreeNow ??= coordination === "turn-free" && !board.list().some((t) => !isClosedTaskState(t.state) && tasks.isPii(t)));
742
795
  /**
743
796
  * Why `to` does not get an agent's message (issue #107): sender and recipient are in one silent cohort, and the sender
744
797
  * has not stopped since its cohort task closed. Workflow events and anything to the console are never held back.
@@ -786,7 +839,7 @@ export async function startDaemon(opts: DaemonOptions) {
786
839
  const staleOff = config.experiments?.stale_notices === "deliver";
787
840
  if (staleOff) log("experiment: stale notices are delivered as before 0.12.4 (the issue #106 ablation)");
788
841
  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,
842
+ const bus = new Bus({ rostered: membershipAllowed, deliveryHeld: peer => requestedPeerStops.has(peer) || rosterChanging === peer, journal, batchMax: config.batch_max, batchMs: config.batch_ms, queueCap: config.queue_cap, condense: (envs) => inference?.condense(envs) ?? Promise.resolve(envs), admit, relevant: (peer, env) => env.refs?.boundedCheck || env.refs?.supervision || env.refs?.backlog ? relevantNotice(peer, env) : staleOff || relevantNotice(peer, env), silence,
790
843
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (p instanceof WsPeer) p.notifyQueued(counts); } });
791
844
  startupCleanup.push(() => bus.closeJournal());
792
845
  const manualPaused = new Set<PeerId>(bus.manualPausedPeers()); // recovery never lifts an operator's pause
@@ -1012,9 +1065,10 @@ export async function startDaemon(opts: DaemonOptions) {
1012
1065
  if (opts.config) return; // explicit embedding/test configuration remains its authority
1013
1066
  const before = conductorPeer(config.roles);
1014
1067
  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);
1068
+ policyRoles = structuredClone(policy.roles);
1069
+ config.planner = policy.planner;
1017
1070
  config.conductor = policy.conductor;
1071
+ settlePlanner();
1018
1072
  const after = conductorPeer(config.roles);
1019
1073
  if (before && (before !== after || config.conductor.feed === "off")) { bus.revokeSupervision(before); supervision.resetPending(before); budgetFeedSeen.clear(); }
1020
1074
  if (config.conductor.feed === "off" && after) bus.revokeSupervision(after);
@@ -1023,8 +1077,8 @@ export async function startDaemon(opts: DaemonOptions) {
1023
1077
  startupCleanup.push(() => executionBudget.close());
1024
1078
  // Completion checks (issue #7): only from a config file git does not track, one at a time, killed on stop.
1025
1079
  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));
1080
+ 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>;
1081
+ const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && k !== "runner" && !(k in checkCommands));
1028
1082
  if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
1029
1083
  // Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
1030
1084
  for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
@@ -1034,7 +1088,96 @@ export async function startDaemon(opts: DaemonOptions) {
1034
1088
  const runningChecks = new Set<() => void>();
1035
1089
  let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
1036
1090
  const interrupted = { code: null, timedOut: false, interrupted: true, tail: "" };
1091
+ // Bounded capabilities (#418): inactive sandboxed peers need no host account probe.
1092
+ // A config-file daemon probes configured Pi/runner at startup; embeddings opt in or probe lazily on use.
1093
+ let specState: Awaited<ReturnType<typeof specAvailable>> = { available: false, reason: "gh unavailable: not probed" };
1094
+ let specProbe: Promise<void> | undefined, specProbeSettled = false;
1095
+ const ensureSpec = (refresh = false): Promise<void> => {
1096
+ if (specProbe && (!refresh || !specProbeSettled)) return specProbe;
1097
+ specProbeSettled = false;
1098
+ specProbe = Promise.resolve().then(() => (opts.boundedTools?.specAvailable ?? specAvailable)({ projectDir: opts.cwd }))
1099
+ .then(value => { specState = value; }, () => { specState = { available: false, reason: "gh unavailable" }; })
1100
+ .finally(() => { specProbeSettled = true; });
1101
+ return specProbe;
1102
+ };
1103
+ const checkPending = new Set<string>();
1104
+ const checkConnections = new WeakMap<object, string>();
1105
+ const checkBindings = new Map<string, Pick<RunReceipt, "task" | "owner" | "generation" | "updated" | "revision">>();
1106
+ const connectionGeneration = (id: string): string | undefined => {
1107
+ const peer = bus.peers.get(id);
1108
+ if (!peer || peer.state === "offline") return undefined;
1109
+ let token = checkConnections.get(peer);
1110
+ if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1111
+ const native = peer instanceof WsPeer || peer instanceof CodexPeer || peer instanceof PiPeer ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1112
+ if (peer instanceof PiPeer && native === undefined) return undefined;
1113
+ return `${instanceId}:${token}:${String(native ?? "")}`;
1114
+ };
1115
+ const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
1116
+ const task = board.get(receipt.task);
1117
+ return !!task && task.owner === receipt.owner && task.state === "in_progress" && task.updated === receipt.updated && task.history.length === receipt.revision
1118
+ && connectionGeneration(receipt.owner) === receipt.generation && may(receipt.owner, "check_run") && membershipAllowed(receipt.owner) && !tasks.isPii(task)
1119
+ && !turns.get(receipt.owner)?.private && !contextPii(receipt.owner);
1120
+ };
1121
+ const boundedSafe = (text: string) => !looksSecret(text) && screenMemory([{ title: null, text }]).length === 1
1122
+ && !mentionsPrivatePath(text, [], [...(config.pii?.paths ?? []), ...config.local.deny], opts.cwd);
1123
+ const publicCheck = (receipt: RunReceipt) => {
1124
+ const safeTail = boundedSafe(receipt.tail), safeError = !receipt.error || boundedSafe(receipt.error);
1125
+ return { run: receipt.run, task: receipt.task, status: receipt.status, sha: receipt.sha, tree: receipt.tree, lockfile: receipt.lockfile,
1126
+ excluded: receipt.excluded, runner: receipt.runner, image: receipt.image, platform: receipt.platform,
1127
+ check: receipt.check, ...(receipt.files ? { files: receipt.files } : {}), exit: receipt.exit, timed_out: receipt.timed_out,
1128
+ cancelled: receipt.cancelled, interrupted: receipt.interrupted, duration_ms: receipt.duration_ms,
1129
+ tail: safeTail ? receipt.tail : "", output_bytes: receipt.output_bytes, truncated: receipt.truncated,
1130
+ output_withheld: receipt.output_withheld || !safeTail || !safeError, ...(safeError && receipt.error ? { error: receipt.error } : {}) };
1131
+ };
1132
+ const checkOptions: RunnerOptions = {
1133
+ projectDir: opts.cwd, stateDir: opts.stateDir, storageDir: join(hubHome(), "checks"),
1134
+ projectKey: createHash("sha256").update(realPath(opts.cwd)).digest("hex").slice(0, 24),
1135
+ enabled: () => config.checks.runner === "container",
1136
+ isDenied: path => isDenied(path, config.local.deny) || matchesPolicy(opts.cwd, path, config.pii?.paths ?? [], true),
1137
+ isCurrent: currentCheck,
1138
+ onResult: async receipt => {
1139
+ if (!currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
1140
+ // Only a screened, current-owner result is delivered; no tail enters capture or diagnostic logs.
1141
+ const view = publicCheck(receipt);
1142
+ checkBindings.set(receipt.run, { task: receipt.task, owner: receipt.owner, generation: receipt.generation, updated: receipt.updated, revision: receipt.revision });
1143
+ while (checkBindings.size > 200) checkBindings.delete(checkBindings.keys().next().value!);
1144
+ if (stopping || !currentCheck(receipt)) { await checkRunner.markStale(receipt.run); return; }
1145
+ bus.publish(newEnvelope(HUB, sanitize(JSON.stringify(view)), { to: [receipt.owner], kind: "task", refs: { task: String(receipt.task), boundedCheck: receipt.run } }));
1146
+ log(`check ${receipt.run}: exit ${receipt.exit ?? "unknown"}, bytes ${receipt.output_bytes}`);
1147
+ },
1148
+ };
1149
+ const checkRunner = opts.boundedTools?.runner?.(checkOptions) ?? new ContainerRunner(checkOptions);
1150
+ const runnerRefreshAbort = new AbortController();
1151
+ startupCleanup.push(() => { runnerRefreshAbort.abort(); void checkRunner.stopAll(); });
1152
+ let runnerState = await checkRunner.available(runnerRefreshAbort.signal);
1153
+ const orphanChecks = (await checkRunner.sweep()).map(line => boundedSafe(line) ? sanitize(line) : "orphan check: details withheld by privacy policy");
1154
+ if (opts.boundedTools?.specAvailable || !opts.config && (config.pi.enabled || config.checks.runner === "container")) await ensureSpec();
1155
+ let runnerStateAt = runnerState.checkedAt ?? Date.now(), refreshingRunner: Promise<void> | undefined;
1156
+ const refreshRunner = (): Promise<void> => {
1157
+ if (stopping || runnerRefreshAbort.signal.aborted) return Promise.resolve();
1158
+ if (Date.now() - runnerStateAt < 60_000) return Promise.resolve();
1159
+ if (refreshingRunner) return refreshingRunner;
1160
+ refreshingRunner = checkRunner.available(runnerRefreshAbort.signal).then(value => {
1161
+ if (stopping || runnerRefreshAbort.signal.aborted) return;
1162
+ runnerState = value; runnerStateAt = value.checkedAt ?? Date.now();
1163
+ }, () => {
1164
+ if (stopping || runnerRefreshAbort.signal.aborted) return;
1165
+ runnerState = { available: false, reason: "container unavailable" }; runnerStateAt = Date.now();
1166
+ }).finally(() => { refreshingRunner = undefined; });
1167
+ return refreshingRunner;
1168
+ };
1169
+ const peerCapabilities = () => {
1170
+ // Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
1171
+ if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
1172
+ return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
1173
+ if (peer !== "pi" && peer !== "local") return [peer, { tests: true, spec: true }];
1174
+ return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"),
1175
+ testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
1176
+ specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
1177
+ }));
1178
+ };
1037
1179
  const tasks: Tasks = new Tasks({
1180
+ capabilities: peerCapabilities, repository: memoryRepo,
1038
1181
  recordPlanning: change => event({ type: "planning", ...change }),
1039
1182
  wipLimit: peer => Object.hasOwn(config.tasks.wip_limits ?? {}, peer) ? config.tasks.wip_limits![peer]! : 1,
1040
1183
  profile: profileName(config.profile),
@@ -1094,6 +1237,10 @@ export async function startDaemon(opts: DaemonOptions) {
1094
1237
  review: config.review,
1095
1238
  sweep: taskSweepConfig(config.task_sweep),
1096
1239
  sweepHeld: () => stopping || recoveryActive(),
1240
+ cancellationHeld: () => stopping || recoveryActive(),
1241
+ offerRead: (peer, envelopeId) => journal.offerRead(peer, envelopeId),
1242
+ offerPullOnly: (id) => { const peer = bus.peers.get(id); return peer instanceof WsPeer && peer.pullOnly; },
1243
+ stallNotice: stall => { supervision.taskStalled(stall); },
1097
1244
  roles: config.roles,
1098
1245
  turnFree,
1099
1246
  capable: (peer) => capable.has(peer),
@@ -1116,6 +1263,21 @@ export async function startDaemon(opts: DaemonOptions) {
1116
1263
  held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
1117
1264
  });
1118
1265
  relevantNotice = (peer, env) => {
1266
+ if (env.from === HUB && env.refs?.boundedCheck) {
1267
+ const run = env.refs.boundedCheck, binding = checkBindings.get(run);
1268
+ let outputSafe = false;
1269
+ try {
1270
+ const value = JSON.parse(env.body) as { tail?: unknown; error?: unknown };
1271
+ outputSafe = typeof value.tail === "string" && boundedSafe(value.tail)
1272
+ && (value.error === undefined || typeof value.error === "string" && boundedSafe(value.error));
1273
+ } catch { /* An unknown projection never reaches a model. */ }
1274
+ if (!binding || binding.owner !== peer || !currentCheck(binding) || !outputSafe) {
1275
+ checkBindings.delete(run);
1276
+ void checkRunner.markStale(run).catch(() => {});
1277
+ return false;
1278
+ }
1279
+ return true;
1280
+ }
1119
1281
  if (env.from !== HUB || !env.refs?.supervision) return tasks.relevant(peer, env);
1120
1282
  if (conductorPeer(config.roles) !== peer || config.conductor.feed === "off") return false;
1121
1283
  const key = env.refs.supervisionKey ?? "";
@@ -1139,7 +1301,7 @@ export async function startDaemon(opts: DaemonOptions) {
1139
1301
  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
1302
  const progress = new ProgressObserver({
1141
1303
  tasks: () => board.list(),
1142
- isPrivate: () => board.list().some(task => task.state !== "approved" && tasks.isPii(task)),
1304
+ isPrivate: () => [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task)),
1143
1305
  inference: { escalate: (conversation, turn) => inference?.escalate(conversation, turn) ?? Promise.resolve(undefined) },
1144
1306
  emit: conductorProgressSink(event, supervision),
1145
1307
  notify,
@@ -1322,7 +1484,7 @@ export async function startDaemon(opts: DaemonOptions) {
1322
1484
  return undefined;
1323
1485
  };
1324
1486
  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));
1487
+ 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
1488
  const requestCheckpoint = (peer: PeerId, reason: "quota" | "context"): Promise<string | undefined> => {
1327
1489
  // One outstanding request per peer. A quota transition may proceed without a summary rather than steal a context waiter.
1328
1490
  if (checkpointWaits.has(peer) || recoveryActive() || stopping) return Promise.resolve(undefined);
@@ -1416,7 +1578,6 @@ export async function startDaemon(opts: DaemonOptions) {
1416
1578
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
1417
1579
  const runId = Date.now().toString(36);
1418
1580
  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
1581
  const pendingDeliveryTask = new Map<PeerId, number>();
1421
1582
  const taskAttribution = (peer: PeerId, delivery = turns.get(peer)?.deliveryTask): TaskAttribution => {
1422
1583
  const result = attribute(delivery, board.list("in_progress").filter(t => t.owner === peer).map(t => t.id));
@@ -1448,7 +1609,7 @@ export async function startDaemon(opts: DaemonOptions) {
1448
1609
  const turnLog = repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
1449
1610
  if (turnLog) startupCleanup.push(() => turnLog.close());
1450
1611
  /** 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));
1612
+ 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
1613
  const memoryObservations = memoryTelemetry ? new MemoryObservations(memoryTelemetry, (peer, envs) => {
1453
1614
  if (turns.get(peer)?.private || contextPii(peer) || envs.some(env => env.private)) return;
1454
1615
  for (const env of envs) {
@@ -1460,7 +1621,7 @@ export async function startDaemon(opts: DaemonOptions) {
1460
1621
  if (row && tasks.isPii(row)) return;
1461
1622
  return { ...(row ? { task: taskKey(memoryProject, row) } : {}), turn: turns.get(peer)?.id ?? null, session: contextSession(peer) ?? null };
1462
1623
  }) : undefined;
1463
- bus.onMemoryDelivery = (peer, id, out, field) => memoryObservations?.delivery(peer, id, out, field);
1624
+ bus.onMemoryDelivery = (peer, id, out, field) => { if (!out.some(env => env.refs?.boundedCheck)) memoryObservations?.delivery(peer, id, out, field); };
1464
1625
  const citeMemory = (peer: PeerId, text: string, id: string, envs: readonly Envelope[] = []) => {
1465
1626
  if (!tasks.nameable(text) || looksSecret(text) || !screenMemory([{ title: null, text }]).length) return;
1466
1627
  memoryObservations?.cite(peer, text, id, envs);
@@ -1482,7 +1643,7 @@ export async function startDaemon(opts: DaemonOptions) {
1482
1643
  return { tree, ms: Math.round(performance.now() - t0) };
1483
1644
  };
1484
1645
  function privateSnapshotWindow(): boolean {
1485
- return [...turns.values()].some(turn => turn.private) || board.list().some(task => task.state !== "approved" && tasks.isPii(task));
1646
+ return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1486
1647
  }
1487
1648
  function reviewProjection(task: Task): string | undefined {
1488
1649
  if (!projections.enabled || tasks.isPii(task)) return undefined;
@@ -1525,6 +1686,7 @@ export async function startDaemon(opts: DaemonOptions) {
1525
1686
  const contextTailSeen = new Map<PeerId, string>();
1526
1687
  const intervals = [
1527
1688
  setInterval(() => {
1689
+ void refreshRunner().catch(() => {});
1528
1690
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
1529
1691
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
1530
1692
  for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
@@ -1607,7 +1769,7 @@ export async function startDaemon(opts: DaemonOptions) {
1607
1769
  // names are answered by the hub; every call still passes daemon role/capability checks before its effects.
1608
1770
  const HUB_TOOLS = ["hub_send", ...TASK_TOOLS.map((t) => t.name), ...CONDUCTOR_TOOLS.map((t) => t.name)];
1609
1771
  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 });
1772
+ 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
1773
 
1612
1774
  /** One entry point for the task tools, whoever calls them: MCP clients, the local worker, the console. */
1613
1775
  // A task op can write the board across awaits (triage, briefs, the dependents an approval releases): a recovery
@@ -1627,11 +1789,21 @@ export async function startDaemon(opts: DaemonOptions) {
1627
1789
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
1628
1790
  auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
1629
1791
  }
1630
- return text;
1792
+ const hint = args[3] ? ownHeldHint(by) : "";
1793
+ return hint ? `${text}\n\n${hint}` : text;
1794
+ } catch (error) {
1795
+ // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
1796
+ const hint = args[3] ? ownHeldHint(by) : "";
1797
+ if (hint && error instanceof Error) error.message += `\n\n${hint}`;
1798
+ throw error;
1631
1799
  } finally {
1632
1800
  taskOpsInFlight--;
1633
1801
  }
1634
1802
  };
1803
+ function ownHeldHint(peer: PeerId): string {
1804
+ try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), !(bus.peers.get(peer) instanceof LocalPeer || bus.peers.get(peer) instanceof AcpPeer)); }
1805
+ catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
1806
+ }
1635
1807
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
1636
1808
  const peerArg = (v: unknown, name: string): PeerId | undefined => {
1637
1809
  if (v == null || v === "") return undefined;
@@ -1640,6 +1812,12 @@ export async function startDaemon(opts: DaemonOptions) {
1640
1812
  return id;
1641
1813
  };
1642
1814
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
1815
+ if (BOUNDED_TASK_TOOLS.has(op)) {
1816
+ const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
1817
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
1818
+ || (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");
1819
+ if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
1820
+ }
1643
1821
  if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
1644
1822
  if (op === "hub_memory_correct") correctionInput(a);
1645
1823
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
@@ -1660,7 +1838,7 @@ export async function startDaemon(opts: DaemonOptions) {
1660
1838
  // console reads a PII task's text deliberately, with `ahub task show <id>`.
1661
1839
  const onPrem = inProcess && by === "local" && piiTurn;
1662
1840
  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) => {
1841
+ const need = (cap: "propose" | "assign" | "remember" | "memory_read" | "memory_correct" | "spec_read" | "check_run", what: string) => {
1664
1842
  if (may(by, cap)) return;
1665
1843
  log(`capabilities: ${by} may not ${what} (${op})`);
1666
1844
  throw new PreEffectToolRefusal(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
@@ -1678,7 +1856,88 @@ export async function startDaemon(opts: DaemonOptions) {
1678
1856
  }
1679
1857
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
1680
1858
  if (op === "hub_remember") need("remember", "save notes to shared memory");
1859
+ if (op === "hub_task_spec") {
1860
+ need("spec_read", "read assigned specifications");
1861
+ const authorized = () => {
1862
+ const task = board.get(a.id);
1863
+ 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");
1864
+ return task;
1865
+ };
1866
+ const task = authorized(), generation = connectionGeneration(by), updated = task.updated, revision = task.history.length;
1867
+ const deadline = Date.now() + 25_000; // retain a response margin inside the control client's 30-second cap
1868
+ const issues = specRefs(task, memoryRepo);
1869
+ if (!issues.length) throw new PreEffectToolRefusal("no assigned spec references");
1870
+ await ensureSpec();
1871
+ authorized();
1872
+ if (!specState.available) throw new PreEffectToolRefusal(specState.reason ?? "gh unauthenticated");
1873
+ const specs = [];
1874
+ for (const issue of issues) {
1875
+ const remaining = deadline - Date.now();
1876
+ if (remaining <= 0) throw new PreEffectToolRefusal("timeout");
1877
+ specs.push(await (opts.boundedTools?.fetchSpec ?? fetchSpec)(memoryRepo, issue, { screen: boundedSafe, projectDir: opts.cwd, timeoutMs: Math.min(15_000, remaining) }));
1878
+ const current = authorized();
1879
+ if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "spec_read")) throw new PreEffectToolRefusal("spec unavailable");
1880
+ }
1881
+ const result = JSON.stringify({ specs });
1882
+ if (Buffer.byteLength(result) > 80 * 1024) throw new PreEffectToolRefusal("too large");
1883
+ if (!specs.every(spec => boundedSafe(`${spec.title}\n${spec.body}`))) throw new PreEffectToolRefusal("spec withheld by privacy policy");
1884
+ return result;
1885
+ }
1886
+ if (op === "hub_check_start") {
1887
+ need("check_run", "run isolated checks");
1888
+ if (stopping || recoveryActive()) throw new PreEffectToolRefusal("runner unavailable: recovery or shutdown");
1889
+ const authorized = () => {
1890
+ const task = board.get(a.id);
1891
+ if (!task || task.owner !== by || task.state !== "in_progress" || tasks.isPii(task) || turns.get(by)?.private || contextPii(by)) throw new PreEffectToolRefusal("check unavailable");
1892
+ return task;
1893
+ };
1894
+ const task = authorized(), updated = task.updated, revision = task.history.length, generation = connectionGeneration(by);
1895
+ if (!generation) throw new PreEffectToolRefusal("check requires an attached owner");
1896
+ if (a.check !== "test" && a.check !== "typecheck"
1897
+ || 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 === "..")))
1898
+ || a.check === "typecheck" && a.files !== undefined) throw new PreEffectToolRefusal("invalid check request");
1899
+ await refreshRunner();
1900
+ const current = authorized();
1901
+ if (current.updated !== updated || current.history.length !== revision || connectionGeneration(by) !== generation || !may(by, "check_run") || stopping || recoveryActive()) throw new PreEffectToolRefusal("check unavailable");
1902
+ if (!runnerState.available) throw new PreEffectToolRefusal(`runner unavailable: ${runnerState.reason ?? "unknown"}`);
1903
+ const ref = current.refs.commit ?? current.refs.branch;
1904
+ if (!ref) throw new PreEffectToolRefusal("check requires task branch or commit");
1905
+ 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 }) });
1906
+ checkPending.add(handle.run);
1907
+ void handle.done.finally(() => checkPending.delete(handle.run)).catch(() => {});
1908
+ return JSON.stringify({ run: handle.run });
1909
+ }
1910
+ if (op === "hub_check_result" || op === "hub_check_cancel") {
1911
+ need("check_run", "read or cancel isolated checks");
1912
+ let receipt = await checkRunner.result(a.run);
1913
+ const task = receipt ? board.get(receipt.task) : undefined;
1914
+ if (!receipt || by !== USER && (receipt.owner !== by || !task || tasks.isPii(task))) throw new PreEffectToolRefusal("check unavailable");
1915
+ if (receipt.status === "stale" && by !== USER) throw new PreEffectToolRefusal("check unavailable: stale binding");
1916
+ if (!currentCheck(receipt)) {
1917
+ await checkRunner.markStale(receipt.run); receipt = { ...receipt, status: "stale" };
1918
+ // markStale persists and can abort a run: a refusal after it is never proof of absent effects.
1919
+ if (by !== USER) throw new Error("check unavailable: stale binding");
1920
+ }
1921
+ if (op === "hub_check_cancel") {
1922
+ await checkRunner.cancel(receipt.run);
1923
+ const actual = await checkRunner.result(receipt.run);
1924
+ if (!actual) throw new Error("check receipt unavailable after cancellation");
1925
+ return JSON.stringify({ run: actual.run, status: actual.status, cancelled: actual.cancelled });
1926
+ }
1927
+ if (by !== USER && !currentCheck(receipt)) throw new PreEffectToolRefusal("check unavailable: stale binding");
1928
+ if (by === receipt.owner) await checkRunner.consumeOutput(receipt.run);
1929
+ if (by !== USER && (!currentCheck(receipt) || turns.get(by)?.private || contextPii(by))) throw new Error("check unavailable: stale binding");
1930
+ // Policy can change during output cleanup, so form the final projection again.
1931
+ return JSON.stringify(publicCheck(receipt));
1932
+ }
1681
1933
  switch (op) {
1934
+ case "hub_delivery_retry": {
1935
+ 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");
1936
+ 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");
1937
+ if (stopping || recoveryActive()) throw new PreEffectToolRefusal("recovery or shutdown is holding delivery mutations");
1938
+ bus.retryDelivery(by, a.delivery_id);
1939
+ return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
1940
+ }
1682
1941
  case "hub_backlog_list":
1683
1942
  return JSON.stringify(tasks.backlogList(by));
1684
1943
  case "hub_release_list":
@@ -1710,6 +1969,14 @@ export async function startDaemon(opts: DaemonOptions) {
1710
1969
  if (silent && factsOn()) facts.sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
1711
1970
  return [line(t), overlap, tasks.planGuardText(t)].filter(Boolean).join("\n");
1712
1971
  }
1972
+ case "hub_task_cancel": {
1973
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
1974
+ || !Number.isSafeInteger(a.id) || a.id < 1 || typeof a.reason !== "string" || !a.reason.trim() || a.reason.length > 2000
1975
+ || a.superseded_by !== undefined && (typeof a.superseded_by !== "string" || !a.superseded_by.trim() || a.superseded_by.length > 2000)
1976
+ || a.revision !== undefined && (!Number.isSafeInteger(a.revision) || a.revision < 0)) throw new PreEffectToolRefusal("invalid cancellation request");
1977
+ if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("cancellation unavailable during a PII turn");
1978
+ return JSON.stringify(tasks.publicView(await tasks.cancel(a.id, by, { reason: a.reason, supersededBy: a.superseded_by, expectedFlowRevision: a.revision })));
1979
+ }
1713
1980
  case "hub_task_decline":
1714
1981
  return line(await tasks.decline(by, a.id, a.reason));
1715
1982
  case "hub_task_done": {
@@ -1814,8 +2081,13 @@ export async function startDaemon(opts: DaemonOptions) {
1814
2081
  }
1815
2082
  case "task_escalate":
1816
2083
  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");
2084
+ case "route_explain": {
2085
+ const trace = tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass });
2086
+ return [...trace, ...bus.knownPeers().flatMap(peer => {
2087
+ if (trace.some(line => line.startsWith(` hold: ${peer}'s queue is held:`))) return [];
2088
+ const hold = queueHold(peer); return hold ? [`${peer} queue: ${hold}`] : [];
2089
+ })].join("\n");
2090
+ }
1819
2091
  case "turn_revert": {
1820
2092
  // `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
1821
2093
  const turn = turnLog?.get(String(a.turn));
@@ -1840,7 +2112,7 @@ export async function startDaemon(opts: DaemonOptions) {
1840
2112
  }
1841
2113
  function recoveryTaskPreface(peer: PeerId): void {
1842
2114
  if (recoveryPhase !== "restored") return;
1843
- const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !["approved"].includes(task.state));
2115
+ const open = board.list().filter((task) => (task.owner === peer || task.reviewer === peer) && !isClosedTaskState(task.state));
1844
2116
  if (!open.length) return;
1845
2117
  const lines = open.map((task) => {
1846
2118
  const view = tasks.publicView(task) as { title?: unknown; detail?: unknown; state?: unknown; owner?: unknown; reviewer?: unknown };
@@ -1875,6 +2147,7 @@ export async function startDaemon(opts: DaemonOptions) {
1875
2147
  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
2148
  const recoveryReady = () => {
1877
2149
  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;
2150
+ if (checkPending.size !== 0) return false;
1878
2151
  if (!recoveryPeerSnapshot) return true;
1879
2152
  const current = recoveryPeers();
1880
2153
  return recoveryPeerSnapshot.every((saved) => {
@@ -2029,7 +2302,7 @@ export async function startDaemon(opts: DaemonOptions) {
2029
2302
  }
2030
2303
  return now;
2031
2304
  };
2032
- const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer) } : {};
2305
+ const queueHoldStatus = (peer: PeerId, heldBy?: string) => heldBy ? { heldBy, holdNote: queueHold(peer), heldRows: heldRows(peer) } : {};
2033
2306
  const logPermissionStart = (peer: string) => {
2034
2307
  const requested = defaultSources[peer];
2035
2308
  const selected = permissionMode(peer), pending = pendingPermissionDefaults.has(peer);
@@ -2062,6 +2335,8 @@ export async function startDaemon(opts: DaemonOptions) {
2062
2335
  };
2063
2336
  const status = (includeAttentionItems = false, includeAttentionInput = false) => {
2064
2337
  const taskNeeds = board.statusTasks();
2338
+ const taskStalls = tasks.stalled();
2339
+ const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
2065
2340
  const peerIds = statusPeerIds(taskNeeds);
2066
2341
  const view = {
2067
2342
  ...(crashReport.length ? { crash: crashReport } : {}),
@@ -2071,6 +2346,7 @@ export async function startDaemon(opts: DaemonOptions) {
2071
2346
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2072
2347
  lifecycleStages: [...lifecycleConfig.stages],
2073
2348
  protocol: PROTOCOL,
2349
+ features: DAEMON_FEATURES,
2074
2350
  stopping,
2075
2351
  pid: process.pid,
2076
2352
  cwd: opts.cwd,
@@ -2082,11 +2358,15 @@ export async function startDaemon(opts: DaemonOptions) {
2082
2358
  ),
2083
2359
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2084
2360
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2085
- roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id) }])),
2361
+ roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2362
+ planner: { peers: plannerPeers(), source: plannerSource },
2086
2363
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
2087
2364
  ...(sidecar ? { switchyard: sidecar.status } : {}),
2088
2365
  ...(modelRelay ? { models: modelRelay.status() } : {}),
2366
+ isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
2367
+ taskCapabilities: peerCapabilities(),
2089
2368
  tasks: board.counts(),
2369
+ taskStalls, pendingTaskOffers,
2090
2370
  ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
2091
2371
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
2092
2372
  };
@@ -2103,6 +2383,7 @@ export async function startDaemon(opts: DaemonOptions) {
2103
2383
  storageBlocked: !!bus.storageError,
2104
2384
  recovery: recoveryOperationId ? { id: recoveryOperationId, running: true } : undefined,
2105
2385
  tasks: taskNeeds,
2386
+ 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
2387
  budget: Object.fromEntries(Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused ? { paused: { since: row.paused.since, resetsAt: row.paused.resetsAt } } : {}])),
2107
2388
  };
2108
2389
  const attention = needs(attentionInput, Date.now());
@@ -2122,37 +2403,58 @@ export async function startDaemon(opts: DaemonOptions) {
2122
2403
  const taskChanged = board.onChange;
2123
2404
  board.onChange = (task, entry) => {
2124
2405
  try { taskChanged?.(task, entry); }
2125
- finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
2406
+ finally {
2407
+ try { settleSupersededDeliveries(); }
2408
+ finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
2409
+ }
2126
2410
  };
2127
2411
 
2128
2412
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
2129
2413
  // 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
- }))];
2414
+ function heldRows(peer: PeerId) {
2415
+ return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
2416
+ id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
2417
+ kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
2418
+ context: deliveryTaskContext(row.originals, board, peer),
2419
+ }));
2136
2420
  }
2137
2421
  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>`;
2422
+ const rows = heldRows(peer);
2423
+ if (!rows.length) return undefined;
2424
+ 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("; ");
2425
+ }
2426
+ let settlingSuperseded = false;
2427
+ function settleSupersededDeliveries() {
2428
+ if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
2429
+ settlingSuperseded = true;
2430
+ try {
2431
+ for (const row of bus.queueList()) {
2432
+ if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
2433
+ const reason = supersededDeliveryReason(row.originals, board, row.peer);
2434
+ if (!reason) continue;
2435
+ try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
2436
+ catch { /* Busy peers, storage failures and changed revisions remain held for the next queue/board change. */ }
2437
+ }
2438
+ } finally { settlingSuperseded = false; }
2143
2439
  }
2144
2440
  const noticedHolds = new Set<string>();
2441
+ let reportingQueues = false;
2145
2442
  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();
2443
+ if (stopping || settlingSuperseded || reportingQueues) return;
2444
+ reportingQueues = true;
2445
+ try {
2446
+ settleSupersededDeliveries();
2447
+ for (const peer of bus.knownPeers()) {
2448
+ const rows = heldRows(peer);
2449
+ if (!rows.some(row => !noticedHolds.has(row.id))) continue;
2450
+ for (const row of rows) { noticedHolds.add(row.id); supervision.needsReview(peer, row.id); }
2451
+ notify(`${peer} queue ${queueHold(peer)}`);
2452
+ }
2453
+ writeStatus();
2454
+ } finally { reportingQueues = false; }
2155
2455
  };
2456
+ // Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
2457
+ settleSupersededDeliveries();
2156
2458
 
2157
2459
  // When each peer went offline; a peer never seen attached counts from hub start (issue #6).
2158
2460
  const offlineSince = new Map<PeerId, number>();
@@ -2580,6 +2882,7 @@ export async function startDaemon(opts: DaemonOptions) {
2580
2882
  if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
2581
2883
  if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
2582
2884
  const mode = startMode(peer);
2885
+ if (mode === "tui" && (peer === "pi" || peer === "local")) { await ensureSpec(true); await refreshRunner(); }
2583
2886
  const owner = bus.peers.get(peer);
2584
2887
  if (mode === "headless") {
2585
2888
  // A Pi a person runs in a terminal is theirs: a start made for them never takes it over as a headless one.
@@ -2662,6 +2965,7 @@ export async function startDaemon(opts: DaemonOptions) {
2662
2965
  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
2966
  let unmute: (() => void) | undefined;
2664
2967
  try {
2968
+ if (peer === "pi" || peer === "local") { await ensureSpec(true); await refreshRunner(); }
2665
2969
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
2666
2970
  } finally {
2667
2971
  unmute?.();
@@ -2776,7 +3080,7 @@ export async function startDaemon(opts: DaemonOptions) {
2776
3080
  : { source: "acp_usage_update", sessionId, measuredAt: Date.now(), tokens: null, window: null, used: null }, current);
2777
3081
  },
2778
3082
  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 })) }],
3083
+ mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("qwen", true)).map(([name, value]) => ({ name, value })) }],
2780
3084
  preamble: sessionRoleContract("qwen"),
2781
3085
  });
2782
3086
  recoveryTaskPreface("qwen"); await ensurePreface("qwen");
@@ -2828,7 +3132,7 @@ export async function startDaemon(opts: DaemonOptions) {
2828
3132
  current);
2829
3133
  },
2830
3134
  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 })) }],
3135
+ mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv("kimi", true)).map(([name, value]) => ({ name, value })) }],
2832
3136
  preamble: sessionRoleContract("kimi"),
2833
3137
  });
2834
3138
  recoveryTaskPreface("kimi");
@@ -3364,7 +3668,7 @@ export async function startDaemon(opts: DaemonOptions) {
3364
3668
  tasks: boardTasks.map((task) => {
3365
3669
  const view = tasks.publicView(task);
3366
3670
  // 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 } : {}) };
3671
+ 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
3672
  }),
3369
3673
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
3370
3674
  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))) }])),
@@ -3399,6 +3703,8 @@ export async function startDaemon(opts: DaemonOptions) {
3399
3703
  return storedConfigCache.config;
3400
3704
  };
3401
3705
  /** Stored start modes and the scoped instruction policy follow a settings write or undo. */
3706
+ /** #455: the stored planner key, applied at once; planning authority and backlog notices follow config.roles. */
3707
+ const applyPlanner = () => { const stored = storedConfig(); if (stored) { config.planner = stored.planner; settlePlanner(); writeStatus(); } };
3402
3708
  const applyStartModes = () => { const stored = storedConfig(); if (stored) { config.peers = stored.peers; config.protect_instruction_files = stored.protect_instruction_files; } };
3403
3709
  /** Apply only the named peer; unrelated hand edits retain their prior publication scope. */
3404
3710
  const applyRoleSelection = (peer: string) => {
@@ -3435,7 +3741,8 @@ export async function startDaemon(opts: DaemonOptions) {
3435
3741
  },
3436
3742
  });
3437
3743
  const undo = pendingUndo(opts.stateDir);
3438
- return { rows, ...(undo ? { undo } : {}) };
3744
+ const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
3745
+ return { rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
3439
3746
  }
3440
3747
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
3441
3748
  const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false) => {
@@ -3444,7 +3751,7 @@ export async function startDaemon(opts: DaemonOptions) {
3444
3751
  };
3445
3752
  const settingSource = (authority: SettingAuthority) => authority === "terminal" ? "terminal" as const : "dashboard" as const;
3446
3753
  function rosterView() {
3447
- const open = board.list().filter(task => task.state !== "approved");
3754
+ const open = board.list().filter(task => !isClosedTaskState(task.state));
3448
3755
  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
3756
  }
3450
3757
  function rosterCandidate(def: SettingDef, value: SettingValue, undo = false): PeerRoster {
@@ -3494,6 +3801,7 @@ export async function startDaemon(opts: DaemonOptions) {
3494
3801
  else writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => loadConfig(scratch));
3495
3802
  // Apply exactly the authorized key. Other file edits stay pending until the next hub start.
3496
3803
  config.roster = next;
3804
+ settlePlanner(); // #455: an automatic planner follows the roster
3497
3805
  saved = true;
3498
3806
  recordSetting(def.key, before, after, source, undo);
3499
3807
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
@@ -3518,7 +3826,7 @@ export async function startDaemon(opts: DaemonOptions) {
3518
3826
  if (owner instanceof PiPeer && owner.mode === "tui") await owner.resumeFromRoster();
3519
3827
  }
3520
3828
  writeStatus();
3521
- const stayed = !after ? board.list().filter(task => task.state !== "approved" && (task.owner === peer || task.reviewer === peer)) : [];
3829
+ const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
3522
3830
  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
3831
  } catch (error) {
3524
3832
  if (saved) writeStatus();
@@ -3568,7 +3876,7 @@ export async function startDaemon(opts: DaemonOptions) {
3568
3876
  if (!writeConfigSetting(opts.cwd, opts.stateDir, def, value, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch))) return { ok: true, text: `${def.key}: config.local.json already ${value === null ? "leaves it unset" : `holds ${settingText(value)}`}; nothing was written` };
3569
3877
  storedConfigCache = undefined;
3570
3878
  const to = settingScalar(valueAt(storedConfig(), def.path));
3571
- if (def.type === "roles") applyRoleSelection(def.path[1]!); else applyStartModes();
3879
+ if (def.type === "roles") applyRoleSelection(def.path[1]!); else if (def.key === "planner") applyPlanner(); else applyStartModes();
3572
3880
  recordSetting(def.key, from, to, source);
3573
3881
  if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
3574
3882
  // Config trust (#17) can drop a machine-local field at load: the write happened, and the person is told it will not count.
@@ -3595,7 +3903,7 @@ export async function startDaemon(opts: DaemonOptions) {
3595
3903
  const from = read();
3596
3904
  undoSetting(opts.cwd, opts.stateDir, (scratch) => def.type === "roles" ? validateRoleSetting(scratch, def.path[1]!) : loadConfig(scratch), routingCheck);
3597
3905
  storedConfigCache = undefined;
3598
- if (def.type === "roles") applyRoleSelection(def.path[1]!); else applyStartModes();
3906
+ if (def.type === "roles") applyRoleSelection(def.path[1]!); else if (def.key === "planner") applyPlanner(); else applyStartModes();
3599
3907
  recordSetting(def.key, from, read(), settingSource(authority), true);
3600
3908
  if (def.type === "roles") event({ type: "role", peer: def.path[1]!, actor: "user", stage: "assigned", roles: selectedRoles(def.path[1]!, config.roles, roleAssignments, catalog).map(roleIdentity) });
3601
3909
  return { ok: true, text: `${def.key}: put back to ${settingText(read())}` };
@@ -3619,10 +3927,10 @@ export async function startDaemon(opts: DaemonOptions) {
3619
3927
  const peer = def.path.at(-1)!;
3620
3928
  return { ok: true, lines: [permissionBoundary(peer), ...(isPermissionMode(value) ? [`${value}: ${permissionGrant(peer, value)}`] : ["inherit: the shared file's default, or ask"])] };
3621
3929
  }
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"}`] };
3930
+ if (def.store !== "routing") return { ok: true, lines: [`${def.key}: ${settingText(value)}; ${def.applies === "peer start" ? "applies at the peer's next start" : def.applies === "live" ? "in force as soon as it is saved" : "read at the next hub start"}`] };
3623
3931
  try {
3624
3932
  const after = parseRouting(routingText(opts.cwd), routingCandidate(readOverlay(opts.cwd), def, value));
3625
- const open = board.list().filter((task) => task.state !== "approved");
3933
+ const open = board.list().filter((task) => !isClosedTaskState(task.state));
3626
3934
  // A PII task's routing is fixed by its constraint and its text is private: it is counted, not shown.
3627
3935
  const shown = open.filter((task) => !tasks.isPii(task)).slice(0, 20);
3628
3936
  return { ok: true, lines: [`${def.key}: ${settingText(routingValue(currentRouting(opts.cwd, log), def))} -> ${settingText(routingValue(after, def))}`, ...(open.length ? [] : ["no open task: nothing is routed differently now"])],
@@ -3735,6 +4043,7 @@ export async function startDaemon(opts: DaemonOptions) {
3735
4043
  // #272: the rest of what recoveryReady() waits for, so the upgrade screen can name the wait. Display only.
3736
4044
  ...(taskOpsInFlight !== 0 ? ["a task command in flight"] : []),
3737
4045
  ...(tasks.checksPending() !== 0 ? ["completion checks queued or running"] : []),
4046
+ ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
3738
4047
  ...((piReceipts?.inFlight ?? 0) !== 0 ? ["Pi tool calls in flight"] : []),
3739
4048
  ...(releasing ? ["ready tasks being released"] : []),
3740
4049
  ...([...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 +4059,19 @@ export async function startDaemon(opts: DaemonOptions) {
3750
4059
  async function recoveryOp(msg: any): Promise<Record<string, unknown>> {
3751
4060
  if (typeof msg.op !== "string" || !["inspect", "prepare", "commit", "abort", "release"].includes(msg.op)) return recoveryError("unknown recovery operation");
3752
4061
  if (typeof msg.expectedInstanceId !== "string" || msg.expectedInstanceId !== instanceId) return recoveryError("expected daemon instance does not match");
4062
+ const incompatibleCancellationTarget = () => {
4063
+ const ids = board.list().filter(task => task.state === "cancelled").map(task => task.id);
4064
+ return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
4065
+ ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
4066
+ };
4067
+ const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
4068
+ if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4069
+
3753
4070
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
3754
4071
  const pi = bus.peers.get("pi");
3755
4072
  if (pi instanceof PiPeer && pi.state === "idle") {
3756
4073
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4074
+ if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3757
4075
  }
3758
4076
  }
3759
4077
  if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
@@ -3769,6 +4087,7 @@ export async function startDaemon(opts: DaemonOptions) {
3769
4087
  recoveryPeerSnapshot = undefined;
3770
4088
  }
3771
4089
  if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
4090
+ { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3772
4091
  recoveryOperationId = op;
3773
4092
  if (!recoveryPhase) recoveryPhase = "preparing";
3774
4093
  armRecoveryLease();
@@ -3776,6 +4095,7 @@ export async function startDaemon(opts: DaemonOptions) {
3776
4095
  budget.setRecoveryHold(true);
3777
4096
  bus.setRecoveryHold(true);
3778
4097
  await bus.fenceRecovery();
4098
+ { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3779
4099
  const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
3780
4100
  if (!blocked.length && permissions.size === 0 && recoveryReady()) {
3781
4101
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
@@ -3823,6 +4143,7 @@ export async function startDaemon(opts: DaemonOptions) {
3823
4143
  peers,
3824
4144
  integrity: integrity(),
3825
4145
  };
4146
+ { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
3826
4147
  try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
3827
4148
  recoveryCommitted = true;
3828
4149
  clearTimeout(recoveryLeaseTimer);
@@ -4009,7 +4330,7 @@ export async function startDaemon(opts: DaemonOptions) {
4009
4330
  if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
4010
4331
  });
4011
4332
  }
4012
- return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL });
4333
+ return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
4013
4334
  }
4014
4335
  if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
4015
4336
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
@@ -4048,11 +4369,19 @@ export async function startDaemon(opts: DaemonOptions) {
4048
4369
  return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
4049
4370
  } catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
4050
4371
  }
4372
+ case "delivery_holds": {
4373
+ if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
4374
+ try {
4375
+ const held = bus.heldDeliveryHints(c.peer);
4376
+ return void reply(held ? { ok: true, held } : { ok: false, error: "held metadata unavailable" });
4377
+ } catch { return void reply({ ok: false, error: "held metadata unavailable" }); }
4378
+ }
4051
4379
  case "delivery_receipt": {
4052
4380
  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" });
4381
+ 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" });
4382
+ 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
4383
  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" } : {}) });
4384
+ bus.deliveryReceipt(peer.id, { id: msg.deliveryId, state: msg.state, ...(msg.state !== "accepted" ? { reason: reason || "Claude bridge could not confirm notification delivery" } : {}) });
4056
4385
  return void reply({ ok: true });
4057
4386
  }
4058
4387
  case "inbox": {
@@ -4501,6 +4830,7 @@ export async function startDaemon(opts: DaemonOptions) {
4501
4830
  }
4502
4831
  async function stopOnce(): Promise<void> {
4503
4832
  stopping = true;
4833
+ runnerRefreshAbort.abort();
4504
4834
  // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
4505
4835
  try { removeSessions(opts.stateDir, instanceId); } catch { /* the state dir is gone */ }
4506
4836
  checksClosed = true;
@@ -4514,6 +4844,8 @@ export async function startDaemon(opts: DaemonOptions) {
4514
4844
  try {
4515
4845
  // A peer can still be inside memory recall or its native handshake when kill arrives.
4516
4846
  // Drain those starts before taking the final owned-process snapshot.
4847
+ // Fence/abort the runner now, but slow container teardown must not consume the peer-stop deadline.
4848
+ const runnerStopped = Promise.allSettled([checkRunner.stopAll(), refreshingRunner ?? Promise.resolve()]);
4517
4849
  await Promise.allSettled([...starting.values()]);
4518
4850
  // A fingerprint still being detected belongs in the research records written below; its peer must still be
4519
4851
  // attached when it settles, so this comes before the peers stop (each detection is bounded by its probe deadline).
@@ -4522,6 +4854,7 @@ export async function startDaemon(opts: DaemonOptions) {
4522
4854
  // A peer whose stop rejects must not strand the daemon (issue #56): its failure is
4523
4855
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
4524
4856
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
4857
+ await runnerStopped;
4525
4858
  await modelRelay?.close();
4526
4859
  await egress?.close();
4527
4860
  await piReceipts?.close();
@@ -4566,7 +4899,7 @@ export async function startDaemon(opts: DaemonOptions) {
4566
4899
  writeFileSync(tokenFile, token, { mode: 0o600 });
4567
4900
  chmodSync(tokenFile, 0o600);
4568
4901
  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
4902
+ bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
4570
4903
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
4571
4904
  ready = true;
4572
4905
  // #258: approvals whose settle entry a crash cut off are settled now (decisions, contracts and fails reach long-term).