@staix/agent-hub 0.27.0 → 0.29.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 (106) hide show
  1. package/CHANGELOG.md +33 -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 +9 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/tasks.md +7 -0
  8. package/docs/agent-notes/tests.md +7 -5
  9. package/docs/design/adapters.md +76 -0
  10. package/docs/design/agent-hub-architecture.md +67 -0
  11. package/docs/design/agent-hub.md +52 -0
  12. package/docs/design/bus.md +48 -0
  13. package/docs/design/task-board.md +86 -0
  14. package/docs/dogfood.md +21 -3
  15. package/docs/operations.md +370 -12
  16. package/docs/profiles.md +1 -0
  17. package/docs/quickstart.md +19 -3
  18. package/docs/sdlc.md +2 -2
  19. package/docs/security.md +24 -4
  20. package/docs/smoke.md +2 -0
  21. package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
  22. package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-11-02-0.28.0.md +54 -0
  24. package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
  25. package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
  26. package/docs/verification/2026-11-02-control-write-after.json +139 -0
  27. package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
  28. package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
  29. package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
  30. package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
  31. package/docs/verification/2026-11-03-0.29.0.md +59 -0
  32. package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
  33. package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
  34. package/docs/verification/2026-11-03-live-board-latency.json +556 -0
  35. package/docs/verification/2026-11-03-live-board-latency.md +64 -0
  36. package/docs/verified.json +14 -14
  37. package/package.json +2 -2
  38. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  39. package/plugins/agent-hub/server.js +857 -125
  40. package/src/adapters/claude-channel.ts +12 -2
  41. package/src/adapters/compat.ts +1 -1
  42. package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
  43. package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
  44. package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
  45. package/src/adapters/jcode.ts +37 -4
  46. package/src/adapters/qwen-dual.ts +3 -2
  47. package/src/cli/agents.ts +4 -2
  48. package/src/cli/bench.ts +4 -2
  49. package/src/cli/board-read.ts +25 -0
  50. package/src/cli/claude-homes.ts +2 -2
  51. package/src/cli/console-state.ts +8 -3
  52. package/src/cli/help.ts +6 -5
  53. package/src/cli/identity.ts +9 -1
  54. package/src/cli/init-prompt.ts +179 -0
  55. package/src/cli/init-settings.ts +229 -0
  56. package/src/cli/init.ts +102 -9
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +106 -35
  59. package/src/cli/output.ts +19 -0
  60. package/src/cli/recovery-package.ts +9 -0
  61. package/src/cli/setup-homes.ts +18 -3
  62. package/src/cli/specs.ts +150 -0
  63. package/src/cli/terminal-recovery.ts +35 -11
  64. package/src/hub/board.ts +59 -5
  65. package/src/hub/bus.ts +54 -7
  66. package/src/hub/child-process.ts +6 -4
  67. package/src/hub/config-apply.ts +4 -1
  68. package/src/hub/config-edit.ts +1 -1
  69. package/src/hub/config-tier.ts +1 -0
  70. package/src/hub/control-client.ts +43 -4
  71. package/src/hub/control-write-trace.ts +409 -0
  72. package/src/hub/cost-governance.ts +22 -6
  73. package/src/hub/crash.ts +20 -0
  74. package/src/hub/daemon.ts +470 -98
  75. package/src/hub/delivery-hints.ts +6 -3
  76. package/src/hub/delivery-journal.ts +14 -3
  77. package/src/hub/hub-tools.ts +10 -6
  78. package/src/hub/peers.ts +1 -1
  79. package/src/hub/review-brief.ts +18 -0
  80. package/src/hub/routing.ts +14 -3
  81. package/src/hub/runtime-capture-prune.ts +4 -1
  82. package/src/hub/sdlc-release-authorization.ts +13 -4
  83. package/src/hub/sdlc-release.ts +23 -10
  84. package/src/hub/sdlc.ts +15 -2
  85. package/src/hub/settings.ts +2 -0
  86. package/src/hub/specs-config.ts +43 -0
  87. package/src/hub/specs-task.ts +108 -0
  88. package/src/hub/specs.ts +1008 -0
  89. package/src/hub/task-list-page.ts +75 -0
  90. package/src/hub/task-sweep.ts +10 -1
  91. package/src/hub/task-worktree.ts +331 -0
  92. package/src/hub/tasks.ts +606 -53
  93. package/src/hub/terminal-open.ts +10 -5
  94. package/src/hub/tool-request.ts +131 -0
  95. package/src/local/deny.ts +49 -0
  96. package/src/local/tools.ts +4 -2
  97. package/src/memory/brief.ts +5 -0
  98. package/src/memory/client.ts +5 -2
  99. package/src/ui/office-model.ts +17 -1
  100. package/src/ui/office-space.ts +1 -1
  101. package/templates/config.json +1 -0
  102. package/templates/profiles/dev.json +1 -0
  103. package/templates/profiles/general.json +1 -0
  104. package/templates/profiles/research.json +1 -0
  105. package/templates/profiles/writing.json +1 -0
  106. package/templates/specs.block.md +5 -0
package/src/hub/daemon.ts CHANGED
@@ -38,11 +38,14 @@ 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";
48
+ import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
46
49
  import { projectedDiff } from "./projected-diff.ts";
47
50
  import { listenWithoutPortCollision } from "./listen.ts";
48
51
  import { needs, type NeedsInput } from "../ui/needs.ts";
@@ -54,7 +57,7 @@ import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
54
57
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
55
58
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
56
59
  import { ActivityRing } from "./activity.ts";
57
- import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
60
+ import { JcodeOptionMismatch, JcodeTuiRefusal, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
58
61
  import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
59
62
  import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
60
63
  import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
@@ -104,6 +107,7 @@ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
104
107
  import { createConfigEditor } from "./config-edit-host.ts";
105
108
  import { readEditable } from "./config-edit.ts";
106
109
  import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
110
+ import { SpecTaskInspector } from "./specs-task.ts";
107
111
  import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
108
112
  import { TokenWindow } from "./token-window.ts";
109
113
  import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
@@ -171,6 +175,7 @@ import { autonomyPreset, autonomyEnvironment, planAutonomy, autonomyRemovalKeys,
171
175
  import { readAutonomyDefaults } from "./autonomy-defaults.ts";
172
176
  import { AUTONOMY_UNDO, autonomyOwnershipDigest, autonomyUndoKeys, writeAutonomyPreset, LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
173
177
  import { githubOriginRepository, githubRemoteRepository } from "./task-repository.ts";
178
+ import { TaskWorktrees, originVerdict } from "./task-worktree.ts";
174
179
  import { Bus } from "./bus.ts";
175
180
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
176
181
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -193,7 +198,7 @@ import { projectChain } from "@staix/agent-mem";
193
198
  import { recallFor } from "../memory/recall.ts";
194
199
  import { conflictsOf } from "./conflicts.ts";
195
200
  import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
196
- import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
201
+ import { crashPlan, lossNotice, openCrashNotices, readSessions, removeSessions, writeSessions, type CrashNotice, type SessionsFile } from "./crash.ts";
197
202
  import type { JournalDelivery } from "./delivery-journal.ts";
198
203
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
199
204
  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 +223,7 @@ import { SourceRegistry } from "./sources.ts";
218
223
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
219
224
  import { nativeRecall } from "../memory/recall.ts";
220
225
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
226
+ import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
221
227
 
222
228
  export interface HubConfig {
223
229
  adapter_modules?: readonly AdapterModuleDeclaration[];
@@ -260,6 +266,7 @@ export interface HubConfig {
260
266
  text_projection?: ProjectionConfig;
261
267
  /** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
262
268
  corpus: { paths: string[] };
269
+ specs: SpecsConfig;
263
270
  catalog_roles?: Record<string, string[]>;
264
271
  conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
265
272
  budget: BudgetConfig;
@@ -352,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
352
359
  schedules: [],
353
360
  folder_triggers: [],
354
361
  corpus: { paths: [] },
362
+ specs: { ...DEFAULT_SPECS_CONFIG, roots: [] },
355
363
  checks: { timeout_s: 600 },
356
364
  // Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
357
365
  snapshots: { enabled: false, keep: 20 },
@@ -370,14 +378,14 @@ export const DEFAULT_CONFIG: HubConfig = {
370
378
  export { stateDirFor };
371
379
 
372
380
  /** 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"]);
381
+ 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
382
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
375
383
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
376
384
 
377
385
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
378
386
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
379
387
  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"];
388
+ 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
389
 
382
390
  /** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
383
391
  function rawResearchRunnersPresent(cwd: string): boolean {
@@ -483,6 +491,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
483
491
  resolveProfile(file.profile, file.profile_overrides);
484
492
  projectionConfig(file.text_projection);
485
493
  corpusConfig(file.corpus);
494
+ if (file.specs !== undefined) parseSpecsConfig(file.specs);
486
495
  queueBackpressurePolicy(file.tasks?.queue_backpressure);
487
496
  validateOperationalConfig(file);
488
497
  for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
@@ -599,6 +608,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
599
608
  schedules: schedulesConfig(file.schedules),
600
609
  folder_triggers: foldersConfig(file.folder_triggers),
601
610
  corpus: corpusConfig(file.corpus),
611
+ specs: parseSpecsConfig(file.specs),
602
612
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
603
613
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
604
614
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
@@ -656,6 +666,8 @@ export interface DaemonOptions {
656
666
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
657
667
  /** 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
668
  kimiHome?: string;
669
+ /** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
670
+ toolWriteReplayMaxEntries?: number;
659
671
  /** Host-only evidence seam; never supplied by project config or control messages. */
660
672
  releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
661
673
  projectId?: string;
@@ -752,6 +764,9 @@ const RESOLUTION_CAUSES: [RegExp, string][] = [
752
764
  [/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
765
  [/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
754
766
  [/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
767
+ // #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.
768
+ [/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"],
769
+ [/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
770
  [/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
756
771
  [/already terminal|already resolved/i, "already resolved: the delivery is settled"],
757
772
  [/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
@@ -778,6 +793,7 @@ function capturedRecoverySourceDigest(): string | undefined {
778
793
  export async function startDaemon(opts: DaemonOptions) {
779
794
  // Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
780
795
  assertHubDbCompatible(join(opts.stateDir, "hub.db"));
796
+ if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
781
797
  const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
782
798
  resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
783
799
  const projectId = opts.projectId ?? randomUUID();
@@ -1194,7 +1210,7 @@ export async function startDaemon(opts: DaemonOptions) {
1194
1210
  let parsed: any;
1195
1211
  try {
1196
1212
  parsed = JSON.parse(String(data));
1197
- onMessage(sock, parsed);
1213
+ onControlMessage(sock, parsed);
1198
1214
  } catch (e) {
1199
1215
  if (isDatabaseBusy(e) && sock.data.authed && Number.isSafeInteger(parsed?.rid)) {
1200
1216
  const busy = new RecoveryDatabaseBusy();
@@ -1268,8 +1284,12 @@ export async function startDaemon(opts: DaemonOptions) {
1268
1284
  const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
1269
1285
  const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
1270
1286
  const startedAt = Date.now();
1271
- /** What crash recovery did or asks the user to do, for `ahub status`. */
1272
- const crashReport: string[] = [];
1287
+ /** What crash recovery did or asks the user to do, for `ahub status` (#1082: `crashView` shows the lines that still stand). */
1288
+ const crashNotices: CrashNotice[] = [];
1289
+ /** A crash was reported this run. It never clears: the Jcode headless hint (#1085) has its own end (`tuiServed`), not the notices'. */
1290
+ let crashReported = false;
1291
+ /** The deliveries the crash left in needs_review, by id; filled below once the journal is open. */
1292
+ const crashLostIds = new Set<string>();
1273
1293
 
1274
1294
  // The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
1275
1295
  let inference: Inference | undefined;
@@ -1279,7 +1299,7 @@ export async function startDaemon(opts: DaemonOptions) {
1279
1299
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1280
1300
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1281
1301
  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]);
1302
+ 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
1303
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
1284
1304
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
1285
1305
  const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
@@ -1700,10 +1720,11 @@ export async function startDaemon(opts: DaemonOptions) {
1700
1720
  for (const t of board.list("in_progress")) if (!tasks.isPii(t)) renewLeases(t);
1701
1721
  }
1702
1722
  let syncRun: Promise<void> | undefined;
1723
+ let controlWriteTelemetry: ControlWriteTelemetry | undefined;
1703
1724
  /** Machine sync, when a person set a remote; one log line per failure, never a blocked turn. Stopping aborts and awaits it. */
1704
1725
  function syncNow(): Promise<void> {
1705
1726
  if (!memoryStore || syncRun || stopping) return syncRun ?? Promise.resolve();
1706
- syncRun = (async () => {
1727
+ const run = async () => {
1707
1728
  try {
1708
1729
  checkMemory();
1709
1730
  const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
@@ -1711,7 +1732,8 @@ export async function startDaemon(opts: DaemonOptions) {
1711
1732
  else if (r.warning) log(`hub memory sync: ${r.warning}`);
1712
1733
  } catch { /* A stale binding disables native memory, never the bus. */ }
1713
1734
  finally { syncRun = undefined; }
1714
- })();
1735
+ };
1736
+ syncRun = controlWriteTelemetry ? controlWriteTelemetry.observe("memory", () => !stopping, run) : run();
1715
1737
  return syncRun;
1716
1738
  }
1717
1739
 
@@ -2029,12 +2051,25 @@ export async function startDaemon(opts: DaemonOptions) {
2029
2051
  }).finally(() => { refreshingRunner = undefined; });
2030
2052
  return refreshingRunner;
2031
2053
  };
2054
+ // #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. The sandbox profile is unchanged; the record file lives in the hub's state dir, which no sandbox root contains.
2055
+ const taskWorktree = new TaskWorktrees({
2056
+ root: opts.cwd, stateFile: join(opts.stateDir, "task-worktrees.json"),
2057
+ applies: peer => definitionOf(peer)?.trust.hubSandboxed === true,
2058
+ deny: () => config.local.deny, protectInstructions: () => config.protect_instruction_files,
2059
+ pushAllowed: urls => originVerdict(shellProjectRepository, urls, url => githubRemoteRepository(url)),
2060
+ });
2032
2061
  const peerCapabilities = () => {
2033
2062
  // Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
2034
2063
  if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
2035
2064
  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"),
2065
+ if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
2066
+ // #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.
2067
+ const network = sandboxNetwork !== false;
2068
+ // A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so the hub makes the task worktree for such a peer (#1115): the capability is whether it can here.
2069
+ const hubWorktree = taskWorktree.available();
2070
+ return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: hubWorktree.ok,
2071
+ ...(hubWorktree.ok ? {} : { worktreeReason: `the hub cannot make a task worktree for it: ${hubWorktree.reason}; ${hubWorktree.next} (#1115)` }),
2072
+ ...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
2038
2073
  testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
2039
2074
  specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
2040
2075
  }));
@@ -2077,6 +2112,27 @@ export async function startDaemon(opts: DaemonOptions) {
2077
2112
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
2078
2113
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
2079
2114
  };
2115
+ 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 };
2116
+ const specInspectors = new Map<string, SpecTaskInspector>();
2117
+ const specInspectorAt = (root: string): SpecTaskInspector => {
2118
+ const cached = specInspectors.get(root);
2119
+ if (cached) { specInspectors.delete(root); specInspectors.set(root, cached); return cached; }
2120
+ const created = new SpecTaskInspector(root, () => config.specs);
2121
+ specInspectors.set(root, created);
2122
+ if (specInspectors.size > 8) specInspectors.delete(specInspectors.keys().next().value!);
2123
+ return created;
2124
+ };
2125
+ const specRootSnapshot = (task: Task): SpecRootSnapshot => {
2126
+ const peer = task.owner ?? USER, binding = executionOwners.get(peer), execution = executionRoots.get(peer) ?? opts.cwd;
2127
+ const root = binding?.socket && !currentExecutionOwner(peer) ? opts.cwd : execution;
2128
+ const verifiedRoot = ownerWriteRoot(peer), attached = bus.peers.get(peer);
2129
+ 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 } : {}) };
2130
+ };
2131
+ const sameSpecRoot = (left: SpecRootSnapshot, right: SpecRootSnapshot): boolean => left.peer === right.peer && left.root === right.root
2132
+ && left.executionRoot === right.executionRoot && left.verifiedRoot === right.verifiedRoot && left.owner === right.owner && left.attached === right.attached
2133
+ && left.cwd === right.cwd && left.socket === right.socket && left.launchId === right.launchId && left.generation === right.generation && left.repo === right.repo;
2134
+ const projectSpecs = specInspectorAt(opts.cwd);
2135
+ let specsMissingBlockHintShown = false;
2080
2136
  const tasks: Tasks = new Tasks({
2081
2137
  releaseWindow,
2082
2138
  researchRunners: () => {
@@ -2085,7 +2141,7 @@ export async function startDaemon(opts: DaemonOptions) {
2085
2141
  if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
2086
2142
  return parseResearchRunners(config.research.runners);
2087
2143
  },
2088
- capabilities: peerCapabilities, repository: memoryRepo,
2144
+ capabilities: peerCapabilities, taskWorktree, repository: memoryRepo,
2089
2145
  contextAdmission: () => ({
2090
2146
  policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
2091
2147
  peers: (config.context.max_assignment_used ?? 0) > 0 ? Object.fromEntries(bus.knownPeers().filter(peer => definitionOf(peer)?.budget?.context).map(peer => [peer,
@@ -2163,6 +2219,20 @@ export async function startDaemon(opts: DaemonOptions) {
2163
2219
  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
2220
  return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
2165
2221
  },
2222
+ specsConfig: () => config.specs,
2223
+ specNotice: task => {
2224
+ const snapshot = specRootSnapshot(task);
2225
+ return specInspectorAt(snapshot.root).notice(task.refs.paths ?? []);
2226
+ },
2227
+ inspectSpecs: async (task, head, changedFiles, acks) => {
2228
+ const snapshot = specRootSnapshot(task), inspector = specInspectorAt(snapshot.root);
2229
+ const report = await Promise.resolve(inspector.inspect(head, changedFiles, { complete: changedFiles !== undefined, acks }));
2230
+ const current = specRootSnapshot(task), currentTask = board.get(task.id);
2231
+ if (!sameSpecRoot(snapshot, current) || !currentTask || currentTask.owner !== task.owner) throw new PreEffectToolRefusal("task repository scope changed during spec inspection; retry hub_task_done");
2232
+ if (config.specs.enabled !== true || config.specs.review === "off") return undefined;
2233
+ return report;
2234
+ },
2235
+ specsRead: (action, paths) => projectSpecs.read(action, paths),
2166
2236
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
2167
2237
  board,
2168
2238
  bus,
@@ -2183,13 +2253,16 @@ export async function startDaemon(opts: DaemonOptions) {
2183
2253
  read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
2184
2254
  }),
2185
2255
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
2186
- checkMemory();
2187
- const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
2188
- const nodes = planGuardNodes(task, context);
2189
- const related = screenMemory(memoryStore.related(memoryProject, nodes, Number.MAX_SAFE_INTEGER));
2190
- const all = screenMemory(memoryStore.list({ project: memoryProject, kind: "fail", limit: Number.MAX_SAFE_INTEGER }));
2191
- const rows = [...new Map([...related, ...all].map(row => [row.id, row])).values()];
2192
- return matchingPlanFails(task, rows, context).slice(0, config.memory.brief_items).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
2256
+ const read = () => {
2257
+ checkMemory();
2258
+ const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
2259
+ const nodes = planGuardNodes(task, context);
2260
+ const related = screenMemory(memoryStore.related(memoryProject, nodes, Number.MAX_SAFE_INTEGER));
2261
+ const all = screenMemory(memoryStore.list({ project: memoryProject, kind: "fail", limit: Number.MAX_SAFE_INTEGER }));
2262
+ const rows = [...new Map([...related, ...all].map(row => [row.id, row])).values()];
2263
+ return matchingPlanFails(task, rows, context).slice(0, config.memory.brief_items).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
2264
+ };
2265
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("memory", controlWritePrivacyGuard(true, true, undefined, task.id), read) : read();
2193
2266
  } } : {}),
2194
2267
  ...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
2195
2268
  notify,
@@ -2212,14 +2285,29 @@ export async function startDaemon(opts: DaemonOptions) {
2212
2285
  log(`note for ${peer} not kept: ${(e as Error).message}`);
2213
2286
  }
2214
2287
  },
2215
- triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
2288
+ triage: {
2289
+ classify: (title, detail) => {
2290
+ const classify = () => inference?.triage(title, detail) ?? Promise.resolve(undefined);
2291
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), classify,
2292
+ () => boundedSafe(`${title}\n${detail}`)) : classify();
2293
+ },
2294
+ onCampus: () => controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), () => onCampus()) : onCampus(),
2295
+ },
2216
2296
  // #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
2217
- intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
2297
+ intake: { split: (system, user, opts) => {
2298
+ const split = () => inference?.split(system, user, opts) ?? Promise.resolve(undefined);
2299
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), split,
2300
+ () => boundedSafe(`${system}\n${user}`)) : split();
2301
+ }, sourceKnown: id => sourceRegistry.get(id) !== undefined,
2218
2302
  partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
2219
2303
  studySource: id => sourceRegistry.get(id),
2220
2304
  experimentCheck: run => checkRunner.result(run),
2221
2305
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
2222
- piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
2306
+ piiScreen: (text) => {
2307
+ const screen = () => (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(text);
2308
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), screen,
2309
+ () => boundedSafe(text)) : screen();
2310
+ },
2223
2311
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
2224
2312
  quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
2225
2313
  review: config.review,
@@ -2514,7 +2602,21 @@ export async function startDaemon(opts: DaemonOptions) {
2514
2602
  return typeof session === "string" ? session : undefined;
2515
2603
  };
2516
2604
  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));
2605
+ const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
2606
+ // Hoisted for the Tasks ports above; only the returned guard reads fully initialized daemon state.
2607
+ function controlWritePrivacyGuard(admittedAtReceive: boolean, inputPublicAtReceive: boolean, peer?: PeerId, taskId?: number) {
2608
+ return () => {
2609
+ try {
2610
+ if (!admittedAtReceive || !inputPublicAtReceive) return false;
2611
+ if (peer && (turns.get(peer)?.private || contextPii(peer))) return false;
2612
+ if (taskId !== undefined) {
2613
+ const task = board.get(taskId);
2614
+ if (!task || tasks.isPii(task) || !tasks.publicMetadataText(String(task.id))) return false;
2615
+ }
2616
+ return true;
2617
+ } catch { return false; }
2618
+ };
2619
+ }
2518
2620
  // #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
2519
2621
  const doings = new Map<PeerId, DoingTracker>();
2520
2622
  const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
@@ -2757,22 +2859,25 @@ export async function startDaemon(opts: DaemonOptions) {
2757
2859
  };
2758
2860
  const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
2759
2861
  if (!capture) return { ms: 0 };
2760
- const t0 = performance.now(), budget = reserveSnapshotBudget();
2761
- let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
2762
- try {
2763
- repo = peer ? repoFor(peer, budget) : factsRepo;
2764
- const before = repo && headState(repo, budget);
2765
- tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
2766
- const after = repo && headState(repo, budget);
2767
- head = after?.tree; commit = after?.head;
2768
- if (!before || !after || before.tree !== after.tree) tree = undefined;
2769
- if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
2770
- if (repo && !tree) log(`snapshot failed at ${what}`);
2771
- } catch (error) {
2772
- tree = undefined; head = undefined; commit = undefined; reflog = undefined;
2773
- snapshotFailure(what, error);
2774
- }
2775
- return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
2862
+ const captureSnapshot = () => {
2863
+ const t0 = performance.now(), budget = reserveSnapshotBudget();
2864
+ let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
2865
+ try {
2866
+ repo = peer ? repoFor(peer, budget) : factsRepo;
2867
+ const before = repo && headState(repo, budget);
2868
+ tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
2869
+ const after = repo && headState(repo, budget);
2870
+ head = after?.tree; commit = after?.head;
2871
+ if (!before || !after || before.tree !== after.tree) tree = undefined;
2872
+ if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
2873
+ if (repo && !tree) log(`snapshot failed at ${what}`);
2874
+ } catch (error) {
2875
+ tree = undefined; head = undefined; commit = undefined; reflog = undefined;
2876
+ snapshotFailure(what, error);
2877
+ }
2878
+ return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
2879
+ };
2880
+ return controlWriteTelemetry ? controlWriteTelemetry.observe("snapshot", controlWritePrivacyGuard(true, true, peer as PeerId | undefined), captureSnapshot) : captureSnapshot();
2776
2881
  };
2777
2882
  function privateSnapshotWindow(): boolean {
2778
2883
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
@@ -3244,6 +3349,77 @@ export async function startDaemon(opts: DaemonOptions) {
3244
3349
  // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
3245
3350
  // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
3246
3351
  let taskOpsInFlight = 0;
3352
+ controlWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
3353
+ try { if (tasks.publicMetadataText(line) && boundedSafe(line)) log(line); } catch { /* diagnostics never fail a write */ }
3354
+ } });
3355
+ startupCleanup.push(() => controlWriteTelemetry?.stop());
3356
+ const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
3357
+ const toolWriteScope = (c: Client, operation: string): string => {
3358
+ const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
3359
+ const binding = c.toolsOwner ?? c.externalOwner;
3360
+ return JSON.stringify({ actor, role: c.role ?? "", operation, session: c.session === true, launchId: c.launchId ?? null,
3361
+ generation: binding?.generation ?? (c.peer ? connectionGeneration(c.peer) ?? null : null), owner: binding?.owner.id ?? peer?.id ?? null,
3362
+ executionRoot: c.peer ? executionRoots.get(c.peer) ?? null : null });
3363
+ };
3364
+ const taskWriteId = (value: unknown): number | undefined => {
3365
+ const id = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
3366
+ return Number.isInteger(id) ? id : undefined;
3367
+ };
3368
+ const toolWriteCurrent = (c: Client, sock: Sock, operation: string, payload: Record<string, unknown>, scope: string, result?: Record<string, unknown>): boolean => {
3369
+ try {
3370
+ const actor = c.peer ?? USER;
3371
+ if (!c.authed || c.closed || sock.readyState !== WebSocket.OPEN || !c.role || stopping || recoveryActive() && recoveryPhase !== "preparing"
3372
+ || c.peer && !membershipAllowed(c.peer) || c.externalOwner && !currentExternalTools(c, true) || c.toolsOwner && !currentImplementerTools(c)) return false;
3373
+ if (c.session && sessionGate(actor, payload)) return false;
3374
+ if (toolWriteScope(c, operation) !== scope || readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
3375
+ || c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(operation)) return false;
3376
+ const privateContext = actor !== USER && (!!turns.get(actor)?.private || contextPii(actor));
3377
+ const args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
3378
+ const requestedOwner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
3379
+ if (operation === "hub_task_propose" && (!may(actor, "propose") || requestedOwner && PEER_ID.test(requestedOwner)
3380
+ && ![actor, USER, HUB, "digest"].includes(requestedOwner) && !may(actor, "assign"))) return false;
3381
+ const safePiiProposalReceipt = operation === "hub_task_propose" && actor !== USER && result?.ok === true && typeof result.text === "string"
3382
+ && /^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);
3383
+ const taskId = operation === "hub_review" ? taskWriteId(args.id)
3384
+ : result?.ok === true && typeof result.text === "string" ? Number(/task #(\d+):/.exec(result.text)?.[1]) : undefined;
3385
+ if (taskId !== undefined) {
3386
+ const task = Number.isSafeInteger(taskId) && taskId > 0 ? board.get(taskId) : undefined;
3387
+ if (!task) {
3388
+ // Let the original Tasks.review validation return its specific pre-effect refusal for a new/bad target.
3389
+ // A successful cached verdict without its task row is no longer safe to replay.
3390
+ if (operation !== "hub_review" || result?.ok === true) return false;
3391
+ } else {
3392
+ const piiTask = tasks.isPii(task);
3393
+ // ponytail: non-console review/PII replays remain held; narrower privacy needs host-proven per-operation private-turn authority.
3394
+ if (operation === "hub_review" && actor !== USER && privateContext) return false;
3395
+ if (actor !== USER && piiTask && !safePiiProposalReceipt
3396
+ || operation === "hub_review" && actor !== USER && task.reviewer !== actor
3397
+ || actor !== USER && result?.ok === true && typeof result.text === "string" && !tasks.publicMetadataText(result.text) && !safePiiProposalReceipt) return false;
3398
+ }
3399
+ }
3400
+ if (actor !== USER && operation === "hub_task_propose" && result?.ok === true && typeof result.text === "string") {
3401
+ const overlapLines = result.text.split(/\r?\n/).filter(line => line.startsWith("Overlaps "));
3402
+ if (overlapLines.length > 1) return false;
3403
+ if (overlapLines.length === 1) {
3404
+ const line = overlapLines[0]!;
3405
+ const markers = [...line.matchAll(/#([1-9]\d*) \(owner ([^)\r\n]{1,128})\)/g)];
3406
+ const markerPrefixes = [...line.matchAll(/#([^()\r\n]{1,128}) \(owner /g)];
3407
+ if (!markers.length || markers.length > 64 || markerPrefixes.length !== markers.length) return false;
3408
+ const refs = new Set<number>();
3409
+ for (const marker of markers) {
3410
+ const id = Number(marker[1]);
3411
+ if (!Number.isSafeInteger(id) || id < 1) return false;
3412
+ refs.add(id);
3413
+ }
3414
+ if (refs.size !== markers.length) return false;
3415
+ const facts = board.taskPrivacySignals([...refs]);
3416
+ if (facts.length !== refs.size || facts.some(task => tasks.isPii(task))) return false;
3417
+ }
3418
+ }
3419
+ if (actor !== USER && typeof result?.error === "string" && result.outcome !== "unknown" && !tasks.publicMetadataText(result.error)) return false;
3420
+ return true;
3421
+ } catch { return false; }
3422
+ };
3247
3423
  /**
3248
3424
  * #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
3425
  * 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 +3530,10 @@ export async function startDaemon(opts: DaemonOptions) {
3354
3530
  try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
3355
3531
  catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
3356
3532
  }
3533
+ function inboxHeldMetadata(peer: PeerId) {
3534
+ try { return bus.heldDeliveryHints(peer); }
3535
+ catch { return undefined; } // the inbox pull is already settled; advisory held-row metadata cannot hide its result
3536
+ }
3357
3537
  /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
3358
3538
  const peerArg = (v: unknown, name: string): PeerId | undefined => {
3359
3539
  if (v == null || v === "") return undefined;
@@ -3504,14 +3684,19 @@ export async function startDaemon(opts: DaemonOptions) {
3504
3684
  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
3685
  }
3506
3686
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
3687
+ 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
3688
  if (BOUNDED_TASK_TOOLS.has(op)) {
3508
3689
  const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
3509
3690
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
3510
3691
  || (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
3692
  if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
3512
3693
  }
3694
+ if (op === "hub_specs" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
3513
3695
  if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
3514
3696
  if (op === "hub_memory_correct") correctionInput(a);
3697
+ if (op === "hub_task_propose" && by !== USER && (piiTurn || turns.get(by)?.private)) {
3698
+ throw new PreEffectToolRefusal("hub_task_propose is not available while working on a PII task: its text must not leave this machine");
3699
+ }
3515
3700
  if (CONDUCTOR_TOOL_NAMES.has(op)) {
3516
3701
  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
3702
  const result = await conductor.execute(by, op, a);
@@ -3682,6 +3867,9 @@ export async function startDaemon(opts: DaemonOptions) {
3682
3867
  return JSON.stringify(publicCheck(receipt));
3683
3868
  }
3684
3869
  switch (op) {
3870
+ case "hub_specs":
3871
+ 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");
3872
+ return JSON.stringify(tasks.specsRead(by, a.action, a.paths));
3685
3873
  case "hub_delivery_retry": {
3686
3874
  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
3875
  if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
@@ -3689,6 +3877,16 @@ export async function startDaemon(opts: DaemonOptions) {
3689
3877
  bus.retryDelivery(by, a.delivery_id);
3690
3878
  return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
3691
3879
  }
3880
+ case "hub_delivery_resolve": {
3881
+ if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["delivery_id", "revision", "action", "reason"].includes(key))
3882
+ || typeof a.delivery_id !== "string" || !a.delivery_id.trim() || a.delivery_id.length > 256
3883
+ || !Number.isSafeInteger(a.revision) || a.revision < 0 || !["completed", "discard"].includes(a.action)
3884
+ || typeof a.reason !== "string" || !a.reason.trim() || a.reason.trim().length > 1000 || a.reason.length > 1000
3885
+ || !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");
3886
+ 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");
3887
+ bus.resolveOwnDelivery(by, a.delivery_id, a.revision, a.action, a.reason.trim());
3888
+ return `your held delivery was marked ${a.action}`;
3889
+ }
3692
3890
  case "hub_backlog_list":
3693
3891
  return JSON.stringify(tasks.backlogList(by));
3694
3892
  case "hub_release_new":
@@ -3764,19 +3962,21 @@ export async function startDaemon(opts: DaemonOptions) {
3764
3962
  const t = await tasks.propose(by, a);
3765
3963
  const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
3766
3964
  const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
3767
- if (t.state === "backlog") return [line(t), repoHint, submodules].filter(Boolean).join("\n");
3965
+ const specs = tasks.specNotice(t, by);
3966
+ if (t.state === "backlog") return [line(t), specs, repoHint, submodules].filter(Boolean).join("\n");
3768
3967
  const overlap = tasks.overlaps(t, t.owner === by);
3769
3968
  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");
3969
+ 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
3970
  }
3772
3971
  case "hub_task_accept": {
3773
3972
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
3774
3973
  const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
3974
+ const specs = tasks.specNotice(t, by);
3775
3975
  // A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
3776
3976
  const silent = tasks.silentFor(t.id);
3777
3977
  const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
3778
3978
  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
3979
+ return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t), tasks.worktreeNote(t, by)].filter(Boolean).join("\n"); // #798 D1
3780
3980
  }
3781
3981
  case "hub_task_cancel": {
3782
3982
  if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
@@ -3789,7 +3989,9 @@ export async function startDaemon(opts: DaemonOptions) {
3789
3989
  case "hub_task_decline":
3790
3990
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
3791
3991
  case "hub_task_done": {
3792
- const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
3992
+ // #1115: a hub-sandboxed peer's worktree is staged, committed and pushed by the hub first, so the done carries the real head and branch.
3993
+ const handed = await tasks.worktreeHandoff(by, a.id, a.refs);
3994
+ const t = await tasks.done(by, a.id, a.summary, handed ?? a.refs, a.deliverables, a.spec_ack);
3793
3995
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
3794
3996
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
3795
3997
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -3798,8 +4000,10 @@ export async function startDaemon(opts: DaemonOptions) {
3798
4000
  const last = t.history.at(-1);
3799
4001
  if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
3800
4002
  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;
4003
+ const doneLine = by === USER ? tasks.resultLine(t, by) : line(t);
4004
+ const result = tasks.isChecking(t.id) ? `${doneLine}; its check is queued or running, and the result comes as a task message` : doneLine;
4005
+ const specReport = by === USER ? "" : tasks.specReportText(t, by);
4006
+ return [result, specReport, extra].filter(Boolean).join("\n");
3803
4007
  }
3804
4008
  case "hub_sdlc_record":
3805
4009
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
@@ -3839,7 +4043,13 @@ export async function startDaemon(opts: DaemonOptions) {
3839
4043
  if (!release) throw new PreEffectToolRefusal("no such release");
3840
4044
  releaseId = release.id;
3841
4045
  }
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) } : {}) }; }));
4046
+ // #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.
4047
+ const listQuery = parseListQuery(a, by !== USER);
4048
+ 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));
4049
+ 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) } : {}) }; };
4050
+ if (!listQuery) return JSON.stringify(shown.map(listRow));
4051
+ const page = pageTasks(shown, listQuery);
4052
+ return JSON.stringify({ tasks: page.rows.map(t => listQuery.detail ? listRow(t) : compactRow(listRow(t))), total: page.total, ...(page.next ? { next: page.next } : {}) });
3843
4053
  }
3844
4054
  }
3845
4055
  if (op === "task_show" && by !== USER) {
@@ -4533,6 +4743,18 @@ export async function startDaemon(opts: DaemonOptions) {
4533
4743
  }
4534
4744
  return checkoutInTick.branch;
4535
4745
  };
4746
+ /**
4747
+ * 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
4748
+ * still in needs_review, so it matches `ahub queue list` and reaches zero when the operator has resolved them.
4749
+ */
4750
+ const crashView = (): string[] => {
4751
+ if (!crashNotices.length) return [];
4752
+ const attached = new Set<string>();
4753
+ 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);
4754
+ let inFlight = 0;
4755
+ 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; } }
4756
+ return openCrashNotices(crashNotices, { attached, inFlight });
4757
+ };
4536
4758
  const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
4537
4759
  const taskNeeds = board.statusTasks();
4538
4760
  const ownerIsolation = ownerIsolationProjection();
@@ -4540,9 +4762,10 @@ export async function startDaemon(opts: DaemonOptions) {
4540
4762
  const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
4541
4763
  const peerIds = byKind(statusPeerIds(taskNeeds));
4542
4764
  const queueBackpressure = tasks.queuePressureStatus(peerIds);
4765
+ const crashLines = crashView();
4543
4766
  const view = {
4544
4767
  ...(queueBackpressure ? { queueBackpressure } : {}),
4545
- ...(crashReport.length ? { crash: crashReport } : {}),
4768
+ ...(crashLines.length ? { crash: crashLines } : {}),
4546
4769
  projectId,
4547
4770
  instanceId,
4548
4771
  rooms: roomCommands.statusView(),
@@ -4599,6 +4822,7 @@ export async function startDaemon(opts: DaemonOptions) {
4599
4822
  isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
4600
4823
  taskCapabilities: peerCapabilities(),
4601
4824
  tasks: board.counts(),
4825
+ ...(includeAttentionItems && controlWriteTelemetry ? { controlWriteTiming: controlWriteTelemetry.status() } : {}),
4602
4826
  taskStalls, pendingTaskOffers,
4603
4827
  ...(bus.storageError ? { deliveryError: bus.storageError } : {}),
4604
4828
  ...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
@@ -4629,20 +4853,28 @@ export async function startDaemon(opts: DaemonOptions) {
4629
4853
  // (the status reply is unchanged); readers without it judge the pid alone, as 0.12.20 did.
4630
4854
  const pidSignature = processSignature(process.pid);
4631
4855
  const writeStatus = () => {
4632
- try {
4633
- const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
4634
- writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify({ ...status(), ...(pidSignature ? { pidSignature } : {}) }, null, 2)}\n`);
4635
- renameSync(`${file}.${instanceId}.tmp`, file);
4636
- } catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
4856
+ const write = () => {
4857
+ try {
4858
+ const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
4859
+ writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify({ ...status(), ...(pidSignature ? { pidSignature } : {}) }, null, 2)}\n`);
4860
+ renameSync(`${file}.${instanceId}.tmp`, file);
4861
+ } catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
4862
+ };
4863
+ if (controlWriteTelemetry) controlWriteTelemetry.observe("status-hook", () => true, write);
4864
+ else write();
4637
4865
  };
4638
4866
  const taskChanged = board.onChange;
4639
4867
  board.onChange = (task, entry) => {
4640
- try { taskChanged?.(task, entry); }
4641
- finally {
4642
- try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
4643
- try { settleSupersededDeliveries(); }
4644
- finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
4645
- }
4868
+ const changed = () => {
4869
+ try { taskChanged?.(task, entry); }
4870
+ finally {
4871
+ try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
4872
+ try { settleSupersededDeliveries(); }
4873
+ finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
4874
+ }
4875
+ };
4876
+ if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", controlWritePrivacyGuard(true, true, undefined, task.id), changed);
4877
+ else changed();
4646
4878
  };
4647
4879
 
4648
4880
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
@@ -4677,6 +4909,7 @@ export async function startDaemon(opts: DaemonOptions) {
4677
4909
  let reportingQueues = false;
4678
4910
  let queuePressureStatusQueued = false;
4679
4911
  bus.onQueues = () => {
4912
+ const changed = () => {
4680
4913
  if (stopping || settlingSuperseded || reportingQueues) return;
4681
4914
  remoteQueueChanged();
4682
4915
  reportingQueues = true;
@@ -4702,6 +4935,9 @@ export async function startDaemon(opts: DaemonOptions) {
4702
4935
  });
4703
4936
  }
4704
4937
  }
4938
+ };
4939
+ if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", () => true, changed);
4940
+ else changed();
4705
4941
  };
4706
4942
  // Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
4707
4943
  settleSupersededDeliveries();
@@ -4900,34 +5136,35 @@ export async function startDaemon(opts: DaemonOptions) {
4900
5136
  } catch (error) { log(`session record not written: ${(error as Error).message}`); }
4901
5137
  };
4902
5138
  const recoverAfterCrash = async (prev: SessionsFile) => {
4903
- const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
5139
+ // `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).
5140
+ const report = (line: string, notice: { peer?: string; inFlight?: true } = {}, shown = line) => { crashNotices.push({ text: line, ...notice }); crashReported = true; notify(`crash recovery: ${shown}`); };
4904
5141
  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)` : ""}`);
5142
+ 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
5143
  const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
4907
5144
  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; }
5145
+ if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`, { peer: step.peer }); continue; }
5146
+ 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
5147
  // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
4911
5148
  // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
4912
5149
  if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
4913
5150
  if (step.resume) {
4914
5151
  const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
4915
5152
  if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
4916
- report(`pi not resumed (${String(r.error)}); ${step.how}`);
5153
+ 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
5154
  } else report(step.how);
4918
5155
  // #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; }
5156
+ 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
5157
  const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
4921
5158
  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)})`);
5159
+ 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
5160
  continue;
4924
5161
  }
4925
5162
  if (!step.resume || !autoResume()) {
4926
- report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
5163
+ report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`, { peer: step.peer });
4927
5164
  continue;
4928
5165
  }
4929
5166
  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}`);
5167
+ report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`, r.ok ? {} : { peer: step.peer });
4931
5168
  }
4932
5169
  writeStatus();
4933
5170
  };
@@ -5361,7 +5598,7 @@ export async function startDaemon(opts: DaemonOptions) {
5361
5598
  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
5599
  if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
5363
5600
  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" };
5601
+ if (recoveryActive()) return { ok: false, error: startHeldByRecovery(peer) };
5365
5602
  const continuity = definitionOf(peer)?.start.autoStart === by
5366
5603
  ? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
5367
5604
  : undefined;
@@ -5423,6 +5660,15 @@ export async function startDaemon(opts: DaemonOptions) {
5423
5660
  /** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
5424
5661
  const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
5425
5662
 
5663
+ /** 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. */
5664
+ const startHeldByRecovery = (peer: unknown): string => {
5665
+ const held = "recovery is holding mutations";
5666
+ 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`;
5667
+ const owner = bus.peers.get(peer);
5668
+ 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`;
5669
+ 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)}`;
5670
+ };
5671
+
5426
5672
  async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
5427
5673
  operational?.current();
5428
5674
  if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
@@ -5657,6 +5903,7 @@ export async function startDaemon(opts: DaemonOptions) {
5657
5903
  ...(instance?.provider ? { provider: instance.provider } : {}),
5658
5904
  ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
5659
5905
  toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
5906
+ tuiHintContext: () => ({ tui: startMode(peer) === "tui", afterCrash: crashReported }),
5660
5907
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
5661
5908
  // #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
5909
  // or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
@@ -6437,6 +6684,7 @@ export async function startDaemon(opts: DaemonOptions) {
6437
6684
  return {
6438
6685
  id: row.id, peer: row.peer, state: row.state, revision: row.revision,
6439
6686
  ...roomChat.evidence(row.peer, row.id),
6687
+ ...(detail && bus.deliveryResolution(row.id) ? { resolvedBy: bus.deliveryResolution(row.id) } : {}),
6440
6688
  ...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
6441
6689
  createdAt: row.createdAt, updatedAt: row.updatedAt,
6442
6690
  envelopeIds: originals.map((env) => env.id),
@@ -7605,6 +7853,7 @@ export async function startDaemon(opts: DaemonOptions) {
7605
7853
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
7606
7854
  };
7607
7855
  const incompatibleRecoveryTarget = () => {
7856
+ 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
7857
  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
7858
  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
7859
  if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
@@ -7951,13 +8200,68 @@ export async function startDaemon(opts: DaemonOptions) {
7951
8200
  if (!result.ok) throw new Error(String(result.error));
7952
8201
  }
7953
8202
 
7954
- function onMessage(sock: Sock, msg: any): void {
8203
+ function onControlMessage(sock: Sock, msg: any): void {
8204
+ const client = sock.data;
8205
+ const admittedAtReceive = client.authed && !!client.role, peer = client.peer;
8206
+ if (!admittedAtReceive) return onMessage(sock, msg);
8207
+ const metadata = controlWriteMetadata(msg);
8208
+ if (!metadata || !controlWriteTelemetry) return onMessage(sock, msg);
8209
+ const inputPublicAtReceive = (() => {
8210
+ const rawArgs = msg?.args;
8211
+ if (rawArgs === undefined) return true;
8212
+ if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) return false;
8213
+ try {
8214
+ const idDescriptor = Object.getOwnPropertyDescriptor(rawArgs, "id");
8215
+ if (idDescriptor && (!Object.hasOwn(idDescriptor, "value") || idDescriptor.value !== undefined) && metadata.task === undefined) return false;
8216
+ const stack: { value: unknown; depth: number }[] = [{ value: rawArgs, depth: 0 }];
8217
+ const text: string[] = [];
8218
+ let bytes = 0, nodes = 0;
8219
+ while (stack.length) {
8220
+ const current = stack.pop()!;
8221
+ if (++nodes > 2048 || current.depth > 16) return undefined;
8222
+ if (typeof current.value === "string") {
8223
+ bytes += Buffer.byteLength(current.value);
8224
+ if (bytes > 32 * 1024) return undefined;
8225
+ text.push(current.value);
8226
+ } else if (Array.isArray(current.value)) {
8227
+ if (current.value.length > 256) return undefined;
8228
+ for (const value of current.value) stack.push({ value, depth: current.depth + 1 });
8229
+ } else if (current.value && typeof current.value === "object") {
8230
+ const values = Object.values(current.value);
8231
+ if (values.length > 256) return undefined;
8232
+ for (const value of values) stack.push({ value, depth: current.depth + 1 });
8233
+ }
8234
+ }
8235
+ const joined = text.join("\n");
8236
+ return tasks.publicMetadataText(joined) && boundedSafe(joined);
8237
+ } catch { return undefined; }
8238
+ })();
8239
+ const privacyCurrent = controlWritePrivacyGuard(admittedAtReceive, inputPublicAtReceive === true, peer, metadata.task);
8240
+ let finishControlWrite = () => {};
8241
+ let finished = false;
8242
+ const finish = () => { if (!finished) { finished = true; finishControlWrite(); } };
8243
+ try {
8244
+ void controlWriteTelemetry.run(metadata, privacyCurrent, () => {
8245
+ const completed = new Promise<void>(resolve => { finishControlWrite = resolve; });
8246
+ try {
8247
+ controlWriteSpan("daemon-dispatch", () => onMessage(sock, msg, finish));
8248
+ } catch (error) { finish(); throw error; }
8249
+ return completed;
8250
+ });
8251
+ } catch (error) { finish(); throw error; }
8252
+ }
8253
+
8254
+ function onMessage(sock: Sock, msg: any, finishControlWrite: () => void = () => {}): void {
7955
8255
  const c = sock.data;
7956
8256
  let refusalRecorded=false;
7957
8257
  const reply = (body: Record<string, unknown>) => {
8258
+ const sendReply = () => {
8259
+ const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
8260
+ && toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
8261
+ let writeRefusal: string | undefined;
7958
8262
  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"}: {}) };
8263
+ if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) { writeRefusal = "external instance launch or execution root changed"; body = { ok: false, error: writeRefusal }; }
8264
+ if (c.toolsOwner && !currentImplementerTools(c)) { writeRefusal = "Claude child generation or execution root changed"; body = { ok: false, error: writeRefusal, ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) }; }
7961
8265
  if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
7962
8266
  if(body.authorityRefused===true) {
7963
8267
  if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
@@ -7965,7 +8269,48 @@ export async function startDaemon(opts: DaemonOptions) {
7965
8269
  body={...body,error,refusalAudited:refusalRecorded};
7966
8270
  } else body={...body,authorityRefused:false};
7967
8271
  }
8272
+ if (writeRequestId && body.requestId !== writeRequestId) {
8273
+ const authority = { ...(body.authorityRefused === true || body.authorityRefused === false ? { authorityRefused: body.authorityRefused } : {}),
8274
+ ...(typeof body.authorityScope === "string" ? { authorityScope: body.authorityScope } : {}), ...(typeof body.refusalAudited === "boolean" ? { refusalAudited: body.refusalAudited } : {}) };
8275
+ body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
8276
+ }
7968
8277
  if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
8278
+ };
8279
+ try {
8280
+ controlWriteSpan("daemon-reply", sendReply);
8281
+ } finally { finishControlWrite(); }
8282
+ };
8283
+ 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 => {
8284
+ const operation = toolWriteOperation(msg);
8285
+ const payload = { t: "task", op: msg.op, args: msg.args ?? {} };
8286
+ const actor = c.peer ?? USER, args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
8287
+ const knownPreEffectRefusal = operation !== undefined && (
8288
+ readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
8289
+ || operation === "hub_task_propose" && (!may(actor, "propose") || (() => {
8290
+ const owner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
8291
+ return !!owner && PEER_ID.test(owner) && ![actor, USER, HUB, "digest"].includes(owner) && !may(actor, "assign");
8292
+ })())
8293
+ || operation === "hub_review" && actor !== USER && (() => {
8294
+ const id = taskWriteId(args.id);
8295
+ if (id === undefined || id < 1) return false;
8296
+ const task = board.get(id);
8297
+ return !!task && task.reviewer !== actor;
8298
+ })()
8299
+ );
8300
+ if (!operation || msg.requestId === undefined || knownPreEffectRefusal) {
8301
+ void run().then(text => reply({ t: "task", ok: true, text }), error => reply(formatError(error)));
8302
+ return;
8303
+ }
8304
+ const scope = toolWriteScope(c, operation);
8305
+ void toolWriteRegistry.run({ id: msg.requestId, scope, bucket: actor, payload,
8306
+ current: result => toolWriteCurrent(c, sock, operation, payload, scope, result) && (() => { try { extraCurrent(); return true; } catch { return false; } })(),
8307
+ }, async () => {
8308
+ try { return { t: "task", ok: true, text: await run() }; }
8309
+ catch (error) {
8310
+ if (error instanceof PreEffectToolRefusal || error instanceof OperationalAuthorityError) return formatError(error);
8311
+ throw error;
8312
+ }
8313
+ }).then(result => reply(result), () => reply(toolWriteUnknownReply(payload, typeof msg.requestId === "string" ? msg.requestId : undefined)));
7969
8314
  };
7970
8315
  if (!c.authed) {
7971
8316
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
@@ -8186,7 +8531,11 @@ export async function startDaemon(opts: DaemonOptions) {
8186
8531
  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
8532
  if (msg.op === "list") {
8188
8533
  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")) });
8534
+ if (msg.all !== undefined && typeof msg.all !== "boolean") return void reply({ ok: false, error: "all is true or false" });
8535
+ // #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`.
8536
+ const everything = bus.queueList(msg.peer), settled = msg.all === true ? new Set<string>() : bus.settledIds();
8537
+ const shown = everything.filter((row) => !settled.has(row.id));
8538
+ 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
8539
  }
8191
8540
  if (msg.op === "discard") {
8192
8541
  // #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 +8543,18 @@ export async function startDaemon(opts: DaemonOptions) {
8194
8543
  // because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
8195
8544
  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
8545
  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 });
8546
+ // #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.
8547
+ const KINDS = ["queued", "needs_review", "failed"] as const;
8548
+ if (msg.states !== undefined && msg.state !== undefined) return void reply({ ok: false, error: "give state or states, not both" });
8549
+ 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(", ")}` });
8550
+ 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" });
8551
+ const wanted: readonly string[] = msg.states ?? [msg.state === undefined ? "queued" : msg.state];
8552
+ const settledRows = wanted.includes("failed") ? bus.settledIds() : new Set<string>();
8553
+ const rows = bus.queueList(msg.peer).filter((row) => wanted.includes(row.state) && !(row.state === "failed" && settledRows.has(row.id)));
8554
+ const byState = Object.fromEntries(wanted.map((kind) => [kind, rows.filter((row) => row.state === kind).length]));
8555
+ const state = wanted.length === 1 ? wanted[0]! : wanted.join("+");
8556
+ const many = msg.states !== undefined ? { states: wanted, byState } : {};
8557
+ if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded: 0, refused: 0 });
8201
8558
  if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
8202
8559
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
8203
8560
  let discarded = 0; const refused: Record<string, number> = {};
@@ -8206,7 +8563,7 @@ export async function startDaemon(opts: DaemonOptions) {
8206
8563
  // 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
8564
  const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
8208
8565
  try {
8209
- if (!current || current.state !== state) throw new Error("unknown delivery");
8566
+ if (!current || current.state !== row.state) throw new Error("unknown delivery");
8210
8567
  bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
8211
8568
  if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
8212
8569
  discarded++;
@@ -8214,7 +8571,7 @@ export async function startDaemon(opts: DaemonOptions) {
8214
8571
  catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
8215
8572
  }
8216
8573
  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 } : {}) });
8574
+ 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
8575
  }
8219
8576
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
8220
8577
  if (msg.op === "show") {
@@ -8248,6 +8605,7 @@ export async function startDaemon(opts: DaemonOptions) {
8248
8605
  return void reply({ ok: true });
8249
8606
  }
8250
8607
  case "inbox": {
8608
+ 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
8609
  if (c.toolsOwner) {
8252
8610
  const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
8253
8611
  try {
@@ -8255,9 +8613,9 @@ export async function startDaemon(opts: DaemonOptions) {
8255
8613
  const pending = bus.pendingEnvelopes(peer);
8256
8614
  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
8615
  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);
8616
+ const envs = bus.pull(peer, { allowNeedsReviewHold: true });
8259
8617
  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) });
8618
+ return void reply({ ok: true, envs, held: inboxHeldMetadata(peer), waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
8261
8619
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
8262
8620
  }
8263
8621
  // The attached session can explicitly claim queued work even during a native turn (#493).
@@ -8265,7 +8623,7 @@ export async function startDaemon(opts: DaemonOptions) {
8265
8623
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
8266
8624
  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
8625
  try {
8268
- const envs = bus.pull(peer.id);
8626
+ const envs = bus.pull(peer.id, { allowNeedsReviewHold: true });
8269
8627
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
8270
8628
  peer.lastInboxAt = Date.now();
8271
8629
  let polling: ReturnType<InboxPolling["read"]>;
@@ -8276,7 +8634,7 @@ export async function startDaemon(opts: DaemonOptions) {
8276
8634
  } catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
8277
8635
  writeStatus();
8278
8636
  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 } } : {}) });
8637
+ 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
8638
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
8281
8639
  }
8282
8640
  case "delivery_complete": {
@@ -8959,8 +9317,18 @@ export async function startDaemon(opts: DaemonOptions) {
8959
9317
  }
8960
9318
  return void reply({ ok: false, error: "unknown room operation" });
8961
9319
  }
8962
- case "status":
8963
- return void reply({ t: "status", status: status(c.role === "console", false, true) });
9320
+ case "status": {
9321
+ const current = status(c.role === "console", false, true);
9322
+ if ((c.role === "console" || c.role === "tools") && msg.specs === true) {
9323
+ const summary = projectSpecs.status();
9324
+ if (summary) {
9325
+ const missingBlockHint = !specsMissingBlockHintShown && projectSpecs.missingBlock() === true;
9326
+ if (missingBlockHint) specsMissingBlockHintShown = true;
9327
+ return void reply({ t: "status", status: { ...current, specs: { ...summary, ...(missingBlockHint ? { missingBlockHint: true } : {}) } } });
9328
+ }
9329
+ }
9330
+ return void reply({ t: "status", status: current });
9331
+ }
8964
9332
  case "cli_audit_ack": {
8965
9333
  if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
8966
9334
  if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
@@ -9036,8 +9404,11 @@ export async function startDaemon(opts: DaemonOptions) {
9036
9404
  case "native_tui_launch": {
9037
9405
  if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
9038
9406
  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" });
9407
+ // #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.
9408
+ 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 }); };
9409
+ if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer)) return refuseTui("hub_busy", "owned native TUI launch is unavailable");
9410
+ if (peer !== definitionOf(peer)?.kind || !isJcodePeer(owner)) return refuseTui("not_running", "owned native TUI launch is unavailable");
9411
+ if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return refuseTui("mcp_stale", "owned native TUI launch is unavailable");
9041
9412
  try {
9042
9413
  if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
9043
9414
  const options = msg.args ?? {};
@@ -9045,7 +9416,8 @@ export async function startDaemon(opts: DaemonOptions) {
9045
9416
  return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
9046
9417
  } catch (error) {
9047
9418
  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" });
9419
+ if (error instanceof JcodeTuiRefusal) return refuseTui(error.code, "owned Jcode launch or saved session could not be verified; details withheld", owner.resumedSession);
9420
+ return refuseTui("bad_request", "owned Jcode launch or saved session could not be verified; details withheld");
9049
9421
  }
9050
9422
  }
9051
9423
  case "peer_start":
@@ -9060,7 +9432,7 @@ export async function startDaemon(opts: DaemonOptions) {
9060
9432
  return;
9061
9433
  case "start":
9062
9434
  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" });
9435
+ if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: startHeldByRecovery(msg.peer) });
9064
9436
  if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
9065
9437
  startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
9066
9438
  .catch((e: Error) => ({ ok: false, error: e.message }))
@@ -9093,10 +9465,8 @@ export async function startDaemon(opts: DaemonOptions) {
9093
9465
  // #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
9466
  let current: () => void;
9095
9467
  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
- );
9468
+ runTaskRequest(() => operationalContext.run({ actor: c.peer!, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})), current,
9469
+ error => ({ t: "task", ...operationalFailure(error) }));
9100
9470
  return;
9101
9471
  }
9102
9472
  let invocation: AbortController | undefined;
@@ -9109,14 +9479,14 @@ export async function startDaemon(opts: DaemonOptions) {
9109
9479
  invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
9110
9480
  }
9111
9481
  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: () => {
9482
+ const execute = () => (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
9113
9483
  const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
9114
9484
  if (!live) invocation?.abort();
9115
9485
  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); });
9486
+ } }, run) : run());
9487
+ if (invocation) void execute().then(text => reply({ t: "task", ok: true, text }), (error: Error) => reply({ t: "task", ok: false, error: error.message }))
9488
+ .finally(() => { if (c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
9489
+ else runTaskRequest(execute);
9120
9490
  return;
9121
9491
  case "pause":
9122
9492
  case "resume": {
@@ -9300,6 +9670,7 @@ export async function startDaemon(opts: DaemonOptions) {
9300
9670
  }
9301
9671
  async function stopOnce(): Promise<void> {
9302
9672
  stopping = true;
9673
+ controlWriteTelemetry?.stop();
9303
9674
  roomCommands.stop();
9304
9675
  tasks.stopQueuePressureRechecks();
9305
9676
  const studiesStopped = tasks.stopStudies();
@@ -9399,6 +9770,7 @@ export async function startDaemon(opts: DaemonOptions) {
9399
9770
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
9400
9771
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
9401
9772
  ready = true;
9773
+ controlWriteTelemetry?.start();
9402
9774
  handoffCatchUp();
9403
9775
  tasks.scheduleQueuePressureRecheck();
9404
9776
  for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {