@staix/agent-hub 0.14.0 → 0.16.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 (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +23 -9
  3. package/docs/agent-notes/README.md +8 -0
  4. package/docs/agent-notes/adapters.md +21 -4
  5. package/docs/agent-notes/budget.md +6 -4
  6. package/docs/agent-notes/bus.md +1 -0
  7. package/docs/agent-notes/daemon.md +20 -9
  8. package/docs/agent-notes/local-worker.md +2 -2
  9. package/docs/agent-notes/tasks.md +21 -4
  10. package/docs/agent-notes/tests.md +6 -3
  11. package/docs/dogfood.md +19 -5
  12. package/docs/events.md +21 -7
  13. package/docs/memory.md +78 -10
  14. package/docs/operations.md +197 -39
  15. package/docs/profiles.md +1 -0
  16. package/docs/quickstart.md +16 -6
  17. package/docs/sdlc.md +17 -0
  18. package/docs/security.md +110 -21
  19. package/docs/smoke.md +7 -3
  20. package/docs/specs/2026-09-19-agent-hub-design.md +156 -16
  21. package/docs/verification/2026-10-23-0.15.0.md +80 -0
  22. package/docs/verification/2026-10-23-daemon-responsiveness.md +44 -0
  23. package/docs/verification/2026-10-23-headless-claude-permissions.md +47 -0
  24. package/docs/verification/2026-10-23-terminal-claude-execution-root.md +41 -0
  25. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  26. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  27. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  28. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  29. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  30. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  31. package/docs/verified.json +40 -19
  32. package/package.json +2 -2
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +3109 -2046
  35. package/src/adapters/acp.ts +46 -7
  36. package/src/adapters/claude-channel.ts +26 -4
  37. package/src/adapters/claude-implementer.ts +243 -0
  38. package/src/adapters/claude-print-child.ts +90 -0
  39. package/src/adapters/claude-task-sessions.ts +80 -0
  40. package/src/adapters/codex-appserver.ts +69 -1
  41. package/src/adapters/compat.ts +6 -2
  42. package/src/adapters/conformance/enrollment.ts +2 -1
  43. package/src/adapters/conformance/fakes/acp-server.ts +3 -1
  44. package/src/adapters/definitions/builtin-metadata.ts +17 -5
  45. package/src/adapters/definitions/native-channel-state.ts +1 -1
  46. package/src/adapters/definitions/native-cli-entry-claude.ts +13 -8
  47. package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/kimi-wire-usage.ts +303 -0
  50. package/src/adapters/local-worker.ts +5 -4
  51. package/src/adapters/registry-types.ts +8 -4
  52. package/src/adapters/registry.ts +14 -2
  53. package/src/adapters/reviewer-print.ts +30 -53
  54. package/src/cli/console-config.ts +123 -0
  55. package/src/cli/console-state.ts +48 -3
  56. package/src/cli/console-symbols.ts +3 -2
  57. package/src/cli/console-usage.ts +5 -2
  58. package/src/cli/console.ts +30 -2
  59. package/src/cli/facts-hook.ts +9 -5
  60. package/src/cli/help.ts +10 -7
  61. package/src/cli/identity.ts +3 -1
  62. package/src/cli/launch.ts +10 -4
  63. package/src/cli/main.ts +91 -19
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +40 -4
  66. package/src/cli/reset.ts +1 -1
  67. package/src/cli/settings-edit.ts +89 -0
  68. package/src/cli/terminal-recovery.ts +1 -6
  69. package/src/hub/action-class.ts +211 -17
  70. package/src/hub/action-enforce.ts +90 -0
  71. package/src/hub/action-policy.ts +40 -17
  72. package/src/hub/ask.ts +16 -4
  73. package/src/hub/board.ts +110 -31
  74. package/src/hub/budget-notes.ts +65 -0
  75. package/src/hub/budget.ts +44 -7
  76. package/src/hub/bus.ts +6 -6
  77. package/src/hub/claude-launch-proof.ts +33 -0
  78. package/src/hub/code-facts.ts +56 -16
  79. package/src/hub/conductor.ts +5 -2
  80. package/src/hub/config-apply.ts +12 -0
  81. package/src/hub/config-edit-host.ts +173 -0
  82. package/src/hub/config-edit.ts +282 -0
  83. package/src/hub/config-tier.ts +89 -0
  84. package/src/hub/control-client.ts +24 -3
  85. package/src/hub/daemon.ts +614 -104
  86. package/src/hub/deliverable-files.ts +131 -0
  87. package/src/hub/deliverables.ts +125 -0
  88. package/src/hub/delivery-journal.ts +18 -2
  89. package/src/hub/envelope.ts +1 -1
  90. package/src/hub/events.ts +5 -3
  91. package/src/hub/hub-tools.ts +22 -6
  92. package/src/hub/instances.ts +7 -4
  93. package/src/hub/kimi-usage.ts +156 -0
  94. package/src/hub/lifecycle.ts +10 -5
  95. package/src/hub/manager.ts +1 -1
  96. package/src/hub/memory-code-head.ts +44 -0
  97. package/src/hub/memory-code-overlay.ts +119 -0
  98. package/src/hub/memory-plan-guard.ts +10 -1
  99. package/src/hub/memory-stale-anchors.ts +159 -0
  100. package/src/hub/memory-symbols.ts +74 -0
  101. package/src/hub/peers.ts +7 -0
  102. package/src/hub/permission-payload.ts +15 -0
  103. package/src/hub/planning.ts +10 -4
  104. package/src/hub/read-tools.ts +21 -3
  105. package/src/hub/routing.ts +118 -3
  106. package/src/hub/sdlc.ts +34 -0
  107. package/src/hub/settings.ts +18 -3
  108. package/src/hub/settled-roles.ts +15 -3
  109. package/src/hub/task-sweep.ts +25 -3
  110. package/src/hub/tasks.ts +551 -75
  111. package/src/hub/token-window.ts +46 -0
  112. package/src/hub/usage-rollup.ts +16 -6
  113. package/src/hub/window-kind.ts +13 -0
  114. package/src/hub/workflow.ts +16 -0
  115. package/src/local/tools.ts +5 -5
  116. package/src/memory/brief.ts +22 -12
  117. package/src/memory/capture.ts +26 -40
  118. package/src/memory/client.ts +137 -65
  119. package/src/memory/recall.ts +13 -22
  120. package/src/ui/index.html +215 -19
package/src/hub/daemon.ts CHANGED
@@ -1,5 +1,13 @@
1
+ import { deriveMemoryCodeOverlay, unavailableMemoryCodeOverlay } from "./memory-code-overlay.ts";
2
+ import { readMemoryCodeHead, type MemoryCodeHead } from "./memory-code-head.ts";
3
+ import { canonicalMemorySymbolNode, resolveMemorySymbolNodes, type MemorySymbolNodes } from "./memory-symbols.ts";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { normalizeUsage } from "../omniroute/usage.ts";
6
+ import { permissionPayload } from "./permission-payload.ts";
7
+ import { readClaudeLaunchProof } from "./claude-launch-proof.ts";
1
8
  import { InboxPolling } from "./inbox-polling.ts";
2
9
  import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
10
+ import { actionGate, claudeActionClasses } from "./action-enforce.ts";
3
11
  import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
4
12
  import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
5
13
  import type { DocEvidence } from "./sdlc-release.ts";
@@ -16,7 +24,7 @@ import { renderHeldHint } from "./delivery-hints.ts";
16
24
  import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
17
25
  import { MemoryObservations } from "./memory-observations.ts";
18
26
  import { createStalenessProbe } from "./memory-staleness.ts";
19
- import { matchingPlanFails, planGuardNodes } from "./memory-plan-guard.ts";
27
+ import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
20
28
  import { planningWipLimits } from "./planning.ts";
21
29
  import { projectedDiff } from "./projected-diff.ts";
22
30
  import { listenWithoutPortCollision } from "./listen.ts";
@@ -58,11 +66,17 @@ import { ALL_MACHINE_LOCAL, configRepositoryPresent, configTracked, stripUntrust
58
66
  import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validatePetConfig } from "./config-apply.ts";
59
67
  import { resolveSettledRoles } from "./settled-roles.ts";
60
68
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSources } from "./config-reload.ts";
61
- import { eventLog, readEvents, tokenDeltas, type RoleIdentity } from "./events.ts";
69
+ import { createConfigEditor } from "./config-edit-host.ts";
70
+ import { readEditable } from "./config-edit.ts";
71
+ import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
72
+ import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
73
+ import { TokenWindow } from "./token-window.ts";
74
+ import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
75
+ import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
62
76
  import { ExecutionBudget } from "./execution-budget.ts";
63
77
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
64
78
  import type { ServerWebSocket } from "bun";
65
- import { isAcpPeer, type AcpPeer, QwenIdentityError, type PermissionRequest } from "../adapters/acp.ts";
79
+ import { isAcpPeer, type AcpPeer, QwenIdentityError, ACTION_BUDGET_OPTION, type PermissionRequest, type ActionGateVerdict } from "../adapters/acp.ts";
66
80
  import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
67
81
  import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
68
82
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
@@ -79,7 +93,7 @@ import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableErro
79
93
  import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
80
94
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
81
95
  import { reviewDiff } from "../adapters/claude-print.ts";
82
- import { Capture, skipTools } from "../memory/capture.ts";
96
+ import { Capture } from "../memory/capture.ts";
83
97
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
84
98
  import { Briefs } from "../memory/brief.ts";
85
99
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -93,7 +107,7 @@ import { fileURLToPath } from "node:url";
93
107
  import { realPath, hubHome } from "./project.ts";
94
108
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
95
109
  import type { BusEvent, PublishResult } from "./bus.ts";
96
- import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
110
+ import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, profileTaskTools } from "./hub-tools.ts";
97
111
  import { Conductor, ConductorHolds, conductorPeer, conductorProgressSink, publicConductorTask, publicPeerBudget, type ConductEvent } from "./conductor.ts";
98
112
  import { SupervisionFeed } from "./supervision.ts";
99
113
  import { drainCliAudits } from "../cli/identity-audit.ts";
@@ -111,7 +125,7 @@ import { codeGroup } from "./task-code-group.ts";
111
125
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
112
126
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
113
127
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
114
- import { MemoryClient, workerUrl } from "../memory/client.ts";
128
+ import { MemoryClient } from "../memory/client.ts";
115
129
  import { VERSION } from "../version.ts";
116
130
  import { appendRecords, taskRecords } from "./research.ts";
117
131
  import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
@@ -135,9 +149,9 @@ import { grantablePath, instructionProtection, isPermissionMode, permissionBound
135
149
  import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
136
150
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
137
151
  import { detectTooling } from "./tooling.ts";
138
- import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
152
+ import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
139
153
  import { memoryProjectKey } from "@staix/agent-mem";
140
- import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
154
+ import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
141
155
  import { syncMemory } from "@staix/agent-mem";
142
156
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
143
157
  import { nativeRecall } from "../memory/recall.ts";
@@ -168,7 +182,7 @@ export interface HubConfig {
168
182
  roster: PeerRoster;
169
183
  roles: Record<string, string[]>;
170
184
  /** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
171
- planner?: string;
185
+ planner?: string | string[];
172
186
  profile?: ProfileName;
173
187
  profile_overrides?: Record<string, unknown>;
174
188
  pii?: { paths: string[] };
@@ -277,11 +291,11 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
277
291
  const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
278
292
 
279
293
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
280
- export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
294
+ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
281
295
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
282
296
  const conductor = { ...DEFAULT_CONFIG.conductor };
283
297
  const notices: string[] = [];
284
- let planner: string | undefined;
298
+ let planner: string | string[] | undefined;
285
299
  for (const name of CONFIG_FILES) {
286
300
  let file: Record<string, unknown>;
287
301
  if (layers && !layers.some(layer => layer.name === name)) continue;
@@ -299,8 +313,11 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
299
313
  }
300
314
  }
301
315
  if (file.planner !== undefined) {
302
- if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
303
- planner = file.planner as string;
316
+ if (file.planner === null) throw new Error("planner takes a peer id or a nonempty list of up to eight ids");
317
+ const def = instanceSettingDef("planner", scope.instances, scope.catalog);
318
+ if (typeof def === "string") throw new Error(def);
319
+ const normalized = checkSettingValue(def, file.planner) as string | string[];
320
+ planner = Array.isArray(file.planner) ? [...file.planner] as string[] : normalized;
304
321
  }
305
322
  if (file.conductor !== undefined) {
306
323
  if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
@@ -409,7 +426,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
409
426
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
410
427
  }
411
428
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
412
- const conductorPolicy = loadConductorPolicy(cwd, captured);
429
+ const conductorPolicy = loadConductorPolicy(cwd, captured, instanceConfig);
413
430
  retired.push(...conductorPolicy.notices ?? []);
414
431
  const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
415
432
  const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
@@ -492,6 +509,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
492
509
  }
493
510
 
494
511
  export interface DaemonOptions {
512
+ /** Host-only test/embedding port; never read from project configuration or a tool. */
513
+ memoryClient?: MemoryClient;
495
514
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
496
515
  snapshot?: typeof snapshot;
497
516
  snapshotBurstMs?: number;
@@ -500,6 +519,8 @@ export interface DaemonOptions {
500
519
  checkoutBranch?: typeof mainCheckoutBranch;
501
520
  /** Host-only admission transport; never selected by project config or tools. */
502
521
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
522
+ /** 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. */
523
+ kimiHome?: string;
503
524
  /** Host-only evidence seam; never supplied by project config or control messages. */
504
525
  releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
505
526
  projectId?: string;
@@ -544,6 +565,7 @@ interface Client {
544
565
  role?: "peer" | "console" | "tools";
545
566
  peer?: PeerId;
546
567
  launchId?: string;
568
+ toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
547
569
  tail?: () => void;
548
570
  watch?: () => void;
549
571
  /** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
@@ -603,7 +625,10 @@ export async function startDaemon(opts: DaemonOptions) {
603
625
  const channelPeers = [...ADAPTERS.filter(definition => definition.transport === "claude-channel").map(definition => definition.kind),
604
626
  ...channelExtras.filter(id => adapterCatalog.definitionOf(id, declarations)?.transport === "claude-channel")];
605
627
  const executionRoots = new Map<string, string>();
606
- const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
628
+ const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
629
+ const implementerTranscripts = new Map<string, string>();
630
+ const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
631
+ const verifiedTerminalRootsThisTurn = new Set<object>();
607
632
  const reviewerUnavailable = new Map<string, string>();
608
633
  const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
609
634
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
@@ -679,6 +704,13 @@ export async function startDaemon(opts: DaemonOptions) {
679
704
  let configRevision = 0;
680
705
  let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
681
706
  let pendingRestart: { key: string; reason: string }[] = [];
707
+ // #689: structural config edits. Assigned with the settings section; status and the settings view read it before then, so it is held here.
708
+ let configEditor: ReturnType<typeof createConfigEditor> | undefined;
709
+ const structuralScopes = new Set<string>();
710
+ let sdlcFileReads = 0;
711
+ let sdlcFileCache: { stamp: string; value: { mode: string; requiredChecks: readonly string[] } | string } | undefined;
712
+ /** What waits for a restart: the reload's own keys, then a changed sdlc.toml (read once at start). */
713
+ const allPendingRestart = (): { key: string; reason: string }[] => [...pendingRestart, ...(configEditor?.pendingRestart() ?? [])];
682
714
  let retainedConfig: { key: string; reason: string }[] = [];
683
715
  function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
684
716
  const rows = new Map<string, string>();
@@ -713,8 +745,9 @@ export async function startDaemon(opts: DaemonOptions) {
713
745
  let policyRoles = structuredClone(config.roles);
714
746
  let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
715
747
  let plannerLogged = "";
748
+ const plannerEligible = new Set(projectSettings(declarations, adapterCatalog).find(def => def.key === "planner")?.values ?? []);
716
749
  const settlePlanner = () => {
717
- const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
750
+ const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster, peer => plannerEligible.has(peer));
718
751
  for (const key of Object.keys(config.roles)) delete config.roles[key];
719
752
  Object.assign(config.roles, roles);
720
753
  plannerSource = source;
@@ -722,7 +755,6 @@ export async function startDaemon(opts: DaemonOptions) {
722
755
  if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
723
756
  };
724
757
  const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
725
- if (config.planner !== undefined && !(PLANNER_PEERS as readonly string[]).includes(config.planner)) log(`planner: ${JSON.stringify(config.planner)} is not one of ${PLANNER_PEERS.join(", ")}; ignored`);
726
758
  settlePlanner();
727
759
  const catalog = loadRoleCatalog(opts.cwd);
728
760
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
@@ -840,6 +872,7 @@ export async function startDaemon(opts: DaemonOptions) {
840
872
  sock.data.watch?.();
841
873
  const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
842
874
  if (isWsPeer(peer)) {
875
+ if (sock.data.peer && executionOwners.get(sock.data.peer)?.socket === sock) revokeTerminalExecution(sock.data.peer);
843
876
  peer.detach(sock);
844
877
  if (!!sock.data.peer && !!definitionOf(sock.data.peer)?.attach?.admission && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
845
878
  }
@@ -1030,7 +1063,6 @@ export async function startDaemon(opts: DaemonOptions) {
1030
1063
  }, delayMs);
1031
1064
  recoveryLeaseTimer.unref?.();
1032
1065
  };
1033
- const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
1034
1066
  const chain = projectChain(opts.cwd);
1035
1067
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
1036
1068
  // #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
@@ -1074,18 +1106,96 @@ export async function startDaemon(opts: DaemonOptions) {
1074
1106
  * Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
1075
1107
  * policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
1076
1108
  */
1077
- const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
1109
+ const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs.filter(v => !looksSecret(`${v.title ?? ""}\n${v.text}`) && !v.title?.startsWith(ASK_NOTE_TITLE)), activeRouting(), opts.cwd, config.pii?.paths ?? []);
1078
1110
  let candidates: CandidateStore | undefined;
1079
1111
  if (memoryStore) {
1080
1112
  try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
1081
1113
  catch { log("memory candidate sidecar unavailable"); }
1082
1114
  }
1115
+ let captureBinding = memoryBinding;
1116
+ if (config.memory.enabled && !captureBinding && !memoryProblem) {
1117
+ try { captureBinding = new MemoryProfileBinding({ project: memoryProject, projectKey: memoryProjectRaw }); }
1118
+ catch { memoryUnavailable("memory profile unavailable; capture disabled"); }
1119
+ }
1120
+ const memory = opts.memoryClient ?? new MemoryClient({
1121
+ project: memoryProject, store: memoryStore, candidates,
1122
+ check: () => { if (memoryProblem || !captureBinding) throw new Error("memory unavailable"); captureBinding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) throw new Error("memory project changed"); },
1123
+ screen: rows => screenMemory(rows),
1124
+ publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
1125
+ capture: () => ({ memoryDir: captureBinding!.home }), log,
1126
+ });
1083
1127
  const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
1084
1128
  const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
1085
1129
  const memoryLine = (v: MemoryView) => `mem:${v.id.slice(0, 8)} ${v.kind} ${v.tier}: ${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 160)}`;
1086
1130
  /** A task's own nodes: what its title names (an issue, a path), its refs and plan paths, plan symbols, and the task itself. */
1087
1131
  const taskNodes = (t: { id?: number; created?: number; title?: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }) =>
1088
- extractNodes(t.title ?? "", { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, paths: [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])], symbols: t.plan?.symbols, ...(t.id !== undefined && t.created !== undefined ? { task: { id: t.id, created: t.created } } : {}) });
1132
+ taskMemoryQueryNodes({ ...t, title: t.title ?? "" }, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny });
1133
+
1134
+ const memoryCodeCache = new Map<string, { code: import("./memory-code-overlay.ts").MemoryCodeOverlay; proof: MemoryCodeHead; expires: number }>();
1135
+ const memoryCodeProofs = new Map<string, MemoryCodeHead>();
1136
+ const memoryCodeSeeds = new WeakMap<object, Set<string>>();
1137
+ let memoryCodeNextStart = 0;
1138
+ async function memoryCode(starts: readonly string[], valid: () => boolean = () => true, cacheOnly = false) {
1139
+ if (!memoryStore || !starts.length || !valid()) return unavailableMemoryCodeOverlay();
1140
+ const key = JSON.stringify([memoryRepo, starts]), now = performance.now(), cached = memoryCodeCache.get(key);
1141
+ if (cached && cached.expires > now && cached.proof.current() === cached.code.head) return cached.code;
1142
+ if (cacheOnly || now < memoryCodeNextStart) return unavailableMemoryCodeOverlay();
1143
+ memoryCodeNextStart = now + 5000;
1144
+ const proof = readMemoryCodeHead(opts.cwd); if (!proof) return unavailableMemoryCodeOverlay();
1145
+ const head = proof.head; memoryCodeProofs.set(head, proof);
1146
+ if (memoryCodeProofs.size > 32) memoryCodeProofs.delete(memoryCodeProofs.keys().next().value!);
1147
+ const code = await deriveMemoryCodeOverlay(starts, {
1148
+ context: () => ({ cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny }),
1149
+ head: () => head,
1150
+ valid: () => { checkMemory(); return !stopping && valid() && proof.current() === head; },
1151
+ publicMetadataText: text => tasks.publicMetadataText(text) && boundedSafe(text),
1152
+ neighbours: (root, head, selector, depth) => codeFacts.neighbours(root, head, selector, depth, { priority: "optional" }),
1153
+ related: (nodes, limit) => screenMemory(memoryStore!.related(memoryProject, [...nodes], limit)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })),
1154
+ });
1155
+ memoryCodeSeeds.set(code, new Set(starts.map(raw => canonicalMemorySymbolNode(raw, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText })).filter((id): id is string => !!id)));
1156
+ memoryCodeCache.set(key, { code, proof, expires: performance.now() + (code.state === "known" ? 30_000 : 5000) });
1157
+ if (memoryCodeCache.size > 32) memoryCodeCache.delete(memoryCodeCache.keys().next().value!);
1158
+ return code;
1159
+ }
1160
+ function currentMemoryCode(code: import("./memory-code-overlay.ts").MemoryCodeOverlay) {
1161
+ try {
1162
+ checkMemory();
1163
+ if (!code.head || memoryCodeProofs.get(code.head)?.current() !== code.head || !tasks.publicMetadataText(JSON.stringify({ ...code, notes: [] })) || !boundedSafe(JSON.stringify({ ...code, notes: [] }))) return unavailableMemoryCodeOverlay();
1164
+ const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: (text: string) => tasks.publicMetadataText(text) && boundedSafe(text) };
1165
+ if (code.nodes.some(node => canonicalMemorySymbolNode(node.id, context) !== node.id)) return unavailableMemoryCodeOverlay();
1166
+ for (const note of code.notes) {
1167
+ const row = memoryStore?.get(note.id.slice(4), memoryProject);
1168
+ if (!row || `mem:${row.id}` !== note.id || !screenMemory([row]).length || !boundedSafe(JSON.stringify(row))) return unavailableMemoryCodeOverlay();
1169
+ }
1170
+ const seeds = memoryCodeSeeds.get(code) ?? new Set<string>();
1171
+ const neighbours = code.nodes.map(node => node.id).filter(id => !seeds.has(id));
1172
+ const rows = memoryStore && neighbours.length ? screenMemory(memoryStore.related(memoryProject, neighbours, 128)) : [];
1173
+ const notes = rows.filter(row => row.about.some(id => neighbours.includes(id)) && boundedSafe(JSON.stringify(row)))
1174
+ .sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || b.at.localeCompare(a.at)).slice(0, 16)
1175
+ .map(row => ({ id: `mem:${row.id}`, line: memoryLine(row), kind: row.kind, about: row.about, tier: row.tier, peer: row.peer, machine: row.machine, at: row.at }));
1176
+ return { ...code, notes };
1177
+ } catch { return unavailableMemoryCodeOverlay(); }
1178
+ }
1179
+ async function taskMemoryResult(task: import("../memory/brief.ts").BriefTask, cacheOnly = false) {
1180
+ const maximum = config.memory.brief_items;
1181
+ const current = () => task.id === undefined ? undefined : board.get(task.id), initial = current();
1182
+ const valid = () => { const value = current(); return !!initial && !!value && value.created === task.created && value.updated === initial.updated && value.history.length === initial.history.length && value.owner === initial.owner && tasks.memoryCodeEligible(value) && !turns.get(value.owner ?? "")?.private && !contextPii(value.owner ?? ""); };
1183
+ if (!memoryStore || initial && tasks.isPii(initial)) return { items: [], state: "unknown" as const };
1184
+ const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
1185
+ let code = unavailableMemoryCodeOverlay();
1186
+ try { if (valid() && starts.length) { const prepared = await memoryCode(starts, valid, cacheOnly); if (valid()) code = currentMemoryCode(prepared); } }
1187
+ catch { /* Optional code failure must not discard existing direct memory advice. */ }
1188
+ checkMemory();
1189
+ const latest = current(); if (latest && tasks.isPii(latest)) return { items: [], state: "unknown" as const };
1190
+ // Optional code evidence never suppresses the existing direct path/title/symbol memory brief.
1191
+ const direct = screenMemory(memoryStore.related(memoryProject, taskNodes(task), 128)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
1192
+ const items = [...new Map([...direct, ...code.notes].map(row => [row.id, row])).values()]
1193
+ .filter(row => [row.line, ...(row.about ?? [])].every(tasks.publicMetadataText))
1194
+ .sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || Number(b.kind === "decision") - Number(a.kind === "decision"))
1195
+ .slice(0, maximum);
1196
+ return { items, state: starts.length ? code.state : "known" as const, ...(code.head ? { head: code.head } : {}) };
1197
+ }
1198
+ async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
1089
1199
 
1090
1200
  const leaseNoticed = new Set<string>();
1091
1201
  const goalLine = (g: GoalView) => `goal:${g.id.slice(0, 8)} ${g.state} ${g.title}${g.lease.holder ? ` (held by ${g.lease.holder === memoryStore?.machine.id ? "this machine" : `machine ${g.lease.holder.slice(0, 8)}`} until ${g.lease.until}${g.lease.conflict.length ? `; conflicting claim from ${g.lease.conflict.map((m) => m.slice(0, 8)).join(", ")}` : ""})` : ""}`;
@@ -1157,7 +1267,7 @@ export async function startDaemon(opts: DaemonOptions) {
1157
1267
  /** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
1158
1268
  async function ensurePreface(peer: PeerId): Promise<void> {
1159
1269
  if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
1160
- // The hub's own long-term decisions, contracts and fails lead; claude-mem's recent work follows, each with half the budget.
1270
+ // The hub's own long-term decisions, contracts and fails lead; amem's curated short-term notes follow, each with half the budget.
1161
1271
  let own: string | undefined;
1162
1272
  try {
1163
1273
  checkMemory();
@@ -1224,6 +1334,7 @@ export async function startDaemon(opts: DaemonOptions) {
1224
1334
  const board = new Board(join(opts.stateDir, "hub.db"));
1225
1335
  const codeFacts = new CodeFacts();
1226
1336
  const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1337
+ const memorySymbols = new Map<string, MemorySymbolNodes>();
1227
1338
  startupCleanup.push(() => board.close());
1228
1339
  startupCleanup.push(() => codeFacts.close());
1229
1340
  const supervision = new SupervisionFeed({
@@ -1405,12 +1516,16 @@ export async function startDaemon(opts: DaemonOptions) {
1405
1516
  reviewOnly: reviewOnlyPeer,
1406
1517
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1407
1518
  reviewAuthority: runningReviewAuthority,
1519
+ requestAuthority: by => {
1520
+ const scope = childToolAuthority.getStore();
1521
+ return scope?.by === by ? () => scope.current() ? undefined : "Claude child generation or execution root changed" : undefined;
1522
+ },
1408
1523
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1409
1524
  reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1410
1525
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
1411
1526
  // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1412
1527
  ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
1413
- const bound = executionOwners.get(id);
1528
+ const bound = currentExecutionOwner(id);
1414
1529
  return [id, !!bound && bound.owner === bus.peers.get(id) && bound.cwd !== opts.cwd && bound.cwd === executionRoots.get(id)];
1415
1530
  })),
1416
1531
  recordPlanning: change => event({ type: "planning", ...change }),
@@ -1419,6 +1534,9 @@ export async function startDaemon(opts: DaemonOptions) {
1419
1534
  profile: profileName(config.profile),
1420
1535
  profileData: resolvedProfile,
1421
1536
  piiPaths: config.pii?.paths,
1537
+ deny: config.local.deny,
1538
+ // #509a: deliverables are hashed under the owner's verified execution root (a declared worktree instance's worktree), `undefined` when unproven.
1539
+ executionRoot: peer => ownerWriteRoot(peer),
1422
1540
  catalog,
1423
1541
  sessionRoles: peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline" ? preparedSessionRoles.get(peer) ?? [] : [],
1424
1542
  projectedReview: task => reviewProjection(task),
@@ -1430,8 +1548,23 @@ export async function startDaemon(opts: DaemonOptions) {
1430
1548
  const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1431
1549
  if ("unavailable" in brief) return;
1432
1550
  const key = JSON.stringify([root, brief.head, brief.mergeBase]);
1433
- reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
1434
- if (reviewSymbols.size > 64) reviewSymbols.delete(reviewSymbols.keys().next().value!);
1551
+ // prepareOnly and completion share one exact-tree pass, including an unknown result.
1552
+ if (memoryStore && memorySymbols.has(key)) return;
1553
+ const observed = await codeFacts.changedSymbols(root, brief.mergeBase, brief.head);
1554
+ reviewSymbols.set(key, observed);
1555
+ const candidates = tasks.memorySymbolCandidates(task);
1556
+ if (memoryStore && candidates && !tasks.isPii(board.get(task.id) ?? task)) {
1557
+ const nodes = await resolveMemorySymbolNodes(candidates,
1558
+ { cwd: root, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText },
1559
+ name => { const current = board.get(task.id); return !current || current.owner !== task.owner || tasks.isPii(current) ? Promise.resolve({ state: "unknown" }) : codeFacts.exists(root, brief.head, name); });
1560
+ memorySymbols.set(key, nodes);
1561
+ }
1562
+ if (reviewSymbols.size > 64) { const oldest = reviewSymbols.keys().next().value!; reviewSymbols.delete(oldest); memorySymbols.delete(oldest); }
1563
+ },
1564
+ memorySymbolNodes: task => {
1565
+ if (tasks.isPii(task) || !task.codeSymbols || task.codeSymbols.head !== task.refs.commit) return undefined;
1566
+ const root = executionRoot(task.owner ?? "");
1567
+ return memorySymbols.get(JSON.stringify([root, task.codeSymbols.head, task.codeSymbols.mergeBase]));
1435
1568
  },
1436
1569
  reviewBrief: task => {
1437
1570
  const root = executionRoot(task.owner ?? "");
@@ -1449,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
1449
1582
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1450
1583
  memoryCheck: checkMemory,
1451
1584
  ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1452
- memoryStore ? (t) => { checkMemory(); return screenMemory(memoryStore!.related(memoryProject, taskNodes(t), Number.MAX_SAFE_INTEGER)).slice(0, config.memory.brief_items).map((v) => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })); } : undefined,
1453
- (peer, task, counts) => event({ type: "memory_brief", peer, task, ...counts }),
1585
+ memoryStore ? taskMemoryItems : undefined,
1586
+ (peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
1454
1587
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
1455
1588
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
1456
1589
  checkMemory();
@@ -1554,7 +1687,10 @@ export async function startDaemon(opts: DaemonOptions) {
1554
1687
  const facts: Facts = new Facts({ root: opts.cwd, repo: factsRepo, tmp: join(opts.stateDir, "facts"), instance: instanceId.slice(0, 8), scope: (peer) => repositoryFacts ? (scopes.has(peer) ? scopes.get(peer) : scopes.set(peer, tasks.factScope(peer)).get(peer)) : undefined, peers: () => [...bus.peers.keys()], nameable: tasks.nameable, deny: config.local.deny, privatePath: path => matchesPolicy(opts.cwd, path, config.pii?.paths ?? []), projection: file => projections.project(file) });
1555
1688
  const worktreeFacts = new Map<string, Facts>();
1556
1689
  const worktreeProjections = new Map<string, TextProjections>();
1557
- const executionRoot = (peer: string) => executionRoots.get(peer) ?? opts.cwd;
1690
+ const executionRoot = (peer: string) => {
1691
+ if (executionOwners.get(peer)?.socket && !currentExecutionOwner(peer)) return opts.cwd;
1692
+ return executionRoots.get(peer) ?? opts.cwd;
1693
+ };
1558
1694
  const repoFor = (peer: string, budget?: SnapshotBudget) => executionRoot(peer) === opts.cwd ? factsRepo : repoOf(executionRoot(peer), budget);
1559
1695
  const projectionsFor = (peer: string): TextProjections => {
1560
1696
  const root = executionRoot(peer);
@@ -1856,7 +1992,7 @@ export async function startDaemon(opts: DaemonOptions) {
1856
1992
  }
1857
1993
  for (const move of record.moved) moves.set(JSON.stringify([move.id, move.role, move.to, move.title]), move);
1858
1994
  const unreadMoves = [...moves.values()];
1859
- if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume) kimiTokens.length = 0; // a new window: the old counts would pause it again at once
1995
+ if (definitionOf(record.peer)?.budget?.resetSoftTokensOnResume) kimiUsage.reset(); // a new window: the old counts would pause it again at once
1860
1996
  const moved = unreadMoves.length ? `While you were paused these moved: ${unreadMoves.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : earlier.length ? "Moves from earlier pauses are preserved in the unread notices below." : "Nothing was moved while you were paused.";
1861
1997
  bus.publish(newEnvelope(HUB, `Your quota window has reset and the hub has resumed you (paused since ${new Date(record.since).toLocaleTimeString()}, ${record.reason}). ${moved}${earlier.length ? ` Earlier unread resume notices: ${earlier.join("\n")}` : ""} Messages queued for you follow.`, { to: [record.peer], kind: "budget", priority: "important", refs: { budgetResume: record.peer, budgetResumeMoves: unreadMoves, ...(earlier.length ? { budgetResumeEarlier: earlier } : {}) } }));
1862
1998
  // Publish the complete replacement before removing its evidence. A failed write keeps the old notices.
@@ -1868,8 +2004,35 @@ export async function startDaemon(opts: DaemonOptions) {
1868
2004
  // ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
1869
2005
  // 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
1870
2006
  // read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
1871
- budget.seedResets(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
1872
- const kimiTokens: { at: number; n: number }[] = [];
2007
+ const startupEvents = readEvents(join(opts.stateDir, "events.jsonl"));
2008
+ budget.seedResets(startupEvents.flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
2009
+ // #285 phase 2: Kimi's consumption is the hub's own count of its session wire log (`src/adapters/kimi-wire-usage.ts`), not an ACP total.
2010
+ const kimiUsage = new KimiUsage({
2011
+ home: opts.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), ".kimi-code"),
2012
+ now: () => Date.now(),
2013
+ ceiling: () => config.budget.kimi_tokens_5h,
2014
+ session: peer => contextSession(peer),
2015
+ record: (peer, row, counted) => {
2016
+ addTokens(peer, counted);
2017
+ event({ type: "usage", peer, source: "kimi_wire", id: row.id, measuredAt: new Date(row.at).toISOString(), inputTokens: row.inputOther, outputTokens: row.output,
2018
+ cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
2019
+ ...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
2020
+ },
2021
+ report: (peer, window) => budget.report(peer, [{ id: "tokens", kind: "count", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
2022
+ forget: peer => budget.forget(peer, "tokens", "count"),
2023
+ notice: line => notify(line),
2024
+ });
2025
+ kimiUsage.seed(kimiSeedRows(startupEvents, Math.max(0, ...startupEvents.flatMap(e => e.type === "budget_resume" && definitionOf(e.peer)?.budget?.resetSoftTokensOnResume ? [Date.parse(e.at)] : []).filter(Number.isFinite))));
2026
+ /** Why a peer shows no number (no quota, nothing read yet): one line each, from the definition and the counters, never from a log's text. */
2027
+ const budgetPeerNotes = (): BudgetPeerNote[] => {
2028
+ const quota = budget.status();
2029
+ const peers = [...new Set([...bus.knownPeers().filter(peer => bus.peers.has(peer) && bus.stateOf(peer) !== "offline"), ...Object.keys(quota)])].filter(peer => peer !== USER && peer !== HUB);
2030
+ return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
2031
+ hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
2032
+ };
2033
+ /** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end and a usage_update. */
2034
+ const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
2035
+ const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
1873
2036
  startupCleanup.push(() => budget.close());
1874
2037
  // #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
1875
2038
  const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
@@ -1968,13 +2131,70 @@ export async function startDaemon(opts: DaemonOptions) {
1968
2131
  function privateSnapshotWindow(): boolean {
1969
2132
  return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
1970
2133
  }
2134
+ function revokeTerminalExecution(peer: string): void {
2135
+ const bound = executionOwners.get(peer);
2136
+ if (bound) verifiedTerminalRootsThisTurn.delete(bound);
2137
+ executionOwners.delete(peer); executionRoots.delete(peer);
2138
+ worktreeFacts.delete(peer); worktreeProjections.delete(peer);
2139
+ }
2140
+ function currentExecutionOwner(peer: string) {
2141
+ const bound = executionOwners.get(peer);
2142
+ if (bound?.socket) {
2143
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2144
+ if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2145
+ revokeTerminalExecution(peer);
2146
+ return undefined;
2147
+ }
2148
+ // A new event-loop turn revalidates Git inventory and the attached branch before
2149
+ // an admission/effect. Reuse only within synchronous code, never across an await.
2150
+ if (!verifiedTerminalRootsThisTurn.has(bound)) {
2151
+ try {
2152
+ if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2153
+ } catch { revokeTerminalExecution(peer); return undefined; }
2154
+ verifiedTerminalRootsThisTurn.add(bound);
2155
+ queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
2156
+ }
2157
+ // The hello claim is not attached until its asynchronous preface finishes.
2158
+ if (!bound.owner.owns(bound.socket)) return undefined;
2159
+ } else if (bound && definitionOf(peer)?.start.daemonPort === "claude-implementer") {
2160
+ try {
2161
+ if (bound.owner !== bus.peers.get(peer) || bound.owner.state === "offline" || bound.cwd === opts.cwd) throw new Error("execution root changed");
2162
+ if (!verifiedTerminalRootsThisTurn.has(bound)) {
2163
+ if (resolveInstanceExecutionCwd(opts.cwd, peer, declarations, config.roles[peer] ?? [], adapterCatalog) !== bound.cwd) throw new Error("execution root changed");
2164
+ verifiedTerminalRootsThisTurn.add(bound); queueMicrotask(() => verifiedTerminalRootsThisTurn.delete(bound));
2165
+ }
2166
+ } catch { revokeTerminalExecution(peer); return undefined; }
2167
+ }
2168
+ return bound;
2169
+ }
1971
2170
  function ownerWriteRoot(peer: string): string | undefined {
1972
2171
  if (declarations[peer]) {
1973
- const bound = executionOwners.get(peer);
2172
+ const bound = currentExecutionOwner(peer);
1974
2173
  if (!bound || bound.owner !== bus.peers.get(peer) || bound.cwd !== executionRoots.get(peer)) return undefined;
1975
2174
  }
1976
2175
  return executionRoot(peer);
1977
2176
  }
2177
+ function implementerChatTask(peer: string): number | undefined {
2178
+ const owned = board.list("in_progress").filter(task => task.owner === peer && !tasks.isPii(task));
2179
+ return owned.length === 1 ? owned[0]!.id : undefined;
2180
+ }
2181
+ function currentImplementerTools(c: Client, verifyRoot = true): ReturnType<NonNullable<PeerAdapter["ownedTools"]>> {
2182
+ const bound = c.toolsOwner, peer = c.peer;
2183
+ if (!bound || !peer || c.role !== "tools" || stopping || recoveryActive() || !membershipAllowed(peer) || contextPii(peer) || definitionOf(peer)?.start.daemonPort !== "claude-implementer" || bus.peers.get(peer) !== bound.owner) return undefined;
2184
+ const launch = bound.owner.ownedTools?.(bound.launchId);
2185
+ if (!launch || launch.signal.aborted || launch.generation !== bound.generation || bound.cwd === opts.cwd) return undefined;
2186
+ const root = verifyRoot ? ownerWriteRoot(peer) : executionOwners.get(peer)?.owner === bound.owner ? executionRoots.get(peer) : undefined;
2187
+ if (root !== bound.cwd) return undefined;
2188
+ if (launch.task !== undefined) {
2189
+ const task = board.get(launch.task);
2190
+ if (!task || task.owner !== peer || task.state === "cancelled" || tasks.isPii(task)) return undefined;
2191
+ }
2192
+ return launch;
2193
+ }
2194
+ function captureImplementerTools(c: Client): () => boolean {
2195
+ const binding = c.toolsOwner, launch = currentImplementerTools(c);
2196
+ return () => c.toolsOwner === binding && !!launch && currentImplementerTools(c) === launch;
2197
+ }
1978
2198
  function canonicalWriteScope(repo: Repo, budget?: SnapshotBudget): boolean | undefined {
1979
2199
  // ponytail: D1 is inert for builtin owners while .worktrees exists (a deliberate false negative).
1980
2200
  // Bind each owner to its real execution worktree before narrowing this unknown scope.
@@ -2033,28 +2253,27 @@ export async function startDaemon(opts: DaemonOptions) {
2033
2253
  const paths = changedPaths(repo, from, to).filter(path => { const local = path.slice(repo.prefix.length); return scopes.some(scope => !scope || local === scope || local.startsWith(scope + "/")); });
2034
2254
  return projectedDiff({ repo, from, to, paths, projections, deny: config.local.deny, nameable: tasks.nameable, privatePath: path => matchesPolicy(root, join(repo.top, path), config.pii?.paths ?? []) });
2035
2255
  }
2036
- const delta = tokenDeltas();
2037
- /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
2256
+ /** Tokens a peer used, as increments. */
2038
2257
  let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
2039
- const addTokens = (peer: PeerId, n: number): number => {
2258
+ // #285 AC6: a peer with no provider quota (Pi, local) still shows what the hub counted for it in the last 5 h, as information, never as a meter or a gate.
2259
+ const hubTokenWindows = new Map<PeerId, TokenWindow>();
2260
+ const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
2261
+ const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
2262
+ // Replayed through `seed`, which skips rows older than 5 h before inserting any: a long-lived project's whole log costs one pass, not one search per row.
2263
+ const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
2264
+ for (const e of startupEvents) if (e.type === "tokens" && noQuotaPeer(e.peer)) (seedRows.get(e.peer) ?? seedRows.set(e.peer, []).get(e.peer)!).push({ at: Date.parse(e.at), n: e.n });
2265
+ for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
2266
+ const addTokens = (peer: PeerId, n: number, task?: number): number => {
2267
+ if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
2040
2268
  const supervisionTurn = supervisionTurns.get(peer);
2041
2269
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
2042
2270
  if (n > 0) {
2043
- event({ type: "tokens", peer, n, ...taskAttribution(peer) });
2271
+ event({ type: "tokens", peer, n, ...taskAttribution(peer, task) });
2044
2272
  const turn = turns.get(peer);
2045
2273
  if (turn) turn.tokens += n;
2046
2274
  }
2047
2275
  return n;
2048
2276
  };
2049
- const onKimiTokens = (total: number, session: string, peer: string) => {
2050
- const n = addTokens(peer, delta(peer, total, session));
2051
- if (!config.budget.kimi_tokens_5h || !n) return;
2052
- const now = Date.now();
2053
- kimiTokens.push({ at: now, n });
2054
- while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
2055
- const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
2056
- budget.report(peer, [{ id: "tokens", used, resetsAt: kimiTokens[0]!.at + 5 * 3_600_000, source: "kimi usage_update (soft limit)" }]);
2057
- };
2058
2277
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
2059
2278
  let claudeUsageSeen = 0;
2060
2279
  let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
@@ -2085,7 +2304,7 @@ export async function startDaemon(opts: DaemonOptions) {
2085
2304
  try { tasks.flushPlannerNotices(); } catch { /* the board remains authoritative if notice delivery fails */ }
2086
2305
  for (const pending of checkpointWaits.values()) if (!pending.valid()) pending.done(undefined);
2087
2306
  for (const [id, request] of permissions) if (request.expiresAt > Date.now()) supervision.approvalWaiting({ id, peer: request.peer, tool: request.tool ?? "unknown", createdAt: request.createdAt });
2088
- for (const row of bus.queueList()) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
2307
+ for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review") supervision.needsReview(row.peer, row.id);
2089
2308
  feedBudgetPauses();
2090
2309
  supervision.checkRound();
2091
2310
  try {
@@ -2256,10 +2475,10 @@ export async function startDaemon(opts: DaemonOptions) {
2256
2475
  return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
2257
2476
  }
2258
2477
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
2259
- // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
2478
+ // shared memory and a new task could be routed to a cloud peer without matching any pattern.
2260
2479
  if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
2261
2480
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
2262
- // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2481
+ // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2263
2482
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
2264
2483
  const target = board.get(Number(a.id));
2265
2484
  if (target && !tasks.isPii(target)) throw new PreEffectToolRefusal(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
@@ -2281,9 +2500,17 @@ export async function startDaemon(opts: DaemonOptions) {
2281
2500
  if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
2282
2501
  }
2283
2502
  if (MEMORY_READ_TOOLS.has(op)) {
2503
+ validateMemoryRead(op, a);
2284
2504
  need("memory_read", "read shared memory");
2505
+ if (!boundedSafe(JSON.stringify(a))) throw new PreEffectToolRefusal("memory read unavailable");
2506
+ checkMemory();
2507
+ const generation = connectionGeneration(by);
2508
+ const valid = () => may(by, "memory_read") && connectionGeneration(by) === generation && !turns.get(by)?.private && !contextPii(by);
2509
+ const symbolStarts = op === "hub_memory_graph" ? (a.starts as string[]).filter(node => node.startsWith("symbol:")) : [];
2510
+ const code = symbolStarts.length ? await memoryCode(symbolStarts, valid).catch(() => unavailableMemoryCodeOverlay()) : undefined;
2511
+ if (!valid()) throw new PreEffectToolRefusal("memory read unavailable");
2285
2512
  checkMemory();
2286
- return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness());
2513
+ return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness(), code ? currentMemoryCode(code) : undefined);
2287
2514
  }
2288
2515
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
2289
2516
  if (op === "hub_remember") need("remember", "save notes to shared memory");
@@ -2435,7 +2662,7 @@ export async function startDaemon(opts: DaemonOptions) {
2435
2662
  case "hub_task_decline":
2436
2663
  return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
2437
2664
  case "hub_task_done": {
2438
- const t = await tasks.done(by, a.id, a.summary, a.refs);
2665
+ const t = await tasks.done(by, a.id, a.summary, a.refs, a.deliverables);
2439
2666
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
2440
2667
  if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
2441
2668
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
@@ -2449,7 +2676,7 @@ export async function startDaemon(opts: DaemonOptions) {
2449
2676
  }
2450
2677
  case "hub_sdlc_record":
2451
2678
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2452
- return line(tasks.recordLifecycle(by, a.id, a.receipt));
2679
+ return line(await tasks.recordSdlc(by, a.id, a.receipt));
2453
2680
  case "hub_review":
2454
2681
  return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
2455
2682
  case "hub_remember":
@@ -2468,7 +2695,7 @@ export async function startDaemon(opts: DaemonOptions) {
2468
2695
  chmodSync(`${file}.tmp`, 0o600);
2469
2696
  renameSync(`${file}.tmp`, file);
2470
2697
  pending.done(summary);
2471
- if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project: opts.cwd, metadata: { peer: by, kind: "handover" } }).catch(() => undefined);
2698
+ if (config.memory.enabled) await memory.save({ text: summary, title: `agent-hub ${by} context checkpoint`, project: memoryProject, metadata: { peer: by, kind: "handover" } }).catch(() => undefined);
2472
2699
  notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
2473
2700
  return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
2474
2701
  }
@@ -2492,7 +2719,8 @@ export async function startDaemon(opts: DaemonOptions) {
2492
2719
  const task = board.get(Number(a.id));
2493
2720
  if (task?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
2494
2721
  if (!task) return JSON.stringify(`no task #${a.id}`);
2495
- return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), flowRevision: task.history.length });
2722
+ const peerDeliverables = await tasks.deliverableStatus(task);
2723
+ return JSON.stringify({ ...reviewerView(by, publicConductorTask(task, (t) => tasks.publicView(t, true))), ...briefField(task), ...(peerDeliverables ? { deliverables: peerDeliverables } : {}), flowRevision: task.history.length });
2496
2724
  }
2497
2725
  if (op === "route_explain" && by !== USER) {
2498
2726
  if (a.id !== undefined && board.get(Number(a.id))?.state === "backlog") throw new PreEffectToolRefusal("backlog is read through planner tools");
@@ -2528,13 +2756,22 @@ export async function startDaemon(opts: DaemonOptions) {
2528
2756
  if (!task) return JSON.stringify(`no task #${a.id}`);
2529
2757
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2530
2758
  const codeSymbols = tasks.publicView(task).codeSymbols;
2531
- return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2759
+ const consoleDeliverables = await tasks.deliverableStatus(task);
2760
+ const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
2761
+ const fresh = board.get(task.id);
2762
+ const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
2763
+ return JSON.stringify({ ...detail, relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2764
+ }
2765
+ case "deliverables": {
2766
+ // #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
2767
+ if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
2768
+ return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true }));
2532
2769
  }
2533
2770
  case "task_assign":
2534
2771
  case "task_assign_checked": {
2535
2772
  const peer = peerArg(a.peer, "peer");
2536
2773
  if (!peer) throw new Error("peer is required");
2537
- if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer") throw new Error("role must be owner or reviewer");
2774
+ if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer" && a.role !== "planner") throw new Error("role must be owner, reviewer or planner");
2538
2775
  if (op === "task_assign_checked" && (typeof a.revision !== "number" || !Number.isSafeInteger(a.revision) || a.revision < 0)) throw new Error("revision must be a nonnegative integer");
2539
2776
  return line(await tasks.assignTo(a.id, peer, USER, true, a.role, op === "task_assign_checked" ? a.revision : undefined));
2540
2777
  }
@@ -2612,7 +2849,44 @@ export async function startDaemon(opts: DaemonOptions) {
2612
2849
  else delete defaultSources[peer];
2613
2850
  }
2614
2851
  };
2615
- const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
2852
+ // Action allowances have their own host session port; adding local here must not change checkpoints, context telemetry or supervision.
2853
+ const actionSession = (peer: PeerId): string | undefined => {
2854
+ const owner = bus.peers.get(peer);
2855
+ if (isLocalPeer(owner)) { const id = owner.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
2856
+ return contextSession(peer);
2857
+ };
2858
+ const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
2859
+ // #636 slice 2: the always-ask gate of an ACP peer, built per peer from the host's own ports (root, task, PII screen); never from the agent.
2860
+ const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
2861
+ task: p => board.list("in_progress").find(t => t.owner === p), publicView: task => tasks.publicView(task), isPrivate: p => !!turns.get(p)?.private || contextPii(p),
2862
+ nameable: text => tasks.nameable(text) }, peer);
2863
+ // A peer is enforceable only where the hub sees the call before it runs, in the mode it runs in now; anything else says why, never a silent claim.
2864
+ // Claude: the launcher installed the PreToolUse hook (an `--unattended` launch skips every permission check, hooks included). ACP: a hub-owned
2865
+ // adapter with the gate, and only while its native mode is `ask`: Kimi's `yolo` and `auto` modes send no request for a call they allow.
2866
+ const actionEnforcement = (peer: PeerId): { enforced: boolean; reason?: string } => {
2867
+ if (definitionOf(peer)?.transport === "claude-channel") {
2868
+ const launch = claudePermissionLaunch(peer), attached = bus.peers.get(peer);
2869
+ // The launch record outlives the process, so it proves nothing alone: the attached session must be the generation whose hook last spoke to the hub.
2870
+ if (!launch) return { enforced: false, reason: "no hub permission hook in this Claude session (start it with ahub claude)" };
2871
+ if (launch.unattended) return { enforced: false, reason: "this Claude launch is --unattended: its permission checks, hooks included, are off" };
2872
+ if (!isWsPeer(attached) || attached.state === "offline" || claudePermissionGenerations.get(peer) !== attached.sessionGeneration)
2873
+ return { enforced: false, reason: "no live Claude session has run the hub permission hook yet" };
2874
+ return { enforced: true };
2875
+ }
2876
+ const owner = bus.peers.get(peer);
2877
+ if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
2878
+ const generation = owner?.sessionGeneration;
2879
+ return owner?.state === "busy" && typeof generation === "string" && generation.length > 0 ? { enforced: true } : { enforced: false, reason: "no current managed Claude implementer child" };
2880
+ }
2881
+ if (isCodexPeer(owner)) return { enforced: false, reason: owner.actionGated ? "Codex approval RPCs are gated, but native policy can execute without sending one (never-ask sends approvalPolicy never)" : "this Codex proxy has no hub approval gate" };
2882
+ if (isLocalPeer(owner)) return owner.state !== "offline" ? { enforced: true } : { enforced: false, reason: "the local worker is offline" };
2883
+ if (isPiPeer(owner)) return owner.state !== "offline" && owner.acceptingTools ? { enforced: true } : { enforced: false, reason: "Pi has no verified live tool bridge" };
2884
+ if (isAcpPeer(owner) && owner.actionGated) {
2885
+ const mode = owner.permissionModeState;
2886
+ return mode === "ask" ? { enforced: true } : { enforced: false, reason: mode === "unknown" || mode === "unmanaged" ? "its native approval mode is not managed by the hub, so a request may never be sent" : `its native mode ${mode} sends no request for a call it allows` };
2887
+ }
2888
+ return { enforced: false, reason: "this adapter has no hub pre-tool approval path" };
2889
+ };
2616
2890
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2617
2891
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2618
2892
  const unattendedPeers = new Set<string>();
@@ -2632,8 +2906,10 @@ export async function startDaemon(opts: DaemonOptions) {
2632
2906
  owner: row => {
2633
2907
  const owner = bus.peers.get(row.peer), proof = owner?.terminalOwner?.(row.launchId);
2634
2908
  if (!proof) return undefined;
2909
+ const terminalRootCurrent = () => !declarations[row.peer] || definitionOf(row.peer)?.transport !== "claude-channel" || !config.roles[row.peer]?.includes("implementer") || (ownerWriteRoot(row.peer) === row.executionCwd && row.executionCwd !== opts.cwd);
2910
+ if (!terminalRootCurrent()) return undefined;
2635
2911
  return { ...proof, isCurrent: () => {
2636
- if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner) return false;
2912
+ if (stopping || !membershipAllowed(row.peer) || bus.peers.get(row.peer) !== owner || !terminalRootCurrent()) return false;
2637
2913
  const current = owner?.terminalOwner?.(row.launchId);
2638
2914
  return current?.generation === proof.generation && current?.nativePid === proof.nativePid;
2639
2915
  } };
@@ -2706,8 +2982,8 @@ export async function startDaemon(opts: DaemonOptions) {
2706
2982
  if (value.instanceId !== instanceId) return {};
2707
2983
  let currentLauncher = false;
2708
2984
  try {
2709
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
2710
- if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) {
2985
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2986
+ if (launch) {
2711
2987
  currentLauncher = true;
2712
2988
  if (value.launchId !== launch.launchId) return {};
2713
2989
  }
@@ -2806,7 +3082,7 @@ export async function startDaemon(opts: DaemonOptions) {
2806
3082
  // filled one stays in the row and never matches.
2807
3083
  const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
2808
3084
  const integrity = (older = 0) => {
2809
- const queues = Object.fromEntries(Object.keys(bus.snapshot().queues).sort().map((id) => [id, bus.queueIds(id)]));
3085
+ const queues = Object.fromEntries(Object.keys(bus.snapshot(false).queues).sort().map((id) => [id, bus.queueIds(id)]));
2810
3086
  const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
2811
3087
  const row: Record<string, unknown> = { ...t };
2812
3088
  for (const [col, empty] of ADDED_COLUMNS.slice(0, older)) if (JSON.stringify(row[col]) === empty) delete row[col];
@@ -2885,11 +3161,12 @@ export async function startDaemon(opts: DaemonOptions) {
2885
3161
  instanceId,
2886
3162
  version: VERSION,
2887
3163
  pluginBundle: SERVER_JS,
2888
- pendingRestart,
3164
+ pendingRestart: allPendingRestart(),
2889
3165
  retainedConfig,
2890
3166
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
2891
3167
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
2892
3168
  memoryProfile: memoryProfileStatus(includeAttentionItems),
3169
+ memoryRead: memory.readStatus(),
2893
3170
  lifecycleStages: [...lifecycleConfig.stages],
2894
3171
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
2895
3172
  ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
@@ -2904,6 +3181,8 @@ export async function startDaemon(opts: DaemonOptions) {
2904
3181
  readOnlyPeers: extraIds.filter(readOnlyPeer),
2905
3182
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
2906
3183
  activeInstances: extraIds,
3184
+ ownerIsolation: Object.fromEntries(extraIds.map(id => [id, !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd])),
3185
+ ownerIsolationNotes: Object.fromEntries(extraIds.filter(id => definitionOf(id)?.transport === "claude-channel" && !ownerWriteRoot(id)).map(id => [id, "terminal execution root not verified; owner admission refused"])),
2907
3186
  stopping,
2908
3187
  pid: process.pid,
2909
3188
  cwd: opts.cwd,
@@ -2911,11 +3190,11 @@ export async function startDaemon(opts: DaemonOptions) {
2911
3190
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2912
3191
  codexProxyPort: opts.codexProxyPort,
2913
3192
  peers: Object.fromEntries(
2914
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
3193
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: !!ownerWriteRoot(id) && ownerWriteRoot(id) !== opts.cwd, ...(!ownerWriteRoot(id) ? { ownerIsolationNote: "terminal execution root not verified; owner admission refused" } : {}) } : {}), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2915
3194
  ),
2916
3195
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2917
3196
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2918
- actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
3197
+ actionPolicyInfo: Object.fromEntries(peerIds.map(id => { const e = actionEnforcement(id); return [id, actionBudgets.status(id, e.enforced, e.reason)]; })),
2919
3198
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2920
3199
  planner: { peers: plannerPeers(), source: plannerSource },
2921
3200
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
@@ -2969,7 +3248,7 @@ export async function startDaemon(opts: DaemonOptions) {
2969
3248
  // A queue change is not a bus event: without this, status.json reports the queue as it was before the last
2970
3249
  // message and keeps a phantom `queued N` after the queue drains (measured on a live 0.6.3 hub).
2971
3250
  function heldRows(peer: PeerId) {
2972
- return bus.queueList(peer).filter(row => row.state === "needs_review").map(row => ({
3251
+ return bus.queueList(peer, true).filter(row => row.state === "needs_review").map(row => ({
2973
3252
  id: row.id, senders: [...new Set(row.originals.map(env => sanitize(env.from)))],
2974
3253
  kinds: [...new Set(row.originals.map(env => env.kind))], createdAt: row.createdAt,
2975
3254
  context: deliveryTaskContext(row.originals, board, peer),
@@ -2985,7 +3264,7 @@ export async function startDaemon(opts: DaemonOptions) {
2985
3264
  if (settlingSuperseded || stopping || recoveryActive() || bus.isRecoveryHeld) return;
2986
3265
  settlingSuperseded = true;
2987
3266
  try {
2988
- for (const row of bus.queueList()) {
3267
+ for (const row of bus.queueList(undefined, true)) {
2989
3268
  if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
2990
3269
  const reason = supersededDeliveryReason(row.originals, board, row.peer);
2991
3270
  if (!reason) continue;
@@ -3286,7 +3565,9 @@ export async function startDaemon(opts: DaemonOptions) {
3286
3565
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
3287
3566
  turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
3288
3567
  event({ type: "turn_start", peer: e.peer, turn: id });
3568
+ if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer); } catch { /* counting never costs a turn */ }
3289
3569
  } else if (!busy && open) {
3570
+ collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
3290
3571
  turns.delete(e.peer);
3291
3572
  let afterTurn: (() => void) | undefined;
3292
3573
  let files: number | undefined;
@@ -3374,6 +3655,10 @@ export async function startDaemon(opts: DaemonOptions) {
3374
3655
  try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
3375
3656
  }
3376
3657
  }
3658
+ if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer); kimiUsage.drop(e.peer); }
3659
+ // #636: a class-budget allowance belongs to the owner that earned it. A stopped, crashed or removed owner that later resumes the same native
3660
+ // session id starts from zero (a replaced adapter's own `offline` is muted, so a new owner also revokes where it is created).
3661
+ if (e.state === "offline") actionBudgets.revoke(e.peer);
3377
3662
  if (e.state === "offline") { offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now()); supervision.peerOffline(e.peer); supervisionTurns.delete(e.peer); }
3378
3663
  else offlineSince.delete(e.peer);
3379
3664
  // A session that ended may come back without hooks: its context path is verified again or not at all (issue #108).
@@ -3453,6 +3738,28 @@ export async function startDaemon(opts: DaemonOptions) {
3453
3738
  const result = await requestPermission(req, signal);
3454
3739
  return result.outcome === "answered" ? result.optionId : undefined;
3455
3740
  }
3741
+ /** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
3742
+ async function classedToolPermission(peer: string, root: string, title: string, tool: string | undefined, input: unknown, signal: AbortSignal | undefined, current: () => boolean, observe?: ApprovalObserver): Promise<ToolApproval | undefined> {
3743
+ if (!tool || input === undefined) return undefined;
3744
+ if (stopping || signal?.aborted || !current()) return "aborted";
3745
+ // runTool executes in this host root and ignores model-supplied cwd/workdir fields.
3746
+ const effective = input && typeof input === "object" && !Array.isArray(input) ? { ...input, cwd: root, workdir: root } : input;
3747
+ let gate: ActionGateVerdict | undefined;
3748
+ try { gate = acpActionGate(peer)({ names: [tool], input: effective }, actionSession(peer) ?? "", title); }
3749
+ catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
3750
+ if (!gate) return undefined;
3751
+ const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
3752
+ if (gate.denyOnly) options.splice(0, 1);
3753
+ if (!gate.denyOnly && gate.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" });
3754
+ const picked = await requestPermission({ peer, title: gate.title, tool, classed: true, options }, signal, observe);
3755
+ if (picked.outcome === "expired") return "expired";
3756
+ if (stopping || signal?.aborted || !current()) return "aborted";
3757
+ if (picked.outcome !== "answered") return false;
3758
+ if (!gate.denyOnly && picked.optionId === ACTION_BUDGET_OPTION) {
3759
+ try { return gate.budget?.approve() === true; } catch { return false; }
3760
+ }
3761
+ return !gate.denyOnly && picked.optionId === "allow";
3762
+ }
3456
3763
  async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
3457
3764
  const requestEpoch = permissionAnswerEpoch(req.peer);
3458
3765
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
@@ -3460,7 +3767,8 @@ export async function startDaemon(opts: DaemonOptions) {
3460
3767
  catch { try { log(`permission observer failed for ${req.peer}`); } catch { /* settlement is authoritative */ } }
3461
3768
  };
3462
3769
  if (stopping || signal?.aborted) { observed("aborted"); return { outcome: "cancelled" }; }
3463
- if (opts.unattended) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
3770
+ // Always-ask (#636): a classed call is never answered for the person, `--unattended` included; it waits for a person and expires as a denial.
3771
+ if (opts.unattended && !req.classed) { observed("automatic"); return { outcome: "answered", optionId: req.options.find((o) => o.kind === "allow_once")?.optionId }; }
3464
3772
  const id = randomUUID().slice(0, 8);
3465
3773
  // The title is written by an agent and read by the person approving it: escape sequences and carriage returns
3466
3774
  // could repaint the terminal line, so everything but newline and tab is made visible.
@@ -3676,7 +3984,7 @@ export async function startDaemon(opts: DaemonOptions) {
3676
3984
  const owner = bus.peers.get(peer);
3677
3985
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3678
3986
  if (stopNotice) return { ok: false, error: stopNotice };
3679
- if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner))) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3987
+ if (!(isAcpPeer(owner) || isPiPeer(owner) || isLocalPeer(owner) || isReviewerPrintPeer(owner) || definitionOf(peer)?.start.daemonPort === "claude-implementer" && !!owner?.ownedTools)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3680
3988
  if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3681
3989
  if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3682
3990
  if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
@@ -3852,9 +4160,11 @@ export async function startDaemon(opts: DaemonOptions) {
3852
4160
  }
3853
4161
  if (adapter?.start.acpCommand) {
3854
4162
  const cmd = adapter.start.acpCommand(config.kimi_cmd, args.model);
4163
+ actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
3855
4164
  const kimi: AcpPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["acp"]({
3856
4165
  onActivity: item => activityRings[peer]?.append(item),
3857
4166
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
4167
+ actionGate: acpActionGate(peer),
3858
4168
  cmd, kind: adapter.kind,
3859
4169
  permissionMode: permissionMode(peer),
3860
4170
  ...(args.model ? { launchModel: args.model } : {}),
@@ -3868,7 +4178,6 @@ export async function startDaemon(opts: DaemonOptions) {
3868
4178
  // session's configured MCP servers (issue #138). Exact names only; a stopping hub approves nothing.
3869
4179
  autoApprove: (title) => !stopping && HUB_TOOL_TITLES.has(title),
3870
4180
  log,
3871
- onTokens: (total, session) => onKimiTokens(total, session, peer),
3872
4181
  // Pause synchronously before the adapter requeues a provider-refused prompt (#306).
3873
4182
  onQuotaRefusal: (window) => {
3874
4183
  budget.report(peer, [window], true);
@@ -3879,6 +4188,7 @@ export async function startDaemon(opts: DaemonOptions) {
3879
4188
  // adapter's own session id; report() drops it when that is not the daemon's current session. An invalid
3880
4189
  // shape reports a null reading: unknown, not the previous value (#285).
3881
4190
  onUsageDiagnostic: (observation, sessionId) => {
4191
+ if (observation.source === "usage_update") collectKimiUsage(peer); // phase 2: the update is the cue to read the wire log, never a token total
3882
4192
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
3883
4193
  const current = contextSession(peer);
3884
4194
  if (!current) return;
@@ -3904,9 +4214,11 @@ export async function startDaemon(opts: DaemonOptions) {
3904
4214
  userArgs: [], unattended: false, env: {}, nativeCommand: configuredIds.includes(adapter.kind) ? undefined : config.qwen_cmd, model: args.model });
3905
4215
  const mcpServers = [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }];
3906
4216
  const permission = readOnlyPeer(peer) ? "read-only" as const : permissionMode(peer);
4217
+ actionBudgets.revoke(peer); // a new owner never inherits the allowance of the one it replaces, even for the same session id
3907
4218
  const owner = adapter.start.create({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: undefined, permission, mcpServers, daemon: { acp: {
3908
4219
  onActivity: item => activityRings[peer]?.append(item),
3909
4220
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
4221
+ actionGate: acpActionGate(peer),
3910
4222
  kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3911
4223
  ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
3912
4224
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
@@ -3939,9 +4251,11 @@ export async function startDaemon(opts: DaemonOptions) {
3939
4251
  if (adapter?.start.daemonPort === "codex-appserver") {
3940
4252
  if (args.unattended && permissionMode(peer) !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask first, or restart without --unattended" };
3941
4253
  let codexSteering = false;
4254
+ actionBudgets.revoke(peer);
3942
4255
  const codex: CodexPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["codex-appserver"]({
3943
4256
  admitAttach: () => membershipAllowed(peer),
3944
4257
  permissionMode: permissionMode(peer),
4258
+ actionGate: acpActionGate(peer), onPermission,
3945
4259
  onTokens: (added) => void addTokens(peer, added),
3946
4260
  // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
3947
4261
  // the id names the hub run, peer and a per-run sequence, so it is unique across restarts. It never carries the native thread: usage telemetry has no session id (docs/events.md).
@@ -4040,7 +4354,11 @@ export async function startDaemon(opts: DaemonOptions) {
4040
4354
  let piReply: Envelope | undefined;
4041
4355
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
4042
4356
  const piAlways = new Set<string>();
4043
- const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
4357
+ actionBudgets.revoke(peer);
4358
+ const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
4359
+ if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4360
+ const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => valid() && pi.acceptingTools && bus.peers.get(peer) === pi, observe);
4361
+ if (classed !== undefined) return classed;
4044
4362
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4045
4363
  const selected = permissionMode(peer);
4046
4364
  if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
@@ -4090,8 +4408,8 @@ export async function startDaemon(opts: DaemonOptions) {
4090
4408
  const nativeTurn = pi.observationTurn;
4091
4409
  const output = TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name) ? await taskOp(peer, name, raw as Record<string, unknown>, true) : await runTool(name, JSON.stringify(raw), { ...ctx, signal,
4092
4410
  ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
4093
- permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
4094
- const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath);
4411
+ permit: async (title, tool, toolSignal, path, _observe, originalPath, input) => {
4412
+ const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input);
4095
4413
  return picked; // never turn a nonempty expiry/abort string into authorization
4096
4414
  },
4097
4415
  });
@@ -4201,9 +4519,13 @@ export async function startDaemon(opts: DaemonOptions) {
4201
4519
  if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
4202
4520
  if (existing) { mute(existing); await existing.stop(); }
4203
4521
  const capture = config.memory.enabled
4204
- ? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
4522
+ ? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
4205
4523
  : undefined;
4206
- const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
4524
+ actionBudgets.revoke(peer);
4525
+ const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
4526
+ if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4527
+ const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => bus.peers.get(peer) === local, observe);
4528
+ if (classed !== undefined) return classed;
4207
4529
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4208
4530
  const selected = permissionMode(peer);
4209
4531
  if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
@@ -4256,6 +4578,51 @@ export async function startDaemon(opts: DaemonOptions) {
4256
4578
  await local.start();
4257
4579
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
4258
4580
  }
4581
+ if (adapter?.start.daemonPort === "claude-implementer") {
4582
+ if (args.unattended) return { ok: false, error: "Claude implementer refuses unattended permission bypass" };
4583
+ const currentRoot = () => {
4584
+ const roles = config.roles[peer] ?? [];
4585
+ return !stopping && !recoveryActive() && membershipAllowed(peer) && !contextPii(peer) && roles.includes("implementer") && roles.every(role => role === "implementer" || role === "verifier") && permissionMode(peer) !== "never-ask" && resolveInstanceExecutionCwd(opts.cwd, peer, declarations, roles, adapterCatalog) === executionCwd && executionCwd !== opts.cwd;
4586
+ };
4587
+ try { if (!declarations[peer]?.worktree || !currentRoot()) return { ok: false, error: "Claude implementer requires an isolated worktree and ask permissions" }; }
4588
+ catch { return { ok: false, error: "Claude implementer execution root unavailable" }; }
4589
+ if (existing?.state === "busy") return { ok: false, error: "Claude implementer is busy; cleanup must finish before restart" };
4590
+ if (existing) { mute(existing); await existing.stop(); if (!currentRoot()) throw new Error("Claude implementer startup admission changed"); }
4591
+ const selected = permissionMode(peer);
4592
+ if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
4593
+ const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
4594
+ cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
4595
+ factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms,
4596
+ admit: env => {
4597
+ try {
4598
+ if (env.private || !currentRoot() || bus.peers.get(peer) !== implementer) return false;
4599
+ if (env.refs?.task === undefined) return true;
4600
+ const task = board.get(Number(env.refs.task));
4601
+ return !!task && task.owner === peer && !tasks.isPii(task) && ["proposed", "in_progress", "changes_requested"].includes(task.state);
4602
+ } catch { return false; }
4603
+ },
4604
+ chatTask: () => implementerChatTask(peer),
4605
+ transcriptPersisted: (task, session) => {
4606
+ const path = implementerTranscripts.get(`${peer}:${task}:${session}`);
4607
+ return path ? pathEvidence(path, lstatSync) : "unknown";
4608
+ },
4609
+ onUsage: (usage, task) => {
4610
+ if (bus.peers.get(peer) !== implementer) return;
4611
+ const normalized = normalizeUsage(usage), total = claudeReportedTokens(normalized);
4612
+ if (total !== undefined) addTokens(peer, total, task);
4613
+ event({ type: "usage", peer, source: "claude_result", id: `${runId}:${peer}:${++codexUsageSeq}`, ...normalized,
4614
+ ...(total !== undefined ? { totalTokens: total } : {}), ...taskAttribution(peer, task) });
4615
+ }, notify,
4616
+ }) });
4617
+ await implementer.start();
4618
+ try {
4619
+ if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4620
+ recoveryTaskPreface(peer); await ensurePreface(peer);
4621
+ if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
4622
+ executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
4623
+ return { ok: true };
4624
+ } catch (error) { await implementer.stop().catch(() => {}); throw error; }
4625
+ }
4259
4626
  if (adapter?.reviewer) {
4260
4627
  const backend = adapter.reviewer.backend;
4261
4628
  if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
@@ -4381,7 +4748,7 @@ export async function startDaemon(opts: DaemonOptions) {
4381
4748
  const summary = bus.queueSummary(peer);
4382
4749
  return { peer, state: bus.stateOf(peer), attached: !!bus.peers.get(peer) && bus.peers.get(peer)!.state !== "offline", queued: bus.queued(peer), needsReview: summary.needsReview,
4383
4750
  manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
4384
- ...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source })) } : {}) };
4751
+ ...(quota[accountOf(peer)] ? { windows: quota[accountOf(peer)]!.windows.map(w => ({ id: w.id, ...(w.stale || (w.resetsAt !== undefined && w.resetsAt <= Date.now()) ? {} : { used: w.used }), resetsAt: w.resetsAt, at: w.at, source: w.source, kind: w.kind, ...(w.superseded ? { superseded: true } : {}) })) } : {}) };
4385
4752
  }),
4386
4753
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
4387
4754
  };
@@ -4478,9 +4845,25 @@ export async function startDaemon(opts: DaemonOptions) {
4478
4845
  entries: rows.map((v) => shape(v)), ...(node ? { node, graph: memoryStore.graph(memoryProject, node, hops, 200), ...(selected ? { detail: shape(selected, true) } : {}) } : {}) } };
4479
4846
  } catch (e) { log(`hub memory view failed: ${(e as Error).message}`); return {}; } // an unreadable store never breaks the dashboard
4480
4847
  }
4848
+ async function uiMemorySnapshot(after: number, input?: Record<string, unknown>, session?: DashboardSession) {
4849
+ const query = input?.memory as Record<string, unknown> | undefined;
4850
+ const start = input?.view === "memory" && query && query.codeNeighbours === true && typeof query.node === "string" && query.node.startsWith("symbol:") && Buffer.byteLength(query.node) <= 512 ? query.node : undefined;
4851
+ const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
4852
+ const snapshot = uiSnapshot(after, input, session);
4853
+ if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
4854
+ const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
4855
+ const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
4856
+ published.notes = published.notes.filter(note => note.tier === tier && (!query?.kind || note.kind === query.kind) && (!query?.peer || note.peer === query.peer) && (!query?.machine || note.machine === query.machine)
4857
+ && (!query?.since || typeof query.since !== "string" || !note.at || note.at >= query.since)
4858
+ && (!search || (() => { const row = memoryStore?.get(note.id.slice(4), memoryProject); return !!row && `${row.title ?? ""} ${row.text}`.toLowerCase().includes(search); })()));
4859
+ Object.assign(snapshot.memory, { code: published });
4860
+ }
4861
+ return snapshot;
4862
+ }
4481
4863
  function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
4482
4864
  const quota = budget.status();
4483
4865
  const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
4866
+ const budgetNoteOf = new Map(budgetPeerNotes().map(note => [note.peer, note] as const));
4484
4867
  // #264 (phase 3): turn, checking and links are additive optional fields that older pages ignore,
4485
4868
  // so PROTOCOL and EVENTS_SCHEMA stay unchanged. They carry ids and enums only.
4486
4869
  const turnView = (peer: PeerId) => {
@@ -4545,7 +4928,7 @@ export async function startDaemon(opts: DaemonOptions) {
4545
4928
  instanceId,
4546
4929
  attention: attentionStatus.attentionInput,
4547
4930
  pet,
4548
- status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4931
+ status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4549
4932
  boardCapabilities: { backlog: true },
4550
4933
  planningCapabilities: { enabled: true, wipPull: true, split: true },
4551
4934
  releases: tasks.releaseList(USER),
@@ -4558,10 +4941,10 @@ export async function startDaemon(opts: DaemonOptions) {
4558
4941
  tasks: boardTasks.map((task) => {
4559
4942
  const view = tasks.publicView(task);
4560
4943
  // Refs and history can themselves quote private text. The dashboard needs neither.
4561
- return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
4944
+ return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}), ...(() => { const deliverables = tasks.deliverableBadge(task); return deliverables ? { deliverables } : {}; })() };
4562
4945
  }),
4563
4946
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
4564
- budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
4947
+ budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), ...(budgetNoteOf.get(id) ? { note: budgetNoteOf.get(id)!.note, noteKind: budgetNoteOf.get(id)!.kind } : {}), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
4565
4948
  permissions: [...permissions.values()].map(({ push }) => {
4566
4949
  const req = JSON.parse(push);
4567
4950
  // Local tool titles quote file contents and commands, including those of PII turns.
@@ -4618,7 +5001,7 @@ export async function startDaemon(opts: DaemonOptions) {
4618
5001
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4619
5002
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4620
5003
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4621
- const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
5004
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled), peer => plannerEligible.has(peer));
4622
5005
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4623
5006
  }
4624
5007
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -4674,7 +5057,7 @@ export async function startDaemon(opts: DaemonOptions) {
4674
5057
  settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
4675
5058
  if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
4676
5059
  }
4677
- const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
5060
+ const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key), structural: !!scope && structuralScopes.has(scope) };
4678
5061
  const decision = authorizeConfigLeaf(input);
4679
5062
  if (!decision.allowed) {
4680
5063
  const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
@@ -4854,7 +5237,7 @@ export async function startDaemon(opts: DaemonOptions) {
4854
5237
  /** For the dashboard: the rows, and until when this session may raise. A project whose files do not read still gets its dashboard. */
4855
5238
  // ponytail: reads the two config files and the undo record and parses the routing files on each dashboard poll; cache by mtime if a page ever feels it.
4856
5239
  function settingsSnapshot(session?: DashboardSession): { settings?: Record<string, unknown> } {
4857
- try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
5240
+ try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}), ...(configEditor ? { configEdit: configEditor.view() } : {}) } }; }
4858
5241
  catch { return {}; }
4859
5242
  }
4860
5243
  function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
@@ -4875,8 +5258,8 @@ export async function startDaemon(opts: DaemonOptions) {
4875
5258
  },
4876
5259
  });
4877
5260
  const undo = pendingUndo(opts.stateDir);
4878
- const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
4879
- return { pendingRestart, retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
5261
+ const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it pins the listed peers as the only planners; new tasks select one of those holders. Agents cannot change this setting.`;
5262
+ return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
4880
5263
  }
4881
5264
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
4882
5265
  const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
@@ -5114,6 +5497,46 @@ export async function startDaemon(opts: DaemonOptions) {
5114
5497
  return { ...preview, ...identity, lines: [`Undo ${def.key}: put back ${settingText(value)}`, ...(Array.isArray(preview.lines) ? preview.lines : []), "The saved previous machine-local file is revalidated before writing."] };
5115
5498
  }
5116
5499
 
5500
+ // #689: structural config edits (src/hub/config-edit-host.ts). The editor decides authority, the typed confirmation and the audit event;
5501
+ // the daemon supplies the loaders' verdict, the hold and the one scoped reload path. A structural key is allowed through reload authority
5502
+ // only while its own scope is registered here, for the duration of that one reload.
5503
+ configEditor = createConfigEditor({
5504
+ cwd: opts.cwd, stateDir: opts.stateDir,
5505
+ check: (file, text) => {
5506
+ if (file !== "config.local.json") return;
5507
+ const texts = configSourceTexts(opts.cwd);
5508
+ texts["config.local.json"] = text;
5509
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
5510
+ const candidate = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
5511
+ // Bounds and kinds are the raw-config check parseConfigSources just ran; the effective merge below is loadConfig itself.
5512
+ checkJcodeInstanceHomes(sources.identity.instances, candidate.jcode_home);
5513
+ },
5514
+ held: () => stopping || recoveryActive() ? "recovery or shutdown" : undefined,
5515
+ reload: async (authority, key) => {
5516
+ structuralScopes.add(key);
5517
+ try { return await reloadConfig(authority, key); } finally { structuralScopes.delete(key); }
5518
+ },
5519
+ event: entry => event(entry),
5520
+ notify: text => notify(text),
5521
+ changed: () => writeStatus(),
5522
+ pending: () => pendingRestart,
5523
+ effective: path => valueAt(config, path),
5524
+ runningSdlc: () => ({ mode: lifecycleConfig.mode, requiredChecks: lifecycleConfig.requiredChecks }),
5525
+ // Status and the settings view poll this: read sdlc.toml again only when its stamp changed. ponytail: the stamp is mtime, ctime, inode and size, so an in-place rewrite of the same size inside one filesystem timestamp tick is missed until the next change; it feeds display only (a status line), never an authority. A watcher is the upgrade if a poll ever shows.
5526
+ fileSdlc: () => {
5527
+ const stamp = (() => { try { const info = statSync(join(opts.cwd, ".agenthub", "sdlc.toml")); return `${info.mtimeMs}:${info.ctimeMs}:${info.ino}:${info.size}`; } catch { return "-"; } })();
5528
+ if (sdlcFileCache?.stamp !== stamp) {
5529
+ let value: { mode: string; requiredChecks: readonly string[] } | string;
5530
+ sdlcFileReads++;
5531
+ try { const loaded = loadSdlc(opts.cwd, VERSION); value = { mode: loaded.mode, requiredChecks: loaded.requiredChecks }; } catch (error) { value = sanitize((error as Error).message).slice(0, 300); }
5532
+ sdlcFileCache = { stamp, value };
5533
+ }
5534
+ return sdlcFileCache.value;
5535
+ },
5536
+ declared: () => Object.fromEntries(Object.entries(declarations).map(([id, value]) => [id, { ...value }])),
5537
+ localConfig: () => { try { const text = readEditable(opts.cwd, "config.local.json"); const doc = text === null ? {} : JSON.parse(text); return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : {}; } catch { return {}; } },
5538
+ });
5539
+
5117
5540
  async function uiAction(a: Record<string, unknown>, session?: DashboardSession): Promise<unknown> {
5118
5541
  const bad = { ok: false, error: "invalid or unavailable dashboard action" };
5119
5542
  // #269: a session opened by `ahub ui --settings` may raise authority while its window lasts; any other may not.
@@ -5199,6 +5622,11 @@ export async function startDaemon(opts: DaemonOptions) {
5199
5622
  return a.undo === true ? settingsUndoPreview() : settingsPreview(a.key, a.value);
5200
5623
  case "setting_reload":
5201
5624
  return reloadConfig(authority);
5625
+ case "config_edit_preview":
5626
+ return configEditor!.preview(a.edit, authority);
5627
+ case "config_edit_apply":
5628
+ // A dashboard session is never given the typed confirmation of an executable key: that is the console's, and refused here by the editor.
5629
+ return configEditor!.apply(a.edit, authority, undefined, a.reason);
5202
5630
  default:
5203
5631
  return bad;
5204
5632
  }
@@ -5245,6 +5673,9 @@ export async function startDaemon(opts: DaemonOptions) {
5245
5673
  if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
5246
5674
  if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
5247
5675
  if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
5676
+ if (extraIds.some(id => adapterCatalog.definitionOf(declarations[id]!.kind)?.transport === "claude-channel" && declarations[id]!.roles?.includes("implementer")) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("role_stages"))) return "target lacks role_stages; declared Claude implementer authority cannot be restored by that daemon";
5677
+ if ((Array.isArray(config.planner) || typeof config.planner === "string" && extraIds.includes(config.planner) || extraIds.some(id => declarations[id]!.roles?.includes("planner")) || board.hasPlannerState())
5678
+ && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_planners"))) return "target lacks task_planners; configured or persisted planner authority cannot be restored by that daemon";
5248
5679
  if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
5249
5680
  if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
5250
5681
  return undefined;
@@ -5366,11 +5797,8 @@ export async function startDaemon(opts: DaemonOptions) {
5366
5797
  }
5367
5798
 
5368
5799
  function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5369
- try {
5370
- const row = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5371
- if (row.instanceId !== instanceId || row.permissionHook !== true || typeof row.launchId !== "string" || !row.launchId) return undefined;
5372
- return { launchId: row.launchId, unattended: row.unattended === true, ...(typeof row.hookPurpose === "string" ? { hookPurpose: row.hookPurpose } : {}) };
5373
- } catch { return undefined; }
5800
+ const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
5801
+ return row?.permissionHook ? row : undefined;
5374
5802
  }
5375
5803
  async function changePermission(peer: unknown, mode: unknown, confirmed: unknown): Promise<Record<string, unknown>> {
5376
5804
  if (!isSupportedPeer(peer)) return { ok: false, error: "unknown permission peer" };
@@ -5480,7 +5908,10 @@ export async function startDaemon(opts: DaemonOptions) {
5480
5908
 
5481
5909
  function onMessage(sock: Sock, msg: any): void {
5482
5910
  const c = sock.data;
5483
- const reply = (body: Record<string, unknown>) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body })); };
5911
+ const reply = (body: Record<string, unknown>) => {
5912
+ if (c.toolsOwner && !currentImplementerTools(c)) body = { ok: false, error: "Claude child generation or execution root changed" };
5913
+ if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
5914
+ };
5484
5915
  if (!c.authed) {
5485
5916
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
5486
5917
  if (msg.v !== PROTOCOL) {
@@ -5501,6 +5932,13 @@ export async function startDaemon(opts: DaemonOptions) {
5501
5932
  c.peer = String(msg.peer ?? "");
5502
5933
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5503
5934
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5935
+ if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
5936
+ const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
5937
+ if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
5938
+ c.launchId = msg.launchId;
5939
+ c.toolsOwner = { owner, launchId: launch.launchId, generation: launch.generation, cwd: root };
5940
+ if (!currentImplementerTools(c)) return sock.close(4403, "Claude child admission changed");
5941
+ }
5504
5942
  // #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
5505
5943
  // peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
5506
5944
  if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
@@ -5517,8 +5955,8 @@ export async function startDaemon(opts: DaemonOptions) {
5517
5955
  if (definitionOf(c.peer)?.transport === "claude-channel" && msg.launchId !== undefined) {
5518
5956
  let currentLaunch: unknown;
5519
5957
  try {
5520
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", c.peer)), "utf8"));
5521
- if (launch.instanceId === instanceId && typeof launch.launchId === "string" && launch.launchId) currentLaunch = launch.launchId;
5958
+ const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
5959
+ if (launch) currentLaunch = launch.launchId;
5522
5960
  } catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
5523
5961
  if (currentLaunch !== undefined && msg.launchId !== currentLaunch) return sock.close(4430, "Claude launch ended; start a new session with ahub claude");
5524
5962
  if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
@@ -5534,6 +5972,17 @@ export async function startDaemon(opts: DaemonOptions) {
5534
5972
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
5535
5973
  preparedSessionRoleIdentities.delete(c.peer);
5536
5974
  ws.claim(sock, msg.channel === false);
5975
+ if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
5976
+ revokeTerminalExecution(c.peer);
5977
+ const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
5978
+ try {
5979
+ const root = resolveInstanceExecutionCwd(opts.cwd, c.peer, declarations, config.roles[c.peer] ?? [], adapterCatalog);
5980
+ if (root !== opts.cwd && launch?.privateRecord && launch.launchId === c.launchId && launch.executionCwd === root && typeof msg.cwd === "string" && realPath(msg.cwd) === root) {
5981
+ executionRoots.set(c.peer, root);
5982
+ executionOwners.set(c.peer, { owner: ws, cwd: root, socket: sock, launchId: launch.launchId, generation: ws.sessionGeneration });
5983
+ }
5984
+ } catch { /* Unknown or changed worktree evidence never grants owner isolation. */ }
5985
+ }
5537
5986
  if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
5538
5987
  if (definitionOf(c.peer)?.transport === "claude-channel") logPermissionStart(c.peer);
5539
5988
  writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
@@ -5548,7 +5997,24 @@ export async function startDaemon(opts: DaemonOptions) {
5548
5997
  }
5549
5998
  if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
5550
5999
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
6000
+ if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
5551
6001
  switch (msg.t) {
6002
+ case "permission_request": {
6003
+ const peer = c.peer, launch = currentImplementerTools(c), current = captureImplementerTools(c);
6004
+ if (!peer || !launch || c.role !== "tools" || typeof msg.tool !== "string" || !msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)) return void reply({ ok: false, behavior: "deny", message: "permission unavailable" });
6005
+ const shown = permissionPayload(msg.tool, msg.input, tasks.publicMetadataText);
6006
+ const pending = new AbortController(), abortPending = () => pending.abort();
6007
+ launch.signal.addEventListener("abort", abortPending, { once: true });
6008
+ // Heartbeats keep an approval alive; they grant nothing. Admission and the final
6009
+ // answer still revalidate native root evidence, without synchronous Git on every tick.
6010
+ const heartbeat = setInterval(() => { if (currentImplementerTools(c, false) === launch) c.toolsOwner?.owner.keepApprovalAlive?.(); else pending.abort(); }, Math.max(10, Math.min(1000, Math.floor(config.watchdog_ms / 3)))); heartbeat.unref();
6011
+ c.toolsOwner?.owner.keepApprovalAlive?.();
6012
+ void requestPermission({ peer, title: shown.title, tool: shown.tool, classed: true, options: [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }, pending.signal).then(result => {
6013
+ const allowed = current() && result.outcome === "answered" && result.optionId === "allow";
6014
+ reply({ ok: true, behavior: allowed ? "allow" : "deny", ...(allowed ? {} : { message: result.outcome === "expired" ? "no person answered" : "permission denied" }) });
6015
+ }, () => reply({ ok: true, behavior: "deny", message: "hub permission unavailable" })).finally(() => { clearInterval(heartbeat); launch.signal.removeEventListener("abort", abortPending); }).catch(() => {});
6016
+ return;
6017
+ }
5552
6018
  case "permission_default": {
5553
6019
  if (typeof msg.peer === "string" && rosterChanging === msg.peer) return void reply({ ok: false, error: `${msg.peer} roster change is in progress` });
5554
6020
  if (c.role !== "console") return void reply({ ok: false, error: "permission default confirmation is a human console action" });
@@ -5624,6 +6090,18 @@ export async function startDaemon(opts: DaemonOptions) {
5624
6090
  return void reply({ ok: true });
5625
6091
  }
5626
6092
  case "inbox": {
6093
+ if (c.toolsOwner) {
6094
+ const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
6095
+ try {
6096
+ // Inspect the still-pending batch before pull commits/removes it: private work never reaches this native child.
6097
+ const pending = bus.pendingEnvelopes(peer);
6098
+ 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" });
6099
+ 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" });
6100
+ const envs = bus.pull(peer);
6101
+ if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
6102
+ return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
6103
+ } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
6104
+ }
5627
6105
  // The attached session can explicitly claim queued work even during a native turn (#493).
5628
6106
  // Pull and push share holds and journal settlement; work read here is completed, never pushed again.
5629
6107
  const peer = c.peer ? bus.peers.get(c.peer) : undefined;
@@ -5792,6 +6270,21 @@ export async function startDaemon(opts: DaemonOptions) {
5792
6270
  };
5793
6271
  try {
5794
6272
  const peer = c.peer;
6273
+ if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
6274
+ const launch = currentImplementerTools(c), root = ownerWriteRoot(peer);
6275
+ if (!launch || !root || root === opts.cwd || sessionId !== launch.sessionId || msg.nativeLaunchId !== launch.launchId || msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
6276
+ if (typeof msg.transcriptPath === "string" && isAbsolute(msg.transcriptPath) && basename(msg.transcriptPath) === `${launch.sessionId}.jsonl`) {
6277
+ try {
6278
+ const projects = realPath(join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects")), transcript = realPath(msg.transcriptPath);
6279
+ if (transcript.startsWith(`${projects}/`) && statSync(transcript).isFile() && launch.task !== undefined) implementerTranscripts.set(`${peer}:${launch.task}:${launch.sessionId}`, transcript);
6280
+ } catch { /* Exact path is not yet present or is unreadable: unknown preserves session identity. */ }
6281
+ }
6282
+ const mode = permissionMode(peer);
6283
+ if (mode !== "ask" && mode !== "ask-when-needed") return void reply({ t: "facts", ok: false });
6284
+ const blocked = scopedWriteBlocked(phase, mode, msg.tool, () => projectFileTool(msg.tool, msg.input, root, config.protect_instruction_files, config.data?.readonly));
6285
+ const actionClasses = phase === "pre" && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, root) : [];
6286
+ return void reply({ t: "facts", ok: true, ...(phase === "pre" ? { permission: mode === "ask-when-needed" && blocked ? "ask" : mode, projectRoot: root, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {}) });
6287
+ }
5795
6288
  let nativeClaude = false;
5796
6289
  if (definitionOf(peer)?.transport === "claude-channel" && sessionId && msg.nativeInstanceId === instanceId) {
5797
6290
  let expectedLaunch: string | undefined;
@@ -5800,8 +6293,8 @@ export async function startDaemon(opts: DaemonOptions) {
5800
6293
  expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === instanceId)?.launchId : undefined;
5801
6294
  } catch { /* an ordinary terminal has no Orca recovery identity */ }
5802
6295
  try {
5803
- const launch = JSON.parse(readFileSync(join(opts.stateDir, claudeStateFile("claude-launch", peer)), "utf8"));
5804
- if (launch.instanceId === instanceId && typeof launch.launchId === "string") expectedLaunch = launch.launchId;
6296
+ const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
6297
+ if (launch) expectedLaunch = launch.launchId;
5805
6298
  } catch { /* older/manual launches do not have a launcher marker */ }
5806
6299
  const current = claudeSession(peer);
5807
6300
  if ((phase === "stop" || phase === "post") && current.sessionId && current.sessionId !== sessionId) return void reply({ t: "facts", ok: false });
@@ -5818,10 +6311,14 @@ export async function startDaemon(opts: DaemonOptions) {
5818
6311
  if (definitionOf(peer)?.transport === "claude-channel" && msg.nativeInstanceId !== undefined && msg.nativeInstanceId !== instanceId) return void reply({ t: "facts", ok: false });
5819
6312
  const nativePeer = nativeClaude ? bus.peers.get(peer) : undefined;
5820
6313
  const permissionLaunch = definitionOf(peer)?.transport === "claude-channel" ? claudePermissionLaunch(peer) : undefined;
5821
- const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId ? permissionMode(peer) : undefined;
6314
+ const writeRoot = declarations[peer]?.worktree ? ownerWriteRoot(peer) : executionRoot(peer);
6315
+ if (declarations[peer]?.worktree && writeRoot === undefined) return void reply({ t: "facts", ok: false });
6316
+ const permission = nativeClaude && permissionLaunch && !permissionLaunch.unattended && msg.nativeLaunchId === permissionLaunch.launchId && writeRoot !== undefined ? permissionMode(peer) : undefined;
5822
6317
  if (permission !== undefined && isWsPeer(nativePeer)) claudePermissionGenerations.set(peer, nativePeer.sessionGeneration);
5823
- const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, opts.cwd, config.protect_instruction_files, config.data?.readonly));
5824
- const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: opts.cwd, protectInstructionFiles: config.protect_instruction_files } : {};
6318
+ const protectedWrite = scopedWriteBlocked(phase, permission, msg.tool, () => projectFileTool(msg.tool, msg.input, writeRoot ?? opts.cwd, config.protect_instruction_files, config.data?.readonly));
6319
+ // Always-ask (#636): a classed call is asked about in every permission mode. The hub names only the classes, under the same verified root as the file policy above; the hook turns them into `ask`.
6320
+ const actionClasses = phase === "pre" && permission !== undefined && typeof msg.tool === "string" ? claudeActionClasses(actionBudgets, msg.tool, msg.input, writeRoot ?? opts.cwd) : [];
6321
+ const permissionReply = phase === "pre" && permission !== undefined ? { permission: permission === "ask-when-needed" && protectedWrite ? "ask" : permission, projectRoot: writeRoot ?? opts.cwd, protectInstructionFiles: config.protect_instruction_files, ...(actionClasses.length ? { actionClasses } : {}) } : {};
5825
6322
  // Permission-only hooks carry no native-turn evidence and must never drive busy state.
5826
6323
  const purpose = permissionLaunch?.hookPurpose ?? msg.hookPurpose;
5827
6324
  const observesNative = purpose === "facts" || purpose === "idle" || purpose === "facts-and-idle" || (purpose === undefined && (config.coordination === "turn-free" || config.task_sweep.enabled || config.roles[peer]?.includes("conductor")));
@@ -5965,7 +6462,7 @@ export async function startDaemon(opts: DaemonOptions) {
5965
6462
  case "ui":
5966
6463
  if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
5967
6464
  try {
5968
- dashboard ??= startDashboard({ snapshot: uiSnapshot, action: uiAction });
6465
+ dashboard ??= startDashboard({ snapshot: uiMemorySnapshot, action: uiAction });
5969
6466
  writeStatus();
5970
6467
  // #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
5971
6468
  if (msg.settings === true) notify("settings session opened from ahub ui --settings: for 15 minutes that dashboard session may change permission modes, routing and start settings");
@@ -6054,7 +6551,8 @@ export async function startDaemon(opts: DaemonOptions) {
6054
6551
  case "ui_snapshot":
6055
6552
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
6056
6553
  if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
6057
- return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined));
6554
+ void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
6555
+ return;
6058
6556
  case "ui_action":
6059
6557
  if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
6060
6558
  if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
@@ -6069,7 +6567,7 @@ export async function startDaemon(opts: DaemonOptions) {
6069
6567
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
6070
6568
  case "settings_get":
6071
6569
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6072
- return void reply({ ok: true, ...settingsView(), roster: rosterView() });
6570
+ return void reply({ ok: true, ...settingsView(), roster: rosterView(), configEdit: (() => { const view = configEditor!.view(); return { revisions: view.revisions, known: view.known }; })() });
6073
6571
  case "settings_reload":
6074
6572
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person command" });
6075
6573
  return void reloadConfig("terminal", undefined, msg.confirm).then(reply, () => reply({ ok: false, error: "configuration reload could not be confirmed; inspect hub.log" }));
@@ -6083,6 +6581,16 @@ export async function startDaemon(opts: DaemonOptions) {
6083
6581
  case "settings_undo":
6084
6582
  if (c.role !== "console") return void reply({ ok: false, error: "settings are a person's: run ahub settings in a terminal" });
6085
6583
  return void settingsUndo("terminal", msg.confirm, msg.now, msg.reason, msg.key, msg.undoDigest).then(reply, () => reply({ ok: false, error: "the setting could not be undone; inspect hub.log" }));
6584
+ case "config_edit_get":
6585
+ if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
6586
+ return void reply({ ok: true, ...configEditor!.view() });
6587
+ case "config_edit_preview":
6588
+ if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
6589
+ return void reply(configEditor!.preview(msg.edit, "terminal"));
6590
+ case "config_edit_apply":
6591
+ if (c.role !== "console") return void reply({ ok: false, error: "config edits are a person's: run ahub settings edit in a terminal" });
6592
+ void configEditor!.apply(msg.edit, "terminal", msg.confirm, msg.reason).then(reply, () => reply({ ok: false, error: "the config edit could not be completed; inspect hub.log" }));
6593
+ return;
6086
6594
  case "kimi_launch_end": {
6087
6595
  if (c.role !== "console") return void reply({ ok: false, error: "Kimi launch cleanup is console only" });
6088
6596
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
@@ -6132,8 +6640,9 @@ export async function startDaemon(opts: DaemonOptions) {
6132
6640
  });
6133
6641
  return;
6134
6642
  case "task":
6643
+ if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
6135
6644
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
6136
- taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {}).then(
6645
+ (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 ?? {})).then(
6137
6646
  (text) => reply({ t: "task", ok: true, text }),
6138
6647
  (e: Error) => reply({ t: "task", ok: false, error: e.message }),
6139
6648
  );
@@ -6171,7 +6680,7 @@ export async function startDaemon(opts: DaemonOptions) {
6171
6680
  // Screened before the caps: withheld entries never crowd out readable evidence.
6172
6681
  const hits = screenMemory(memoryStore!.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER })).map((v) => [v, score(v)] as const).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([v]) => v);
6173
6682
  const near = screenMemory(memoryStore!.related(memoryProject, [...new Set(hits.flatMap((v) => v.about))], Number.MAX_SAFE_INTEGER)).slice(0, 6);
6174
- return [...hits, ...near].map((v) => ({ id: `mem:${v.id.slice(0, 8)}`, text: memoryLine(v).slice(13) }));
6683
+ return [...hits, ...near].map((v) => ({ id: `mem:${v.id}`, text: memoryLine(v).slice(13) }));
6175
6684
  } } : {}),
6176
6685
  project: chain.at(-1)!,
6177
6686
  logFile,
@@ -6191,7 +6700,7 @@ export async function startDaemon(opts: DaemonOptions) {
6191
6700
  project: chain.at(-1)!,
6192
6701
  metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
6193
6702
  });
6194
- saved = ok ? "saved to shared memory as a model-written answer" : "memory worker unavailable; nothing saved";
6703
+ saved = ok ? "saved as a candidate model-written answer; a person must review it" : "memory store unavailable; nothing saved";
6195
6704
  }
6196
6705
  }
6197
6706
  reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
@@ -6201,7 +6710,7 @@ export async function startDaemon(opts: DaemonOptions) {
6201
6710
  case "budget":
6202
6711
  if (c.role !== "console") {
6203
6712
  if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
6204
- return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), gate: config.budget.gate });
6713
+ return void reply({ t: "budget", ok: true, budget: publicPeerBudget(budget.status(), text => tasks.nameable(text)), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
6205
6714
  }
6206
6715
  if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
6207
6716
  if (msg.resume) {
@@ -6210,9 +6719,9 @@ export async function startDaemon(opts: DaemonOptions) {
6210
6719
  const used = Number(msg.set.used);
6211
6720
  if (!bus.peers.has(String(msg.set.peer)) && !budget.record(String(msg.set.peer))) return void reply({ t: "budget", ok: false, error: `unknown peer: ${msg.set.peer}` });
6212
6721
  if (!(used >= 0 && used <= 1)) return void reply({ t: "budget", ok: false, error: "used must be between 0 and 1" });
6213
- budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
6722
+ budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", kind: "manual", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
6214
6723
  }
6215
- return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
6724
+ return void reply({ t: "budget", ok: true, budget: budget.status(), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
6216
6725
  case "usage": {
6217
6726
  // #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
6218
6727
  const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
@@ -6317,6 +6826,7 @@ export async function startDaemon(opts: DaemonOptions) {
6317
6826
  executionBudget.close();
6318
6827
  syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
6319
6828
  await syncRun?.catch(() => undefined);
6829
+ memory.close();
6320
6830
  candidates?.close();
6321
6831
  memoryStore?.close();
6322
6832
  board.close();
@@ -6393,7 +6903,7 @@ export async function startDaemon(opts: DaemonOptions) {
6393
6903
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
6394
6904
  const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
6395
6905
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
6396
- return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6906
+ return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6397
6907
  } finally {
6398
6908
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
6399
6909
  }