@staix/agent-hub 0.27.0 → 0.28.0

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 (89) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +11 -3
  3. package/docs/agent-notes/adapters.md +4 -2
  4. package/docs/agent-notes/bus.md +4 -4
  5. package/docs/agent-notes/daemon.md +7 -3
  6. package/docs/agent-notes/tasks.md +5 -0
  7. package/docs/agent-notes/tests.md +6 -5
  8. package/docs/design/adapters.md +76 -0
  9. package/docs/design/agent-hub-architecture.md +67 -0
  10. package/docs/design/agent-hub.md +52 -0
  11. package/docs/design/bus.md +48 -0
  12. package/docs/design/task-board.md +86 -0
  13. package/docs/dogfood.md +16 -1
  14. package/docs/operations.md +308 -12
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +11 -3
  17. package/docs/sdlc.md +1 -1
  18. package/docs/security.md +8 -3
  19. package/docs/smoke.md +2 -0
  20. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  21. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  22. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  23. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  24. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  25. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  26. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  27. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  29. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  30. package/docs/verified.json +13 -13
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +338 -112
  34. package/src/adapters/claude-channel.ts +12 -2
  35. package/src/adapters/compat.ts +1 -1
  36. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  39. package/src/adapters/jcode.ts +37 -4
  40. package/src/adapters/qwen-dual.ts +3 -2
  41. package/src/cli/agents.ts +4 -2
  42. package/src/cli/bench.ts +4 -2
  43. package/src/cli/board-read.ts +25 -0
  44. package/src/cli/claude-homes.ts +2 -2
  45. package/src/cli/console-state.ts +8 -3
  46. package/src/cli/help.ts +6 -5
  47. package/src/cli/identity.ts +9 -1
  48. package/src/cli/init-prompt.ts +179 -0
  49. package/src/cli/init-settings.ts +229 -0
  50. package/src/cli/init.ts +102 -9
  51. package/src/cli/launch.ts +3 -1
  52. package/src/cli/main.ts +106 -35
  53. package/src/cli/output.ts +14 -0
  54. package/src/cli/setup-homes.ts +18 -3
  55. package/src/cli/specs.ts +150 -0
  56. package/src/cli/terminal-recovery.ts +35 -11
  57. package/src/hub/board.ts +56 -3
  58. package/src/hub/bus.ts +54 -7
  59. package/src/hub/child-process.ts +6 -4
  60. package/src/hub/config-apply.ts +4 -1
  61. package/src/hub/config-edit.ts +1 -1
  62. package/src/hub/config-tier.ts +1 -0
  63. package/src/hub/control-client.ts +28 -4
  64. package/src/hub/cost-governance.ts +22 -6
  65. package/src/hub/crash.ts +20 -0
  66. package/src/hub/daemon.ts +304 -56
  67. package/src/hub/delivery-hints.ts +6 -3
  68. package/src/hub/delivery-journal.ts +14 -3
  69. package/src/hub/hub-tools.ts +10 -6
  70. package/src/hub/peers.ts +1 -1
  71. package/src/hub/review-brief.ts +18 -0
  72. package/src/hub/routing.ts +7 -3
  73. package/src/hub/settings.ts +2 -0
  74. package/src/hub/specs-config.ts +43 -0
  75. package/src/hub/specs-task.ts +108 -0
  76. package/src/hub/specs.ts +1008 -0
  77. package/src/hub/task-list-page.ts +75 -0
  78. package/src/hub/task-sweep.ts +10 -1
  79. package/src/hub/tasks.ts +448 -40
  80. package/src/hub/terminal-open.ts +10 -5
  81. package/src/hub/tool-request.ts +131 -0
  82. package/src/ui/office-model.ts +17 -1
  83. package/src/ui/office-space.ts +1 -1
  84. package/templates/config.json +1 -0
  85. package/templates/profiles/dev.json +1 -0
  86. package/templates/profiles/general.json +1 -0
  87. package/templates/profiles/research.json +1 -0
  88. package/templates/profiles/writing.json +1 -0
  89. package/templates/specs.block.md +5 -0
package/src/hub/daemon.ts CHANGED
@@ -38,11 +38,13 @@ import { isDenied, sbplString } from "../local/deny.ts";
38
38
  import { hostShellPath } from "./host-executable.ts";
39
39
  import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
40
40
  import { renderHeldHint } from "./delivery-hints.ts";
41
+ import { compactRow, pageTasks, parseListQuery } from "./task-list-page.ts";
41
42
  import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
42
43
  import { MemoryObservations } from "./memory-observations.ts";
43
44
  import { createStalenessProbe } from "./memory-staleness.ts";
44
45
  import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
45
46
  import { planningWipLimits } from "./planning.ts";
47
+ import { ToolWriteRegistry, toolWriteOperation, toolWriteUnknownReply } from "./tool-request.ts";
46
48
  import { projectedDiff } from "./projected-diff.ts";
47
49
  import { listenWithoutPortCollision } from "./listen.ts";
48
50
  import { needs, type NeedsInput } from "../ui/needs.ts";
@@ -54,7 +56,7 @@ import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
54
56
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
55
57
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
56
58
  import { ActivityRing } from "./activity.ts";
57
- import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
59
+ import { JcodeOptionMismatch, JcodeTuiRefusal, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
58
60
  import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
59
61
  import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
60
62
  import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
@@ -104,6 +106,7 @@ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
104
106
  import { createConfigEditor } from "./config-edit-host.ts";
105
107
  import { readEditable } from "./config-edit.ts";
106
108
  import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
109
+ import { SpecTaskInspector } from "./specs-task.ts";
107
110
  import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
108
111
  import { TokenWindow } from "./token-window.ts";
109
112
  import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
@@ -193,7 +196,7 @@ import { projectChain } from "@staix/agent-mem";
193
196
  import { recallFor } from "../memory/recall.ts";
194
197
  import { conflictsOf } from "./conflicts.ts";
195
198
  import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
196
- import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
199
+ import { crashPlan, lossNotice, openCrashNotices, readSessions, removeSessions, writeSessions, type CrashNotice, type SessionsFile } from "./crash.ts";
197
200
  import type { JournalDelivery } from "./delivery-journal.ts";
198
201
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
199
202
  import { soleWorktree, branchHeads, changedPaths, dirtySubmodules, submodulePaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
@@ -218,6 +221,7 @@ import { SourceRegistry } from "./sources.ts";
218
221
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
219
222
  import { nativeRecall } from "../memory/recall.ts";
220
223
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
224
+ import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
221
225
 
222
226
  export interface HubConfig {
223
227
  adapter_modules?: readonly AdapterModuleDeclaration[];
@@ -260,6 +264,7 @@ export interface HubConfig {
260
264
  text_projection?: ProjectionConfig;
261
265
  /** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
262
266
  corpus: { paths: string[] };
267
+ specs: SpecsConfig;
263
268
  catalog_roles?: Record<string, string[]>;
264
269
  conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
265
270
  budget: BudgetConfig;
@@ -352,6 +357,7 @@ export const DEFAULT_CONFIG: HubConfig = {
352
357
  schedules: [],
353
358
  folder_triggers: [],
354
359
  corpus: { paths: [] },
360
+ specs: { ...DEFAULT_SPECS_CONFIG, roots: [] },
355
361
  checks: { timeout_s: 600 },
356
362
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
357
363
  snapshots: { enabled: false, keep: 20 },
@@ -370,14 +376,14 @@ export const DEFAULT_CONFIG: HubConfig = {
370
376
  export { stateDirFor };
371
377
 
372
378
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
373
- const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
379
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
374
380
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
375
381
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
376
382
 
377
383
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
378
384
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
379
385
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
380
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
386
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
381
387
 
382
388
  /** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
383
389
  function rawResearchRunnersPresent(cwd: string): boolean {
@@ -483,6 +489,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
483
489
  resolveProfile(file.profile, file.profile_overrides);
484
490
  projectionConfig(file.text_projection);
485
491
  corpusConfig(file.corpus);
492
+ if (file.specs !== undefined) parseSpecsConfig(file.specs);
486
493
  queueBackpressurePolicy(file.tasks?.queue_backpressure);
487
494
  validateOperationalConfig(file);
488
495
  for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
@@ -599,6 +606,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
599
606
  schedules: schedulesConfig(file.schedules),
600
607
  folder_triggers: foldersConfig(file.folder_triggers),
601
608
  corpus: corpusConfig(file.corpus),
609
+ specs: parseSpecsConfig(file.specs),
602
610
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
603
611
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
604
612
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
@@ -656,6 +664,8 @@ export interface DaemonOptions {
656
664
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
657
665
  /** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
658
666
  kimiHome?: string;
667
+ /** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
668
+ toolWriteReplayMaxEntries?: number;
659
669
  /** Host-only evidence seam; never supplied by project config or control messages. */
660
670
  releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
661
671
  projectId?: string;
@@ -752,6 +762,9 @@ const RESOLUTION_CAUSES: [RegExp, string][] = [
752
762
  [/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
753
763
  [/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
754
764
  [/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
765
+ // #1096: a row that already carries a resolution is settled history, not a stale read. `retry` leaves its parent in `failed` for good because the redelivery is a separate row.
766
+ [/already resolved by retry/i, "already resolved by retry: this failed row is settled history, its redelivery is a separate delivery (ahub queue list shows it); nothing is left to do for this row"],
767
+ [/already resolved by (completed|discard)/i, "already resolved: this delivery was already settled by a person or the conductor; nothing is left to do for this row"],
755
768
  [/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
756
769
  [/already terminal|already resolved/i, "already resolved: the delivery is settled"],
757
770
  [/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
@@ -778,6 +791,7 @@ function capturedRecoverySourceDigest(): string | undefined {
778
791
  export async function startDaemon(opts: DaemonOptions) {
779
792
  // Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
780
793
  assertHubDbCompatible(join(opts.stateDir, "hub.db"));
794
+ if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
781
795
  const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
782
796
  resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
783
797
  const projectId = opts.projectId ?? randomUUID();
@@ -1268,8 +1282,12 @@ export async function startDaemon(opts: DaemonOptions) {
1268
1282
  const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
1269
1283
  const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
1270
1284
  const startedAt = Date.now();
1271
- /** What crash recovery did or asks the user to do, for `ahub status`. */
1272
- const crashReport: string[] = [];
1285
+ /** What crash recovery did or asks the user to do, for `ahub status` (#1082: `crashView` shows the lines that still stand). */
1286
+ const crashNotices: CrashNotice[] = [];
1287
+ /** A crash was reported this run. It never clears: the Jcode headless hint (#1085) has its own end (`tuiServed`), not the notices'. */
1288
+ let crashReported = false;
1289
+ /** The deliveries the crash left in needs_review, by id; filled below once the journal is open. */
1290
+ const crashLostIds = new Set<string>();
1273
1291
 
1274
1292
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
1275
1293
  let inference: Inference | undefined;
@@ -1279,7 +1297,7 @@ export async function startDaemon(opts: DaemonOptions) {
1279
1297
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1280
1298
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1281
1299
  const lost = new Map<PeerId, JournalDelivery[]>();
1282
- 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]);
1300
+ 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]); crashLostIds.add(d.id); }
1283
1301
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
1284
1302
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
1285
1303
  const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
@@ -2033,8 +2051,13 @@ export async function startDaemon(opts: DaemonOptions) {
2033
2051
  // Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
2034
2052
  if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
2035
2053
  return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
2036
- if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true }];
2037
- return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"),
2054
+ if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
2055
+ // #1081: Pi and the local worker run their shell under one sandbox network setting (`local.bash_network`): false is no network at all, an egress proxy or "direct" is some.
2056
+ const network = sandboxNetwork !== false;
2057
+ // A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so such a peer cannot create the task worktree. The hub-made writable worktree is #1115.
2058
+ return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: false,
2059
+ worktreeReason: "git worktree add runs outside the sandbox; this peer cannot create the task worktree (#270; a hub-made worktree is #1115)",
2060
+ ...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
2038
2061
  testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
2039
2062
  specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
2040
2063
  }));
@@ -2077,6 +2100,27 @@ export async function startDaemon(opts: DaemonOptions) {
2077
2100
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
2078
2101
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
2079
2102
  };
2103
+ type SpecRootSnapshot = { peer: PeerId; root: string; executionRoot: string; verifiedRoot?: string; owner?: PeerAdapter; attached?: PeerAdapter; cwd?: string; socket?: Sock; launchId?: string; generation?: string | number; repo?: string };
2104
+ const specInspectors = new Map<string, SpecTaskInspector>();
2105
+ const specInspectorAt = (root: string): SpecTaskInspector => {
2106
+ const cached = specInspectors.get(root);
2107
+ if (cached) { specInspectors.delete(root); specInspectors.set(root, cached); return cached; }
2108
+ const created = new SpecTaskInspector(root, () => config.specs);
2109
+ specInspectors.set(root, created);
2110
+ if (specInspectors.size > 8) specInspectors.delete(specInspectors.keys().next().value!);
2111
+ return created;
2112
+ };
2113
+ const specRootSnapshot = (task: Task): SpecRootSnapshot => {
2114
+ const peer = task.owner ?? USER, binding = executionOwners.get(peer), execution = executionRoots.get(peer) ?? opts.cwd;
2115
+ const root = binding?.socket && !currentExecutionOwner(peer) ? opts.cwd : execution;
2116
+ const verifiedRoot = ownerWriteRoot(peer), attached = bus.peers.get(peer);
2117
+ return { peer, root, executionRoot: execution, ...(verifiedRoot ? { verifiedRoot } : {}), ...(binding ? { owner: binding.owner, cwd: binding.cwd, ...(binding.socket ? { socket: binding.socket } : {}), ...(binding.launchId ? { launchId: binding.launchId } : {}), ...(binding.generation === undefined ? {} : { generation: binding.generation }) } : {}), ...(attached ? { attached } : {}), ...(task.refs.repo ? { repo: task.refs.repo } : {}) };
2118
+ };
2119
+ const sameSpecRoot = (left: SpecRootSnapshot, right: SpecRootSnapshot): boolean => left.peer === right.peer && left.root === right.root
2120
+ && left.executionRoot === right.executionRoot && left.verifiedRoot === right.verifiedRoot && left.owner === right.owner && left.attached === right.attached
2121
+ && left.cwd === right.cwd && left.socket === right.socket && left.launchId === right.launchId && left.generation === right.generation && left.repo === right.repo;
2122
+ const projectSpecs = specInspectorAt(opts.cwd);
2123
+ let specsMissingBlockHintShown = false;
2080
2124
  const tasks: Tasks = new Tasks({
2081
2125
  releaseWindow,
2082
2126
  researchRunners: () => {
@@ -2163,6 +2207,20 @@ export async function startDaemon(opts: DaemonOptions) {
2163
2207
  const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
2164
2208
  return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
2165
2209
  },
2210
+ specsConfig: () => config.specs,
2211
+ specNotice: task => {
2212
+ const snapshot = specRootSnapshot(task);
2213
+ return specInspectorAt(snapshot.root).notice(task.refs.paths ?? []);
2214
+ },
2215
+ inspectSpecs: async (task, head, changedFiles, acks) => {
2216
+ const snapshot = specRootSnapshot(task), inspector = specInspectorAt(snapshot.root);
2217
+ const report = await Promise.resolve(inspector.inspect(head, changedFiles, { complete: changedFiles !== undefined, acks }));
2218
+ const current = specRootSnapshot(task), currentTask = board.get(task.id);
2219
+ if (!sameSpecRoot(snapshot, current) || !currentTask || currentTask.owner !== task.owner) throw new PreEffectToolRefusal("task repository scope changed during spec inspection; retry hub_task_done");
2220
+ if (config.specs.enabled !== true || config.specs.review === "off") return undefined;
2221
+ return report;
2222
+ },
2223
+ specsRead: (action, paths) => projectSpecs.read(action, paths),
2166
2224
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
2167
2225
  board,
2168
2226
  bus,
@@ -2514,7 +2572,7 @@ export async function startDaemon(opts: DaemonOptions) {
2514
2572
  return typeof session === "string" ? session : undefined;
2515
2573
  };
2516
2574
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
2517
- const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.list().some(t => !isClosedTaskState(t.state) && (t.owner === peer || t.reviewer === peer) && tasks.isPii(t));
2575
+ const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
2518
2576
  // #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
2519
2577
  const doings = new Map<PeerId, DoingTracker>();
2520
2578
  const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
@@ -3244,6 +3302,73 @@ export async function startDaemon(opts: DaemonOptions) {
3244
3302
  // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
3245
3303
  // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
3246
3304
  let taskOpsInFlight = 0;
3305
+ const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
3306
+ const toolWriteScope = (c: Client, operation: string): string => {
3307
+ const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
3308
+ const binding = c.toolsOwner ?? c.externalOwner;
3309
+ return JSON.stringify({ actor, role: c.role ?? "", operation, session: c.session === true, launchId: c.launchId ?? null,
3310
+ generation: binding?.generation ?? (c.peer ? connectionGeneration(c.peer) ?? null : null), owner: binding?.owner.id ?? peer?.id ?? null,
3311
+ executionRoot: c.peer ? executionRoots.get(c.peer) ?? null : null });
3312
+ };
3313
+ const taskWriteId = (value: unknown): number | undefined => {
3314
+ const id = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
3315
+ return Number.isInteger(id) ? id : undefined;
3316
+ };
3317
+ const toolWriteCurrent = (c: Client, sock: Sock, operation: string, payload: Record<string, unknown>, scope: string, result?: Record<string, unknown>): boolean => {
3318
+ try {
3319
+ const actor = c.peer ?? USER;
3320
+ if (!c.authed || c.closed || sock.readyState !== WebSocket.OPEN || !c.role || stopping || recoveryActive() && recoveryPhase !== "preparing"
3321
+ || c.peer && !membershipAllowed(c.peer) || c.externalOwner && !currentExternalTools(c, true) || c.toolsOwner && !currentImplementerTools(c)) return false;
3322
+ if (c.session && sessionGate(actor, payload)) return false;
3323
+ if (toolWriteScope(c, operation) !== scope || readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
3324
+ || c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(operation)) return false;
3325
+ const privateContext = actor !== USER && (!!turns.get(actor)?.private || contextPii(actor));
3326
+ const args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
3327
+ const requestedOwner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
3328
+ if (operation === "hub_task_propose" && (!may(actor, "propose") || requestedOwner && PEER_ID.test(requestedOwner)
3329
+ && ![actor, USER, HUB, "digest"].includes(requestedOwner) && !may(actor, "assign"))) return false;
3330
+ const safePiiProposalReceipt = operation === "hub_task_propose" && actor !== USER && result?.ok === true && typeof result.text === "string"
3331
+ && /^task #[1-9]\d*: (?:backlog|proposed|in_progress|in_review|approved|changes_requested|cancelled), owner (?:none|[a-z][a-z0-9-]{0,31}), reviewer (?:none|[a-z][a-z0-9-]{0,31})$/.test(result.text);
3332
+ const taskId = operation === "hub_review" ? taskWriteId(args.id)
3333
+ : result?.ok === true && typeof result.text === "string" ? Number(/task #(\d+):/.exec(result.text)?.[1]) : undefined;
3334
+ if (taskId !== undefined) {
3335
+ const task = Number.isSafeInteger(taskId) && taskId > 0 ? board.get(taskId) : undefined;
3336
+ if (!task) {
3337
+ // Let the original Tasks.review validation return its specific pre-effect refusal for a new/bad target.
3338
+ // A successful cached verdict without its task row is no longer safe to replay.
3339
+ if (operation !== "hub_review" || result?.ok === true) return false;
3340
+ } else {
3341
+ const piiTask = tasks.isPii(task);
3342
+ // ponytail: non-console review/PII replays remain held; narrower privacy needs host-proven per-operation private-turn authority.
3343
+ if (operation === "hub_review" && actor !== USER && privateContext) return false;
3344
+ if (actor !== USER && piiTask && !safePiiProposalReceipt
3345
+ || operation === "hub_review" && actor !== USER && task.reviewer !== actor
3346
+ || actor !== USER && result?.ok === true && typeof result.text === "string" && !tasks.publicMetadataText(result.text) && !safePiiProposalReceipt) return false;
3347
+ }
3348
+ }
3349
+ if (actor !== USER && operation === "hub_task_propose" && result?.ok === true && typeof result.text === "string") {
3350
+ const overlapLines = result.text.split(/\r?\n/).filter(line => line.startsWith("Overlaps "));
3351
+ if (overlapLines.length > 1) return false;
3352
+ if (overlapLines.length === 1) {
3353
+ const line = overlapLines[0]!;
3354
+ const markers = [...line.matchAll(/#([1-9]\d*) \(owner ([^)\r\n]{1,128})\)/g)];
3355
+ const markerPrefixes = [...line.matchAll(/#([^()\r\n]{1,128}) \(owner /g)];
3356
+ if (!markers.length || markers.length > 64 || markerPrefixes.length !== markers.length) return false;
3357
+ const refs = new Set<number>();
3358
+ for (const marker of markers) {
3359
+ const id = Number(marker[1]);
3360
+ if (!Number.isSafeInteger(id) || id < 1) return false;
3361
+ refs.add(id);
3362
+ }
3363
+ if (refs.size !== markers.length) return false;
3364
+ const facts = board.taskPrivacySignals([...refs]);
3365
+ if (facts.length !== refs.size || facts.some(task => tasks.isPii(task))) return false;
3366
+ }
3367
+ }
3368
+ if (actor !== USER && typeof result?.error === "string" && result.outcome !== "unknown" && !tasks.publicMetadataText(result.error)) return false;
3369
+ return true;
3370
+ } catch { return false; }
3371
+ };
3247
3372
  /**
3248
3373
  * #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
3249
3374
  * status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
@@ -3354,6 +3479,10 @@ export async function startDaemon(opts: DaemonOptions) {
3354
3479
  try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
3355
3480
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
3356
3481
  }
3482
+ function inboxHeldMetadata(peer: PeerId) {
3483
+ try { return bus.heldDeliveryHints(peer); }
3484
+ catch { return undefined; } // the inbox pull is already settled; advisory held-row metadata cannot hide its result
3485
+ }
3357
3486
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
3358
3487
  const peerArg = (v: unknown, name: string): PeerId | undefined => {
3359
3488
  if (v == null || v === "") return undefined;
@@ -3504,14 +3633,19 @@ export async function startDaemon(opts: DaemonOptions) {
3504
3633
  return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
3505
3634
  }
3506
3635
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
3636
+ if (inProcess && (op === "hub_task_propose" || op === "hub_review") && a && typeof a === "object" && Object.hasOwn(a, "request_id")) throw new PreEffectToolRefusal("request_id replay keys require the control-protocol task transport");
3507
3637
  if (BOUNDED_TASK_TOOLS.has(op)) {
3508
3638
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
3509
3639
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
3510
3640
  || (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");
3511
3641
  if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
3512
3642
  }
3643
+ if (op === "hub_specs" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
3513
3644
  if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
3514
3645
  if (op === "hub_memory_correct") correctionInput(a);
3646
+ if (op === "hub_task_propose" && by !== USER && (piiTurn || turns.get(by)?.private)) {
3647
+ throw new PreEffectToolRefusal("hub_task_propose is not available while working on a PII task: its text must not leave this machine");
3648
+ }
3515
3649
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
3516
3650
  if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
3517
3651
  const result = await conductor.execute(by, op, a);
@@ -3682,6 +3816,9 @@ export async function startDaemon(opts: DaemonOptions) {
3682
3816
  return JSON.stringify(publicCheck(receipt));
3683
3817
  }
3684
3818
  switch (op) {
3819
+ case "hub_specs":
3820
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "action" && key !== "paths")) throw new PreEffectToolRefusal("hub_specs accepts only action and paths");
3821
+ return JSON.stringify(tasks.specsRead(by, a.action, a.paths));
3685
3822
  case "hub_delivery_retry": {
3686
3823
  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");
3687
3824
  if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
@@ -3689,6 +3826,16 @@ export async function startDaemon(opts: DaemonOptions) {
3689
3826
  bus.retryDelivery(by, a.delivery_id);
3690
3827
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
3691
3828
  }
3829
+ case "hub_delivery_resolve": {
3830
+ if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["delivery_id", "revision", "action", "reason"].includes(key))
3831
+ || typeof a.delivery_id !== "string" || !a.delivery_id.trim() || a.delivery_id.length > 256
3832
+ || !Number.isSafeInteger(a.revision) || a.revision < 0 || !["completed", "discard"].includes(a.action)
3833
+ || typeof a.reason !== "string" || !a.reason.trim() || a.reason.trim().length > 1000 || a.reason.length > 1000
3834
+ || !tasks.publicMetadataText(a.reason.trim()) || !boundedSafe(a.reason.trim())) throw new PreEffectToolRefusal("hub_delivery_resolve needs only delivery_id, revision (the whole number hub_inbox showed), action completed or discard, and a public reason of 1 to 1000 characters; call hub_inbox for the revision and keep private or sensitive text out of the reason");
3835
+ if (stopping || recoveryActive()) throw new PreEffectToolRefusal("the hub is not accepting delivery changes now (recovery or shutdown); run ahub status, then call hub_delivery_resolve again after it recovers");
3836
+ bus.resolveOwnDelivery(by, a.delivery_id, a.revision, a.action, a.reason.trim());
3837
+ return `your held delivery was marked ${a.action}`;
3838
+ }
3692
3839
  case "hub_backlog_list":
3693
3840
  return JSON.stringify(tasks.backlogList(by));
3694
3841
  case "hub_release_new":
@@ -3764,19 +3911,21 @@ export async function startDaemon(opts: DaemonOptions) {
3764
3911
  const t = await tasks.propose(by, a);
3765
3912
  const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
3766
3913
  const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
3767
- if (t.state === "backlog") return [line(t), repoHint, submodules].filter(Boolean).join("\n");
3914
+ const specs = tasks.specNotice(t, by);
3915
+ if (t.state === "backlog") return [line(t), specs, repoHint, submodules].filter(Boolean).join("\n");
3768
3916
  const overlap = tasks.overlaps(t, t.owner === by);
3769
3917
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
3770
- return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
3918
+ return [line(t), specs, tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
3771
3919
  }
3772
3920
  case "hub_task_accept": {
3773
3921
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
3774
3922
  const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
3923
+ const specs = tasks.specNotice(t, by);
3775
3924
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
3776
3925
  const silent = tasks.silentFor(t.id);
3777
3926
  const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
3778
3927
  if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
3779
- return [line(t), overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
3928
+ return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
3780
3929
  }
3781
3930
  case "hub_task_cancel": {
3782
3931
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
@@ -3789,7 +3938,7 @@ export async function startDaemon(opts: DaemonOptions) {
3789
3938
  case "hub_task_decline":
3790
3939
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
3791
3940
  case "hub_task_done": {
3792
- const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
3941
+ const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables, a.spec_ack);
3793
3942
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
3794
3943
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
3795
3944
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -3798,8 +3947,10 @@ export async function startDaemon(opts: DaemonOptions) {
3798
3947
  const last = t.history.at(-1);
3799
3948
  if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
3800
3949
  const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
3801
- const result = tasks.isChecking(t.id) ? `${line(t)}; its check is queued or running, and the result comes as a task message` : line(t);
3802
- return extra ? `${result}\n${extra}` : result;
3950
+ const doneLine = by === USER ? tasks.resultLine(t, by) : line(t);
3951
+ const result = tasks.isChecking(t.id) ? `${doneLine}; its check is queued or running, and the result comes as a task message` : doneLine;
3952
+ const specReport = by === USER ? "" : tasks.specReportText(t, by);
3953
+ return [result, specReport, extra].filter(Boolean).join("\n");
3803
3954
  }
3804
3955
  case "hub_sdlc_record":
3805
3956
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
@@ -3839,7 +3990,13 @@ export async function startDaemon(opts: DaemonOptions) {
3839
3990
  if (!release) throw new PreEffectToolRefusal("no such release");
3840
3991
  releaseId = release.id;
3841
3992
  }
3842
- return JSON.stringify(board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length)).map((t) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; }));
3993
+ // #1083: an agent always pages (default 50 compact rows) because the whole board is over every tool-output limit; the person's console keeps the full array unless it names a paging argument.
3994
+ const listQuery = parseListQuery(a, by !== USER);
3995
+ const shown = board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length));
3996
+ const listRow = (t: Task) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; };
3997
+ if (!listQuery) return JSON.stringify(shown.map(listRow));
3998
+ const page = pageTasks(shown, listQuery);
3999
+ return JSON.stringify({ tasks: page.rows.map(t => listQuery.detail ? listRow(t) : compactRow(listRow(t))), total: page.total, ...(page.next ? { next: page.next } : {}) });
3843
4000
  }
3844
4001
  }
3845
4002
  if (op === "task_show" && by !== USER) {
@@ -4533,6 +4690,18 @@ export async function startDaemon(opts: DaemonOptions) {
4533
4690
  }
4534
4691
  return checkoutInTick.branch;
4535
4692
  };
4693
+ /**
4694
+ * The crash-recovery lines that still stand (#1082), read when status is asked: a line about a peer goes once that peer is attached, and the headline's count is the crash's own deliveries that are
4695
+ * still in needs_review, so it matches `ahub queue list` and reaches zero when the operator has resolved them.
4696
+ */
4697
+ const crashView = (): string[] => {
4698
+ if (!crashNotices.length) return [];
4699
+ const attached = new Set<string>();
4700
+ for (const notice of crashNotices) if (notice.peer !== undefined && bus.peers.has(notice.peer) && bus.peers.get(notice.peer)!.state !== "offline") attached.add(notice.peer);
4701
+ let inFlight = 0;
4702
+ if (crashLostIds.size) { try { for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review" && crashLostIds.has(row.id)) inFlight++; } catch { inFlight = crashLostIds.size; } }
4703
+ return openCrashNotices(crashNotices, { attached, inFlight });
4704
+ };
4536
4705
  const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
4537
4706
  const taskNeeds = board.statusTasks();
4538
4707
  const ownerIsolation = ownerIsolationProjection();
@@ -4540,9 +4709,10 @@ export async function startDaemon(opts: DaemonOptions) {
4540
4709
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
4541
4710
  const peerIds = byKind(statusPeerIds(taskNeeds));
4542
4711
  const queueBackpressure = tasks.queuePressureStatus(peerIds);
4712
+ const crashLines = crashView();
4543
4713
  const view = {
4544
4714
  ...(queueBackpressure ? { queueBackpressure } : {}),
4545
- ...(crashReport.length ? { crash: crashReport } : {}),
4715
+ ...(crashLines.length ? { crash: crashLines } : {}),
4546
4716
  projectId,
4547
4717
  instanceId,
4548
4718
  rooms: roomCommands.statusView(),
@@ -4900,34 +5070,35 @@ export async function startDaemon(opts: DaemonOptions) {
4900
5070
  } catch (error) { log(`session record not written: ${(error as Error).message}`); }
4901
5071
  };
4902
5072
  const recoverAfterCrash = async (prev: SessionsFile) => {
4903
- const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
5073
+ // `peer` marks a line that asks something of that peer's owner (it clears when the peer attaches); a line with neither is what recovery found or did and stays (#1082).
5074
+ const report = (line: string, notice: { peer?: string; inFlight?: true } = {}, shown = line) => { crashNotices.push({ text: line, ...notice }); crashReported = true; notify(`crash recovery: ${shown}`); };
4904
5075
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
4905
- report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
5076
+ report("the previous hub run stopped without shutting down", { inFlight: true }, `the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
4906
5077
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
4907
5078
  for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
4908
- if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`); continue; }
4909
- if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
5079
+ if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`, { peer: step.peer }); continue; }
5080
+ if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session", { peer: step.peer }); continue; }
4910
5081
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
4911
5082
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
4912
5083
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
4913
5084
  if (step.resume) {
4914
5085
  const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
4915
5086
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
4916
- report(`pi not resumed (${String(r.error)}); ${step.how}`);
5087
+ report(`pi not resumed (${String(r.error)}); ${step.how}`); // what happened, true after a fresh start too: history. Whether Pi is back is the last line of this branch.
4917
5088
  } else report(step.how);
4918
5089
  // #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
4919
- if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
5090
+ if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless", { peer: step.peer }); continue; }
4920
5091
  const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
4921
5092
  const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
4922
- report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
5093
+ report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`, fresh.ok ? {} : { peer: step.peer });
4923
5094
  continue;
4924
5095
  }
4925
5096
  if (!step.resume || !autoResume()) {
4926
- report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
5097
+ report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`, { peer: step.peer });
4927
5098
  continue;
4928
5099
  }
4929
5100
  const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
4930
- report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`);
5101
+ report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`, r.ok ? {} : { peer: step.peer });
4931
5102
  }
4932
5103
  writeStatus();
4933
5104
  };
@@ -5361,7 +5532,7 @@ export async function startDaemon(opts: DaemonOptions) {
5361
5532
  if (!isStartablePeer(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, qwen (when on roster), pi (when enabled) and local" };
5362
5533
  if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
5363
5534
  if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
5364
- if (recoveryActive()) return { ok: false, error: "recovery is holding mutations" };
5535
+ if (recoveryActive()) return { ok: false, error: startHeldByRecovery(peer) };
5365
5536
  const continuity = definitionOf(peer)?.start.autoStart === by
5366
5537
  ? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
5367
5538
  : undefined;
@@ -5423,6 +5594,15 @@ export async function startDaemon(opts: DaemonOptions) {
5423
5594
  /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
5424
5595
  const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
5425
5596
 
5597
+ /** The refusal of a start request while recovery holds mutations (#1088): the old sentence first, then whether the peer already runs and the step that works. */
5598
+ const startHeldByRecovery = (peer: unknown): string => {
5599
+ const held = "recovery is holding mutations";
5600
+ if (typeof peer !== "string" || !PEER_ID.test(peer)) return `${held}; no peer was started. Wait for the recovery to finish (ahub recovery status shows where it is), then start the peer again`;
5601
+ const owner = bus.peers.get(peer);
5602
+ if (owner && owner.state !== "offline") return `${held}, and ${peer} is already running, so there is nothing to start. Wait for the recovery to finish (ahub recovery status shows where it is), then check ${peer} with ahub status. If the recovery names ${peer} as blocked, a person repairs it with ahub recovery reattach <operation-id> ${peer}, or ends the hold with ahub recovery resume or abort`;
5603
+ return `${held}, so ${peer} was not started. Wait for the recovery to finish (ahub recovery status shows where it is), then start it with ${startCommand(peer)}`;
5604
+ };
5605
+
5426
5606
  async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
5427
5607
  operational?.current();
5428
5608
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
@@ -5657,6 +5837,7 @@ export async function startDaemon(opts: DaemonOptions) {
5657
5837
  ...(instance?.provider ? { provider: instance.provider } : {}),
5658
5838
  ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
5659
5839
  toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
5840
+ tuiHintContext: () => ({ tui: startMode(peer) === "tui", afterCrash: crashReported }),
5660
5841
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
5661
5842
  // #801: Jcode 0.99.1's usage_update is `{ used, size }`, the live session's context occupancy: a context reading bound to the adapter's own session id, never a tokens event
5662
5843
  // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
@@ -6437,6 +6618,7 @@ export async function startDaemon(opts: DaemonOptions) {
6437
6618
  return {
6438
6619
  id: row.id, peer: row.peer, state: row.state, revision: row.revision,
6439
6620
  ...roomChat.evidence(row.peer, row.id),
6621
+ ...(detail && bus.deliveryResolution(row.id) ? { resolvedBy: bus.deliveryResolution(row.id) } : {}),
6440
6622
  ...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
6441
6623
  createdAt: row.createdAt, updatedAt: row.updatedAt,
6442
6624
  envelopeIds: originals.map((env) => env.id),
@@ -7605,6 +7787,7 @@ export async function startDaemon(opts: DaemonOptions) {
7605
7787
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
7606
7788
  };
7607
7789
  const incompatibleRecoveryTarget = () => {
7790
+ if (config.specs.enabled && config.specs.review === "require" && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("design_specs"))) return "target lacks design_specs; specs.enabled is on and specs.review is require; use a supporting release or explicitly change specs.enabled/specs.review";
7608
7791
  if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
7609
7792
  if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
7610
7793
  if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
@@ -7955,9 +8138,12 @@ export async function startDaemon(opts: DaemonOptions) {
7955
8138
  const c = sock.data;
7956
8139
  let refusalRecorded=false;
7957
8140
  const reply = (body: Record<string, unknown>) => {
8141
+ const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
8142
+ && toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
8143
+ let writeRefusal: string | undefined;
7958
8144
  const label=operationalRequestLabel(msg);
7959
- if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) body = { ok: false, error: "external instance launch or execution root changed" };
7960
- if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed", ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) };
8145
+ if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) { writeRefusal = "external instance launch or execution root changed"; body = { ok: false, error: writeRefusal }; }
8146
+ if (c.toolsOwner && !currentImplementerTools(c)) { writeRefusal = "Claude child generation or execution root changed"; body = { ok: false, error: writeRefusal, ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) }; }
7961
8147
  if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
7962
8148
  if(body.authorityRefused===true) {
7963
8149
  if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
@@ -7965,8 +8151,45 @@ export async function startDaemon(opts: DaemonOptions) {
7965
8151
  body={...body,error,refusalAudited:refusalRecorded};
7966
8152
  } else body={...body,authorityRefused:false};
7967
8153
  }
8154
+ if (writeRequestId && body.requestId !== writeRequestId) {
8155
+ const authority = { ...(body.authorityRefused === true || body.authorityRefused === false ? { authorityRefused: body.authorityRefused } : {}),
8156
+ ...(typeof body.authorityScope === "string" ? { authorityScope: body.authorityScope } : {}), ...(typeof body.refusalAudited === "boolean" ? { refusalAudited: body.refusalAudited } : {}) };
8157
+ body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
8158
+ }
7968
8159
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
7969
8160
  };
8161
+ const runTaskRequest = (run: () => Promise<string>, extraCurrent: () => void = () => {}, formatError: (error: unknown) => Record<string, unknown> = error => ({ t: "task", ok: false, error: error instanceof Error ? error.message : "task write refused" })): void => {
8162
+ const operation = toolWriteOperation(msg);
8163
+ const payload = { t: "task", op: msg.op, args: msg.args ?? {} };
8164
+ const actor = c.peer ?? USER, args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
8165
+ const knownPreEffectRefusal = operation !== undefined && (
8166
+ readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
8167
+ || operation === "hub_task_propose" && (!may(actor, "propose") || (() => {
8168
+ const owner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
8169
+ return !!owner && PEER_ID.test(owner) && ![actor, USER, HUB, "digest"].includes(owner) && !may(actor, "assign");
8170
+ })())
8171
+ || operation === "hub_review" && actor !== USER && (() => {
8172
+ const id = taskWriteId(args.id);
8173
+ if (id === undefined || id < 1) return false;
8174
+ const task = board.get(id);
8175
+ return !!task && task.reviewer !== actor;
8176
+ })()
8177
+ );
8178
+ if (!operation || msg.requestId === undefined || knownPreEffectRefusal) {
8179
+ void run().then(text => reply({ t: "task", ok: true, text }), error => reply(formatError(error)));
8180
+ return;
8181
+ }
8182
+ const scope = toolWriteScope(c, operation);
8183
+ void toolWriteRegistry.run({ id: msg.requestId, scope, bucket: actor, payload,
8184
+ current: result => toolWriteCurrent(c, sock, operation, payload, scope, result) && (() => { try { extraCurrent(); return true; } catch { return false; } })(),
8185
+ }, async () => {
8186
+ try { return { t: "task", ok: true, text: await run() }; }
8187
+ catch (error) {
8188
+ if (error instanceof PreEffectToolRefusal || error instanceof OperationalAuthorityError) return formatError(error);
8189
+ throw error;
8190
+ }
8191
+ }).then(result => reply(result), () => reply(toolWriteUnknownReply(payload, typeof msg.requestId === "string" ? msg.requestId : undefined)));
8192
+ };
7970
8193
  if (!c.authed) {
7971
8194
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7972
8195
  if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
@@ -8186,7 +8409,11 @@ export async function startDaemon(opts: DaemonOptions) {
8186
8409
  if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
8187
8410
  if (msg.op === "list") {
8188
8411
  if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
8189
- return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row,false,c.role!=="console")) });
8412
+ if (msg.all !== undefined && typeof msg.all !== "boolean") return void reply({ ok: false, error: "all is true or false" });
8413
+ // #1096: a row an operator action already settled (a retry parent left `failed` for good, a discarded or completed row) is history, not open work: hidden unless `all`.
8414
+ const everything = bus.queueList(msg.peer), settled = msg.all === true ? new Set<string>() : bus.settledIds();
8415
+ const shown = everything.filter((row) => !settled.has(row.id));
8416
+ return void reply({ ok: true, deliveries: shown.map((row) => queueView(row,false,c.role!=="console")), ...(shown.length < everything.length ? { hiddenSettled: everything.length - shown.length } : {}) });
8190
8417
  }
8191
8418
  if (msg.op === "discard") {
8192
8419
  // #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
@@ -8194,10 +8421,18 @@ export async function startDaemon(opts: DaemonOptions) {
8194
8421
  // because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
8195
8422
  if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
8196
8423
  if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
8197
- const state = msg.state === undefined ? "queued" : msg.state;
8198
- if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
8199
- const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
8200
- if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
8424
+ // #1096: `states` (a list of queued, needs_review, failed) clears several kinds in one call, and a `failed` row counts only while no resolution settled it. The older single `state` keeps its meaning.
8425
+ const KINDS = ["queued", "needs_review", "failed"] as const;
8426
+ if (msg.states !== undefined && msg.state !== undefined) return void reply({ ok: false, error: "give state or states, not both" });
8427
+ if (msg.states !== undefined && (!Array.isArray(msg.states) || !msg.states.length || msg.states.some((entry: unknown) => !(KINDS as readonly unknown[]).includes(entry)) || new Set(msg.states).size !== msg.states.length)) return void reply({ ok: false, error: `states is a list of ${KINDS.join(", ")}` });
8428
+ if (msg.states === undefined && msg.state !== undefined && msg.state !== "queued" && msg.state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
8429
+ const wanted: readonly string[] = msg.states ?? [msg.state === undefined ? "queued" : msg.state];
8430
+ const settledRows = wanted.includes("failed") ? bus.settledIds() : new Set<string>();
8431
+ const rows = bus.queueList(msg.peer).filter((row) => wanted.includes(row.state) && !(row.state === "failed" && settledRows.has(row.id)));
8432
+ const byState = Object.fromEntries(wanted.map((kind) => [kind, rows.filter((row) => row.state === kind).length]));
8433
+ const state = wanted.length === 1 ? wanted[0]! : wanted.join("+");
8434
+ const many = msg.states !== undefined ? { states: wanted, byState } : {};
8435
+ if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded: 0, refused: 0 });
8201
8436
  if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
8202
8437
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
8203
8438
  let discarded = 0; const refused: Record<string, number> = {};
@@ -8206,7 +8441,7 @@ export async function startDaemon(opts: DaemonOptions) {
8206
8441
  // discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
8207
8442
  const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
8208
8443
  try {
8209
- if (!current || current.state !== state) throw new Error("unknown delivery");
8444
+ if (!current || current.state !== row.state) throw new Error("unknown delivery");
8210
8445
  bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
8211
8446
  if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
8212
8447
  discarded++;
@@ -8214,7 +8449,7 @@ export async function startDaemon(opts: DaemonOptions) {
8214
8449
  catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
8215
8450
  }
8216
8451
  writeStatus();
8217
- return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
8452
+ return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
8218
8453
  }
8219
8454
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
8220
8455
  if (msg.op === "show") {
@@ -8248,6 +8483,7 @@ export async function startDaemon(opts: DaemonOptions) {
8248
8483
  return void reply({ ok: true });
8249
8484
  }
8250
8485
  case "inbox": {
8486
+ if (stopping || recoveryActive()) return void reply({ ok: false, error: "the hub is not serving inbox reads now (recovery or shutdown); run ahub status, then call hub_inbox again after it recovers" });
8251
8487
  if (c.toolsOwner) {
8252
8488
  const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
8253
8489
  try {
@@ -8255,9 +8491,9 @@ export async function startDaemon(opts: DaemonOptions) {
8255
8491
  const pending = bus.pendingEnvelopes(peer);
8256
8492
  if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
8257
8493
  if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
8258
- const envs = bus.pull(peer);
8494
+ const envs = bus.pull(peer, { allowNeedsReviewHold: true });
8259
8495
  if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
8260
- return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
8496
+ return void reply({ ok: true, envs, held: inboxHeldMetadata(peer), waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
8261
8497
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
8262
8498
  }
8263
8499
  // The attached session can explicitly claim queued work even during a native turn (#493).
@@ -8265,7 +8501,7 @@ export async function startDaemon(opts: DaemonOptions) {
8265
8501
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
8266
8502
  if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
8267
8503
  try {
8268
- const envs = bus.pull(peer.id);
8504
+ const envs = bus.pull(peer.id, { allowNeedsReviewHold: true });
8269
8505
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
8270
8506
  peer.lastInboxAt = Date.now();
8271
8507
  let polling: ReturnType<InboxPolling["read"]>;
@@ -8276,7 +8512,7 @@ export async function startDaemon(opts: DaemonOptions) {
8276
8512
  } catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
8277
8513
  writeStatus();
8278
8514
  if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
8279
- return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
8515
+ return void reply({ ok: true, envs, held: inboxHeldMetadata(peer.id), waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
8280
8516
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
8281
8517
  }
8282
8518
  case "delivery_complete": {
@@ -8959,8 +9195,18 @@ export async function startDaemon(opts: DaemonOptions) {
8959
9195
  }
8960
9196
  return void reply({ ok: false, error: "unknown room operation" });
8961
9197
  }
8962
- case "status":
8963
- return void reply({ t: "status", status: status(c.role === "console", false, true) });
9198
+ case "status": {
9199
+ const current = status(c.role === "console", false, true);
9200
+ if ((c.role === "console" || c.role === "tools") && msg.specs === true) {
9201
+ const summary = projectSpecs.status();
9202
+ if (summary) {
9203
+ const missingBlockHint = !specsMissingBlockHintShown && projectSpecs.missingBlock() === true;
9204
+ if (missingBlockHint) specsMissingBlockHintShown = true;
9205
+ return void reply({ t: "status", status: { ...current, specs: { ...summary, ...(missingBlockHint ? { missingBlockHint: true } : {}) } } });
9206
+ }
9207
+ }
9208
+ return void reply({ t: "status", status: current });
9209
+ }
8964
9210
  case "cli_audit_ack": {
8965
9211
  if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
8966
9212
  if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
@@ -9036,8 +9282,11 @@ export async function startDaemon(opts: DaemonOptions) {
9036
9282
  case "native_tui_launch": {
9037
9283
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
9038
9284
  const peer = String(msg.peer), owner = bus.peers.get(peer);
9039
- if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
9040
- peer !== definitionOf(peer)?.kind || !isJcodePeer(owner) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
9285
+ // #1085: every refusal names its cause class, in the reply and as one hub.log line. The class is a closed word: never a session id, launch id, socket or path.
9286
+ const refuseTui = (code: string, error: string, resumed?: boolean) => { log(`native_tui_launch jcode refused: ${code}${resumed ? " (resumed session)" : ""}`); return void reply({ ok: false, code, ...(resumed ? { resumed: true } : {}), error }); };
9287
+ if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer)) return refuseTui("hub_busy", "owned native TUI launch is unavailable");
9288
+ if (peer !== definitionOf(peer)?.kind || !isJcodePeer(owner)) return refuseTui("not_running", "owned native TUI launch is unavailable");
9289
+ if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return refuseTui("mcp_stale", "owned native TUI launch is unavailable");
9041
9290
  try {
9042
9291
  if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
9043
9292
  const options = msg.args ?? {};
@@ -9045,7 +9294,8 @@ export async function startDaemon(opts: DaemonOptions) {
9045
9294
  return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
9046
9295
  } catch (error) {
9047
9296
  if (error instanceof JcodeOptionMismatch) return void reply({ ok: false, option: error.option, error: `owned Jcode launch differs from its running session in ${error.option}; start without it or stop jcode first` });
9048
- return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" });
9297
+ if (error instanceof JcodeTuiRefusal) return refuseTui(error.code, "owned Jcode launch or saved session could not be verified; details withheld", owner.resumedSession);
9298
+ return refuseTui("bad_request", "owned Jcode launch or saved session could not be verified; details withheld");
9049
9299
  }
9050
9300
  }
9051
9301
  case "peer_start":
@@ -9060,7 +9310,7 @@ export async function startDaemon(opts: DaemonOptions) {
9060
9310
  return;
9061
9311
  case "start":
9062
9312
  if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
9063
- if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: "recovery is holding mutations" });
9313
+ if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: startHeldByRecovery(msg.peer) });
9064
9314
  if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
9065
9315
  startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
9066
9316
  .catch((e: Error) => ({ ok: false, error: e.message }))
@@ -9093,10 +9343,8 @@ export async function startDaemon(opts: DaemonOptions) {
9093
9343
  // #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
9094
9344
  let current: () => void;
9095
9345
  try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
9096
- void operationalContext.run({ actor: c.peer, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})).then(
9097
- (text) => reply({ t: "task", ok: true, text }),
9098
- (e: Error) => reply({ t: "task", ...operationalFailure(e) }),
9099
- );
9346
+ runTaskRequest(() => operationalContext.run({ actor: c.peer!, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})), current,
9347
+ error => ({ t: "task", ...operationalFailure(error) }));
9100
9348
  return;
9101
9349
  }
9102
9350
  let invocation: AbortController | undefined;
@@ -9109,14 +9357,14 @@ export async function startDaemon(opts: DaemonOptions) {
9109
9357
  invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
9110
9358
  }
9111
9359
  const run = async () => c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {});
9112
- (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
9360
+ const execute = () => (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
9113
9361
  const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
9114
9362
  if (!live) invocation?.abort();
9115
9363
  return live;
9116
- } }, run) : run()).then(
9117
- (text) => reply({ t: "task", ok: true, text }),
9118
- (e: Error) => reply({ t: "task", ok: false, error: e.message }),
9119
- ).finally(() => { if (invocation && c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
9364
+ } }, run) : run());
9365
+ if (invocation) void execute().then(text => reply({ t: "task", ok: true, text }), (error: Error) => reply({ t: "task", ok: false, error: error.message }))
9366
+ .finally(() => { if (c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
9367
+ else runTaskRequest(execute);
9120
9368
  return;
9121
9369
  case "pause":
9122
9370
  case "resume": {