@staix/agent-hub 0.15.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 (80) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +6 -3
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +13 -1
  9. package/docs/agent-notes/tests.md +3 -0
  10. package/docs/dogfood.md +13 -4
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +116 -30
  14. package/docs/quickstart.md +8 -4
  15. package/docs/security.md +80 -18
  16. package/docs/smoke.md +7 -3
  17. package/docs/specs/2026-09-19-agent-hub-design.md +84 -8
  18. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  19. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  20. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  21. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  22. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  23. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  24. package/docs/verified.json +26 -17
  25. package/package.json +2 -2
  26. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  27. package/plugins/agent-hub/server.js +2085 -1702
  28. package/src/adapters/acp.ts +5 -2
  29. package/src/adapters/codex-appserver.ts +69 -1
  30. package/src/adapters/compat.ts +2 -2
  31. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  32. package/src/adapters/local-worker.ts +5 -4
  33. package/src/adapters/registry-types.ts +1 -1
  34. package/src/cli/console-state.ts +20 -2
  35. package/src/cli/console-symbols.ts +3 -2
  36. package/src/cli/console-usage.ts +5 -2
  37. package/src/cli/console.ts +5 -1
  38. package/src/cli/help.ts +7 -5
  39. package/src/cli/identity.ts +3 -1
  40. package/src/cli/main.ts +44 -10
  41. package/src/cli/memory.ts +119 -7
  42. package/src/cli/output.ts +12 -4
  43. package/src/hub/action-class.ts +211 -17
  44. package/src/hub/action-enforce.ts +13 -5
  45. package/src/hub/action-policy.ts +19 -8
  46. package/src/hub/ask.ts +16 -4
  47. package/src/hub/board.ts +86 -31
  48. package/src/hub/budget-notes.ts +65 -0
  49. package/src/hub/budget.ts +42 -10
  50. package/src/hub/bus.ts +1 -1
  51. package/src/hub/code-facts.ts +56 -16
  52. package/src/hub/conductor.ts +5 -2
  53. package/src/hub/config-apply.ts +7 -0
  54. package/src/hub/control-client.ts +1 -1
  55. package/src/hub/daemon.ts +250 -49
  56. package/src/hub/envelope.ts +1 -1
  57. package/src/hub/events.ts +2 -2
  58. package/src/hub/hub-tools.ts +3 -3
  59. package/src/hub/kimi-usage.ts +12 -0
  60. package/src/hub/lifecycle.ts +10 -5
  61. package/src/hub/memory-code-head.ts +44 -0
  62. package/src/hub/memory-code-overlay.ts +119 -0
  63. package/src/hub/memory-plan-guard.ts +10 -1
  64. package/src/hub/memory-stale-anchors.ts +159 -0
  65. package/src/hub/memory-symbols.ts +74 -0
  66. package/src/hub/planning.ts +10 -4
  67. package/src/hub/read-tools.ts +21 -3
  68. package/src/hub/sdlc.ts +34 -0
  69. package/src/hub/settings.ts +17 -2
  70. package/src/hub/settled-roles.ts +15 -3
  71. package/src/hub/task-sweep.ts +25 -3
  72. package/src/hub/tasks.ts +353 -64
  73. package/src/hub/token-window.ts +15 -2
  74. package/src/hub/window-kind.ts +13 -0
  75. package/src/local/tools.ts +5 -5
  76. package/src/memory/brief.ts +22 -12
  77. package/src/memory/capture.ts +26 -40
  78. package/src/memory/client.ts +137 -65
  79. package/src/memory/recall.ts +13 -22
  80. package/src/ui/index.html +100 -18
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,6 @@
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";
1
4
  import { AsyncLocalStorage } from "node:async_hooks";
2
5
  import { normalizeUsage } from "../omniroute/usage.ts";
3
6
  import { permissionPayload } from "./permission-payload.ts";
@@ -21,7 +24,7 @@ import { renderHeldHint } from "./delivery-hints.ts";
21
24
  import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
22
25
  import { MemoryObservations } from "./memory-observations.ts";
23
26
  import { createStalenessProbe } from "./memory-staleness.ts";
24
- import { matchingPlanFails, planGuardNodes } from "./memory-plan-guard.ts";
27
+ import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
25
28
  import { planningWipLimits } from "./planning.ts";
26
29
  import { projectedDiff } from "./projected-diff.ts";
27
30
  import { listenWithoutPortCollision } from "./listen.ts";
@@ -67,11 +70,13 @@ import { createConfigEditor } from "./config-edit-host.ts";
67
70
  import { readEditable } from "./config-edit.ts";
68
71
  import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
69
72
  import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
73
+ import { TokenWindow } from "./token-window.ts";
74
+ import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
70
75
  import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
71
76
  import { ExecutionBudget } from "./execution-budget.ts";
72
77
  import { claudeReportedTokens, readClaudeTranscriptCompletion, readClaudeTranscriptUsage, waitForClaudeCompletion } from "./usage.ts";
73
78
  import type { ServerWebSocket } from "bun";
74
- 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";
75
80
  import { isCodexPeer, type CodexPeer } from "../adapters/codex-appserver.ts";
76
81
  import { isPiPeer, type PiPeer } from "../adapters/pi.ts";
77
82
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
@@ -88,7 +93,7 @@ import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableErro
88
93
  import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
89
94
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
90
95
  import { reviewDiff } from "../adapters/claude-print.ts";
91
- import { Capture, skipTools } from "../memory/capture.ts";
96
+ import { Capture } from "../memory/capture.ts";
92
97
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
93
98
  import { Briefs } from "../memory/brief.ts";
94
99
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -120,7 +125,7 @@ import { codeGroup } from "./task-code-group.ts";
120
125
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
121
126
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
122
127
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
123
- import { MemoryClient, workerUrl } from "../memory/client.ts";
128
+ import { MemoryClient } from "../memory/client.ts";
124
129
  import { VERSION } from "../version.ts";
125
130
  import { appendRecords, taskRecords } from "./research.ts";
126
131
  import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
@@ -144,9 +149,9 @@ import { grantablePath, instructionProtection, isPermissionMode, permissionBound
144
149
  import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
145
150
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
146
151
  import { detectTooling } from "./tooling.ts";
147
- 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";
148
153
  import { memoryProjectKey } from "@staix/agent-mem";
149
- 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";
150
155
  import { syncMemory } from "@staix/agent-mem";
151
156
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
152
157
  import { nativeRecall } from "../memory/recall.ts";
@@ -177,7 +182,7 @@ export interface HubConfig {
177
182
  roster: PeerRoster;
178
183
  roles: Record<string, string[]>;
179
184
  /** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
180
- planner?: string;
185
+ planner?: string | string[];
181
186
  profile?: ProfileName;
182
187
  profile_overrides?: Record<string, unknown>;
183
188
  pii?: { paths: string[] };
@@ -286,11 +291,11 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
286
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"];
287
292
 
288
293
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
289
- 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[] } {
290
295
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
291
296
  const conductor = { ...DEFAULT_CONFIG.conductor };
292
297
  const notices: string[] = [];
293
- let planner: string | undefined;
298
+ let planner: string | string[] | undefined;
294
299
  for (const name of CONFIG_FILES) {
295
300
  let file: Record<string, unknown>;
296
301
  if (layers && !layers.some(layer => layer.name === name)) continue;
@@ -308,8 +313,11 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
308
313
  }
309
314
  }
310
315
  if (file.planner !== undefined) {
311
- if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
312
- 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;
313
321
  }
314
322
  if (file.conductor !== undefined) {
315
323
  if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
@@ -418,7 +426,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
418
426
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
419
427
  }
420
428
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
421
- const conductorPolicy = loadConductorPolicy(cwd, captured);
429
+ const conductorPolicy = loadConductorPolicy(cwd, captured, instanceConfig);
422
430
  retired.push(...conductorPolicy.notices ?? []);
423
431
  const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
424
432
  const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
@@ -501,6 +509,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
501
509
  }
502
510
 
503
511
  export interface DaemonOptions {
512
+ /** Host-only test/embedding port; never read from project configuration or a tool. */
513
+ memoryClient?: MemoryClient;
504
514
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
505
515
  snapshot?: typeof snapshot;
506
516
  snapshotBurstMs?: number;
@@ -735,8 +745,9 @@ export async function startDaemon(opts: DaemonOptions) {
735
745
  let policyRoles = structuredClone(config.roles);
736
746
  let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
737
747
  let plannerLogged = "";
748
+ const plannerEligible = new Set(projectSettings(declarations, adapterCatalog).find(def => def.key === "planner")?.values ?? []);
738
749
  const settlePlanner = () => {
739
- const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
750
+ const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster, peer => plannerEligible.has(peer));
740
751
  for (const key of Object.keys(config.roles)) delete config.roles[key];
741
752
  Object.assign(config.roles, roles);
742
753
  plannerSource = source;
@@ -744,7 +755,6 @@ export async function startDaemon(opts: DaemonOptions) {
744
755
  if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
745
756
  };
746
757
  const plannerPeers = () => Object.keys(config.roles).filter(peer => config.roles[peer]!.includes("planner"));
747
- 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`);
748
758
  settlePlanner();
749
759
  const catalog = loadRoleCatalog(opts.cwd);
750
760
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
@@ -1053,7 +1063,6 @@ export async function startDaemon(opts: DaemonOptions) {
1053
1063
  }, delayMs);
1054
1064
  recoveryLeaseTimer.unref?.();
1055
1065
  };
1056
- const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
1057
1066
  const chain = projectChain(opts.cwd);
1058
1067
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
1059
1068
  // #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
@@ -1097,18 +1106,96 @@ export async function startDaemon(opts: DaemonOptions) {
1097
1106
  * Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
1098
1107
  * policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
1099
1108
  */
1100
- 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 ?? []);
1101
1110
  let candidates: CandidateStore | undefined;
1102
1111
  if (memoryStore) {
1103
1112
  try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
1104
1113
  catch { log("memory candidate sidecar unavailable"); }
1105
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
+ });
1106
1127
  const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
1107
1128
  const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
1108
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)}`;
1109
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. */
1110
1131
  const taskNodes = (t: { id?: number; created?: number; title?: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }) =>
1111
- 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; }
1112
1199
 
1113
1200
  const leaseNoticed = new Set<string>();
1114
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(", ")}` : ""})` : ""}`;
@@ -1180,7 +1267,7 @@ export async function startDaemon(opts: DaemonOptions) {
1180
1267
  /** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
1181
1268
  async function ensurePreface(peer: PeerId): Promise<void> {
1182
1269
  if ((!config.memory.enabled && !memoryStore) || recalled.has(peer) || reviewOnlyPeer(peer)) return; // #223 R2: no shared memory in a fresh review
1183
- // 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.
1184
1271
  let own: string | undefined;
1185
1272
  try {
1186
1273
  checkMemory();
@@ -1247,6 +1334,7 @@ export async function startDaemon(opts: DaemonOptions) {
1247
1334
  const board = new Board(join(opts.stateDir, "hub.db"));
1248
1335
  const codeFacts = new CodeFacts();
1249
1336
  const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1337
+ const memorySymbols = new Map<string, MemorySymbolNodes>();
1250
1338
  startupCleanup.push(() => board.close());
1251
1339
  startupCleanup.push(() => codeFacts.close());
1252
1340
  const supervision = new SupervisionFeed({
@@ -1460,8 +1548,23 @@ export async function startDaemon(opts: DaemonOptions) {
1460
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 });
1461
1549
  if ("unavailable" in brief) return;
1462
1550
  const key = JSON.stringify([root, brief.head, brief.mergeBase]);
1463
- reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
1464
- 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]));
1465
1568
  },
1466
1569
  reviewBrief: task => {
1467
1570
  const root = executionRoot(task.owner ?? "");
@@ -1479,8 +1582,8 @@ export async function startDaemon(opts: DaemonOptions) {
1479
1582
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1480
1583
  memoryCheck: checkMemory,
1481
1584
  ...(config.memory.enabled || memoryStore ? { briefs: new Briefs(config.memory.enabled ? memory : undefined, chain.at(-1)!, () => config.memory.brief_items,
1482
- 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,
1483
- (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 }),
1484
1587
  items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
1485
1588
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
1486
1589
  checkMemory();
@@ -1915,11 +2018,18 @@ export async function startDaemon(opts: DaemonOptions) {
1915
2018
  cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
1916
2019
  ...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
1917
2020
  },
1918
- report: (peer, window) => budget.report(peer, [{ id: "tokens", used: window.used, resetsAt: window.resetsAt, source: `hub count, kimi wire log, of kimi_tokens_5h ${window.ceiling}` }]),
1919
- forget: peer => budget.forget(peer, "tokens"),
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"),
1920
2023
  notice: line => notify(line),
1921
2024
  });
1922
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
+ };
1923
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. */
1924
2034
  const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
1925
2035
  const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
@@ -2145,7 +2255,16 @@ export async function startDaemon(opts: DaemonOptions) {
2145
2255
  }
2146
2256
  /** Tokens a peer used, as increments. */
2147
2257
  let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
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);
2148
2266
  const addTokens = (peer: PeerId, n: number, task?: number): number => {
2267
+ if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
2149
2268
  const supervisionTurn = supervisionTurns.get(peer);
2150
2269
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
2151
2270
  if (n > 0) {
@@ -2356,10 +2475,10 @@ export async function startDaemon(opts: DaemonOptions) {
2356
2475
  return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
2357
2476
  }
2358
2477
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
2359
- // 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.
2360
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`);
2361
2480
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
2362
- // 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.
2363
2482
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
2364
2483
  const target = board.get(Number(a.id));
2365
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`);
@@ -2381,9 +2500,17 @@ export async function startDaemon(opts: DaemonOptions) {
2381
2500
  if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
2382
2501
  }
2383
2502
  if (MEMORY_READ_TOOLS.has(op)) {
2503
+ validateMemoryRead(op, a);
2384
2504
  need("memory_read", "read shared memory");
2505
+ if (!boundedSafe(JSON.stringify(a))) throw new PreEffectToolRefusal("memory read unavailable");
2385
2506
  checkMemory();
2386
- return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness());
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");
2512
+ checkMemory();
2513
+ return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness(), code ? currentMemoryCode(code) : undefined);
2387
2514
  }
2388
2515
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
2389
2516
  if (op === "hub_remember") need("remember", "save notes to shared memory");
@@ -2568,7 +2695,7 @@ export async function startDaemon(opts: DaemonOptions) {
2568
2695
  chmodSync(`${file}.tmp`, 0o600);
2569
2696
  renameSync(`${file}.tmp`, file);
2570
2697
  pending.done(summary);
2571
- 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);
2572
2699
  notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
2573
2700
  return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
2574
2701
  }
@@ -2630,13 +2757,21 @@ export async function startDaemon(opts: DaemonOptions) {
2630
2757
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2631
2758
  const codeSymbols = tasks.publicView(task).codeSymbols;
2632
2759
  const consoleDeliverables = await tasks.deliverableStatus(task);
2633
- return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
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 }));
2634
2769
  }
2635
2770
  case "task_assign":
2636
2771
  case "task_assign_checked": {
2637
2772
  const peer = peerArg(a.peer, "peer");
2638
2773
  if (!peer) throw new Error("peer is required");
2639
- 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");
2640
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");
2641
2776
  return line(await tasks.assignTo(a.id, peer, USER, true, a.role, op === "task_assign_checked" ? a.revision : undefined));
2642
2777
  }
@@ -2714,7 +2849,13 @@ export async function startDaemon(opts: DaemonOptions) {
2714
2849
  else delete defaultSources[peer];
2715
2850
  }
2716
2851
  };
2717
- 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);
2718
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.
2719
2860
  const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
2720
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),
@@ -2733,11 +2874,18 @@ export async function startDaemon(opts: DaemonOptions) {
2733
2874
  return { enforced: true };
2734
2875
  }
2735
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" };
2736
2884
  if (isAcpPeer(owner) && owner.actionGated) {
2737
2885
  const mode = owner.permissionModeState;
2738
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` };
2739
2887
  }
2740
- return { enforced: false };
2888
+ return { enforced: false, reason: "this adapter has no hub pre-tool approval path" };
2741
2889
  };
2742
2890
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2743
2891
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
@@ -3018,6 +3166,7 @@ export async function startDaemon(opts: DaemonOptions) {
3018
3166
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
3019
3167
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
3020
3168
  memoryProfile: memoryProfileStatus(includeAttentionItems),
3169
+ memoryRead: memory.readStatus(),
3021
3170
  lifecycleStages: [...lifecycleConfig.stages],
3022
3171
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
3023
3172
  ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
@@ -3589,6 +3738,28 @@ export async function startDaemon(opts: DaemonOptions) {
3589
3738
  const result = await requestPermission(req, signal);
3590
3739
  return result.outcome === "answered" ? result.optionId : undefined;
3591
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
+ }
3592
3763
  async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
3593
3764
  const requestEpoch = permissionAnswerEpoch(req.peer);
3594
3765
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
@@ -4080,9 +4251,11 @@ export async function startDaemon(opts: DaemonOptions) {
4080
4251
  if (adapter?.start.daemonPort === "codex-appserver") {
4081
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" };
4082
4253
  let codexSteering = false;
4254
+ actionBudgets.revoke(peer);
4083
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"]({
4084
4256
  admitAttach: () => membershipAllowed(peer),
4085
4257
  permissionMode: permissionMode(peer),
4258
+ actionGate: acpActionGate(peer), onPermission,
4086
4259
  onTokens: (added) => void addTokens(peer, added),
4087
4260
  // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
4088
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).
@@ -4181,7 +4354,11 @@ export async function startDaemon(opts: DaemonOptions) {
4181
4354
  let piReply: Envelope | undefined;
4182
4355
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
4183
4356
  const piAlways = new Set<string>();
4184
- 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;
4185
4362
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4186
4363
  const selected = permissionMode(peer);
4187
4364
  if (selected === "never-ask" || (selected === "ask-when-needed" && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
@@ -4231,8 +4408,8 @@ export async function startDaemon(opts: DaemonOptions) {
4231
4408
  const nativeTurn = pi.observationTurn;
4232
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,
4233
4410
  ...(humanBash ? { onProcessExit: (code: number | null) => { exitCode = code; } } : {}),
4234
- permit: async (title, tool, toolSignal, path, _observe, originalPath) => {
4235
- 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);
4236
4413
  return picked; // never turn a nonempty expiry/abort string into authorization
4237
4414
  },
4238
4415
  });
@@ -4342,9 +4519,13 @@ export async function startDaemon(opts: DaemonOptions) {
4342
4519
  if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
4343
4520
  if (existing) { mute(existing); await existing.stop(); }
4344
4521
  const capture = config.memory.enabled
4345
- ? 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 })
4346
4523
  : undefined;
4347
- 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;
4348
4529
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4349
4530
  const selected = permissionMode(peer);
4350
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))) {
@@ -4567,7 +4748,7 @@ export async function startDaemon(opts: DaemonOptions) {
4567
4748
  const summary = bus.queueSummary(peer);
4568
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,
4569
4750
  manualHeld: manualPaused.has(peer), hold: conductorHolds.get(peer), budgetPause: quota[accountOf(peer)]?.paused ?? null,
4570
- ...(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 } : {}) })) } : {}) };
4571
4752
  }),
4572
4753
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
4573
4754
  };
@@ -4664,9 +4845,25 @@ export async function startDaemon(opts: DaemonOptions) {
4664
4845
  entries: rows.map((v) => shape(v)), ...(node ? { node, graph: memoryStore.graph(memoryProject, node, hops, 200), ...(selected ? { detail: shape(selected, true) } : {}) } : {}) } };
4665
4846
  } catch (e) { log(`hub memory view failed: ${(e as Error).message}`); return {}; } // an unreadable store never breaks the dashboard
4666
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
+ }
4667
4863
  function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
4668
4864
  const quota = budget.status();
4669
4865
  const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
4866
+ const budgetNoteOf = new Map(budgetPeerNotes().map(note => [note.peer, note] as const));
4670
4867
  // #264 (phase 3): turn, checking and links are additive optional fields that older pages ignore,
4671
4868
  // so PROTOCOL and EVENTS_SCHEMA stay unchanged. They carry ids and enums only.
4672
4869
  const turnView = (peer: PeerId) => {
@@ -4744,10 +4941,10 @@ export async function startDaemon(opts: DaemonOptions) {
4744
4941
  tasks: boardTasks.map((task) => {
4745
4942
  const view = tasks.publicView(task);
4746
4943
  // Refs and history can themselves quote private text. The dashboard needs neither.
4747
- 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 } : {}; })() };
4748
4945
  }),
4749
4946
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
4750
- 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))) }])),
4751
4948
  permissions: [...permissions.values()].map(({ push }) => {
4752
4949
  const req = JSON.parse(push);
4753
4950
  // Local tool titles quote file contents and commands, including those of PII turns.
@@ -4804,7 +5001,7 @@ export async function startDaemon(opts: DaemonOptions) {
4804
5001
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4805
5002
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4806
5003
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4807
- 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));
4808
5005
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4809
5006
  }
4810
5007
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -5061,7 +5258,7 @@ export async function startDaemon(opts: DaemonOptions) {
5061
5258
  },
5062
5259
  });
5063
5260
  const undo = pendingUndo(opts.stateDir);
5064
- const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
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.`;
5065
5262
  return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
5066
5263
  }
5067
5264
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
@@ -5477,6 +5674,8 @@ export async function startDaemon(opts: DaemonOptions) {
5477
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";
5478
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";
5479
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";
5480
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";
5481
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";
5482
5681
  return undefined;
@@ -6263,7 +6462,7 @@ export async function startDaemon(opts: DaemonOptions) {
6263
6462
  case "ui":
6264
6463
  if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
6265
6464
  try {
6266
- dashboard ??= startDashboard({ snapshot: uiSnapshot, action: uiAction });
6465
+ dashboard ??= startDashboard({ snapshot: uiMemorySnapshot, action: uiAction });
6267
6466
  writeStatus();
6268
6467
  // #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
6269
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");
@@ -6352,7 +6551,8 @@ export async function startDaemon(opts: DaemonOptions) {
6352
6551
  case "ui_snapshot":
6353
6552
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
6354
6553
  if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
6355
- 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;
6356
6556
  case "ui_action":
6357
6557
  if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
6358
6558
  if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
@@ -6480,7 +6680,7 @@ export async function startDaemon(opts: DaemonOptions) {
6480
6680
  // Screened before the caps: withheld entries never crowd out readable evidence.
6481
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);
6482
6682
  const near = screenMemory(memoryStore!.related(memoryProject, [...new Set(hits.flatMap((v) => v.about))], Number.MAX_SAFE_INTEGER)).slice(0, 6);
6483
- 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) }));
6484
6684
  } } : {}),
6485
6685
  project: chain.at(-1)!,
6486
6686
  logFile,
@@ -6500,7 +6700,7 @@ export async function startDaemon(opts: DaemonOptions) {
6500
6700
  project: chain.at(-1)!,
6501
6701
  metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
6502
6702
  });
6503
- 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";
6504
6704
  }
6505
6705
  }
6506
6706
  reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
@@ -6510,7 +6710,7 @@ export async function startDaemon(opts: DaemonOptions) {
6510
6710
  case "budget":
6511
6711
  if (c.role !== "console") {
6512
6712
  if (msg.resume !== undefined || msg.set !== undefined) return void reply({ t: "budget", ok: false, error: "budget mutations are console commands" });
6513
- 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 });
6514
6714
  }
6515
6715
  if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
6516
6716
  if (msg.resume) {
@@ -6519,9 +6719,9 @@ export async function startDaemon(opts: DaemonOptions) {
6519
6719
  const used = Number(msg.set.used);
6520
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}` });
6521
6721
  if (!(used >= 0 && used <= 1)) return void reply({ t: "budget", ok: false, error: "used must be between 0 and 1" });
6522
- 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" }]);
6523
6723
  }
6524
- 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 });
6525
6725
  case "usage": {
6526
6726
  // #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
6527
6727
  const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
@@ -6626,6 +6826,7 @@ export async function startDaemon(opts: DaemonOptions) {
6626
6826
  executionBudget.close();
6627
6827
  syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
6628
6828
  await syncRun?.catch(() => undefined);
6829
+ memory.close();
6629
6830
  candidates?.close();
6630
6831
  memoryStore?.close();
6631
6832
  board.close();