@staix/agent-hub 0.15.0 → 0.17.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 (104) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +12 -8
  3. package/docs/agent-notes/README.md +4 -0
  4. package/docs/agent-notes/adapters.md +24 -8
  5. package/docs/agent-notes/budget.md +4 -2
  6. package/docs/agent-notes/daemon.md +11 -4
  7. package/docs/agent-notes/local-worker.md +2 -2
  8. package/docs/agent-notes/tasks.md +15 -3
  9. package/docs/agent-notes/tests.md +8 -3
  10. package/docs/dogfood.md +18 -6
  11. package/docs/events.md +1 -1
  12. package/docs/memory.md +78 -10
  13. package/docs/operations.md +160 -47
  14. package/docs/quickstart.md +13 -5
  15. package/docs/roles.md +1 -0
  16. package/docs/sdlc.md +11 -11
  17. package/docs/security.md +94 -22
  18. package/docs/smoke.md +58 -6
  19. package/docs/specs/2026-09-19-agent-hub-design.md +124 -12
  20. package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
  21. package/docs/verification/2026-10-24-always-ask-adapters.md +52 -0
  22. package/docs/verification/2026-10-24-amem-runtime.md +69 -0
  23. package/docs/verification/2026-10-24-codex-0164-reviewer-isolation.md +55 -0
  24. package/docs/verification/2026-10-24-delivery-holds-ac8.md +264 -0
  25. package/docs/verification/2026-10-24-hardcoded-values.md +2108 -0
  26. package/docs/verification/2026-10-25-0.16.0.md +82 -0
  27. package/docs/verification/2026-10-25-0.17.0.md +74 -0
  28. package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
  29. package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
  30. package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
  31. package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
  32. package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
  33. package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
  34. package/docs/verified.json +30 -18
  35. package/package.json +2 -2
  36. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  37. package/plugins/agent-hub/server.js +3421 -2176
  38. package/src/adapters/acp.ts +5 -2
  39. package/src/adapters/codex-appserver.ts +69 -1
  40. package/src/adapters/codex-review-isolation.ts +10 -1
  41. package/src/adapters/compat.ts +9 -4
  42. package/src/adapters/conformance/enrollment.ts +18 -4
  43. package/src/adapters/conformance/factories.ts +4 -1
  44. package/src/adapters/conformance/kit.ts +11 -0
  45. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  46. package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
  47. package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
  48. package/src/adapters/definitions/native-factories.ts +8 -0
  49. package/src/adapters/definitions/native-recovery.ts +8 -0
  50. package/src/adapters/definitions/qwen.ts +7 -4
  51. package/src/adapters/local-worker.ts +5 -4
  52. package/src/adapters/qwen-dual.ts +578 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +9 -1
  55. package/src/adapters/reviewer-mcp.ts +12 -6
  56. package/src/adapters/reviewer-print.ts +100 -17
  57. package/src/cli/console-state.ts +20 -2
  58. package/src/cli/console-symbols.ts +3 -2
  59. package/src/cli/console-usage.ts +5 -2
  60. package/src/cli/console.ts +5 -1
  61. package/src/cli/help.ts +7 -5
  62. package/src/cli/identity.ts +3 -1
  63. package/src/cli/main.ts +44 -10
  64. package/src/cli/memory.ts +119 -7
  65. package/src/cli/output.ts +12 -4
  66. package/src/cli/upgrade.ts +4 -3
  67. package/src/hub/action-class.ts +211 -17
  68. package/src/hub/action-enforce.ts +13 -5
  69. package/src/hub/action-policy.ts +19 -8
  70. package/src/hub/ask.ts +16 -4
  71. package/src/hub/board.ts +86 -31
  72. package/src/hub/budget-notes.ts +65 -0
  73. package/src/hub/budget.ts +42 -10
  74. package/src/hub/bus.ts +1 -1
  75. package/src/hub/code-facts.ts +56 -16
  76. package/src/hub/conductor.ts +5 -2
  77. package/src/hub/config-apply.ts +7 -0
  78. package/src/hub/control-client.ts +1 -1
  79. package/src/hub/daemon.ts +384 -71
  80. package/src/hub/envelope.ts +1 -1
  81. package/src/hub/events.ts +2 -2
  82. package/src/hub/hub-tools.ts +3 -3
  83. package/src/hub/kimi-usage.ts +12 -0
  84. package/src/hub/lifecycle.ts +10 -5
  85. package/src/hub/memory-code-head.ts +44 -0
  86. package/src/hub/memory-code-overlay.ts +119 -0
  87. package/src/hub/memory-plan-guard.ts +11 -27
  88. package/src/hub/memory-stale-anchors.ts +159 -0
  89. package/src/hub/memory-symbols.ts +74 -0
  90. package/src/hub/planning.ts +10 -4
  91. package/src/hub/read-tools.ts +21 -3
  92. package/src/hub/sdlc.ts +34 -0
  93. package/src/hub/settings.ts +17 -2
  94. package/src/hub/settled-roles.ts +15 -3
  95. package/src/hub/task-sweep.ts +25 -3
  96. package/src/hub/tasks.ts +378 -76
  97. package/src/hub/token-window.ts +15 -2
  98. package/src/hub/window-kind.ts +13 -0
  99. package/src/local/tools.ts +5 -5
  100. package/src/memory/brief.ts +66 -16
  101. package/src/memory/capture.ts +26 -40
  102. package/src/memory/client.ts +137 -65
  103. package/src/memory/recall.ts +13 -22
  104. 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";
@@ -43,7 +46,7 @@ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } fro
43
46
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
44
47
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
45
48
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
46
- import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
49
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog, isFixedBuiltinDefinition } from "../adapters/registry.ts";
47
50
  import { isSupportedPeer as registrySupportedPeer, definitionOf as builtinDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
48
51
  import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
49
52
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
@@ -67,13 +70,16 @@ 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";
82
+ import { isQwenDualPeer, qwenDualVersionAsync, type QwenDualPeer } from "../adapters/qwen-dual.ts";
77
83
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
78
84
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
79
85
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
@@ -84,11 +90,11 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
84
90
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
85
91
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
86
92
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
87
- import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
88
- import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
93
+ import { isReviewerPrintPeer, reviewerNoVerdictLine, reviewerUnavailableReason, ReviewerUnavailableError, REVIEWER_FALLBACK_UNCONFIRMED, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
94
+ import { createReviewerMcpSession, type ReviewerToolName } from "../adapters/reviewer-mcp.ts";
89
95
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
90
96
  import { reviewDiff } from "../adapters/claude-print.ts";
91
- import { Capture, skipTools } from "../memory/capture.ts";
97
+ import { Capture } from "../memory/capture.ts";
92
98
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
93
99
  import { Briefs } from "../memory/brief.ts";
94
100
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
@@ -120,7 +126,7 @@ import { codeGroup } from "./task-code-group.ts";
120
126
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
121
127
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
122
128
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
123
- import { MemoryClient, workerUrl } from "../memory/client.ts";
129
+ import { MemoryClient } from "../memory/client.ts";
124
130
  import { VERSION } from "../version.ts";
125
131
  import { appendRecords, taskRecords } from "./research.ts";
126
132
  import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
@@ -144,10 +150,10 @@ import { grantablePath, instructionProtection, isPermissionMode, permissionBound
144
150
  import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
145
151
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
146
152
  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";
153
+ 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
154
  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";
150
- import { syncMemory } from "@staix/agent-mem";
155
+ import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
156
+ import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
151
157
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
152
158
  import { nativeRecall } from "../memory/recall.ts";
153
159
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
@@ -177,7 +183,7 @@ export interface HubConfig {
177
183
  roster: PeerRoster;
178
184
  roles: Record<string, string[]>;
179
185
  /** #455: the one peer that holds the planner role; unset, the `roles` planners stand, or the hub assigns one. */
180
- planner?: string;
186
+ planner?: string | string[];
181
187
  profile?: ProfileName;
182
188
  profile_overrides?: Record<string, unknown>;
183
189
  pii?: { paths: string[] };
@@ -286,11 +292,11 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
286
292
  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
293
 
288
294
  /** 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[] } {
295
+ 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
296
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
291
297
  const conductor = { ...DEFAULT_CONFIG.conductor };
292
298
  const notices: string[] = [];
293
- let planner: string | undefined;
299
+ let planner: string | string[] | undefined;
294
300
  for (const name of CONFIG_FILES) {
295
301
  let file: Record<string, unknown>;
296
302
  if (layers && !layers.some(layer => layer.name === name)) continue;
@@ -308,8 +314,11 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
308
314
  }
309
315
  }
310
316
  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;
317
+ if (file.planner === null) throw new Error("planner takes a peer id or a nonempty list of up to eight ids");
318
+ const def = instanceSettingDef("planner", scope.instances, scope.catalog);
319
+ if (typeof def === "string") throw new Error(def);
320
+ const normalized = checkSettingValue(def, file.planner) as string | string[];
321
+ planner = Array.isArray(file.planner) ? [...file.planner] as string[] : normalized;
313
322
  }
314
323
  if (file.conductor !== undefined) {
315
324
  if (!file.conductor || typeof file.conductor !== "object" || Array.isArray(file.conductor)) throw new Error("conductor must be an object");
@@ -418,7 +427,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
418
427
  throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
419
428
  }
420
429
  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);
430
+ const conductorPolicy = loadConductorPolicy(cwd, captured, instanceConfig);
422
431
  retired.push(...conductorPolicy.notices ?? []);
423
432
  const configuredRoles = Object.fromEntries(adapterCatalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
424
433
  const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles, adapterCatalog)]));
@@ -501,6 +510,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
501
510
  }
502
511
 
503
512
  export interface DaemonOptions {
513
+ /** Host-only test/embedding port; never read from project configuration or a tool. */
514
+ memoryClient?: MemoryClient;
504
515
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
505
516
  snapshot?: typeof snapshot;
506
517
  snapshotBurstMs?: number;
@@ -583,6 +594,9 @@ export function resolutionRefusal(error: unknown): string {
583
594
  return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
584
595
  }
585
596
 
597
+ /** No remote set and a stop are not failures: agent-mem words the missing remote "no sync remote" (v1) or "no generation sync remote configured". */
598
+ export const quietSyncError = (error: string | undefined): boolean => /^(no (generation )?sync remote|stopped)/.test(error ?? "");
599
+
586
600
  export async function startDaemon(opts: DaemonOptions) {
587
601
  const projectId = opts.projectId ?? randomUUID();
588
602
  const instanceId = opts.instanceId ?? randomUUID();
@@ -620,6 +634,7 @@ export async function startDaemon(opts: DaemonOptions) {
620
634
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
621
635
  const verifiedTerminalRootsThisTurn = new Set<object>();
622
636
  const reviewerUnavailable = new Map<string, string>();
637
+ const reviewerToolObservers = new Map<string, (task: number, tool: ReviewerToolName, refusal?: string) => void>();
623
638
  const roles = { ...Object.fromEntries(adapterCatalog.definitions.filter(d => configuredIds.includes(d.kind)).map(d => [d.kind, [...d.roles]])), ...loadedConfig.roles };
624
639
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
625
640
  const startModesFromInstances: PeerStartConfig = {};
@@ -735,8 +750,9 @@ export async function startDaemon(opts: DaemonOptions) {
735
750
  let policyRoles = structuredClone(config.roles);
736
751
  let plannerSource: "setting" | "roles" | "automatic" | "none" = "none";
737
752
  let plannerLogged = "";
753
+ const plannerEligible = new Set(projectSettings(declarations, adapterCatalog).find(def => def.key === "planner")?.values ?? []);
738
754
  const settlePlanner = () => {
739
- const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster);
755
+ const { roles, source } = resolveSettledRoles(policyRoles, config.planner, onRoster, peer => plannerEligible.has(peer));
740
756
  for (const key of Object.keys(config.roles)) delete config.roles[key];
741
757
  Object.assign(config.roles, roles);
742
758
  plannerSource = source;
@@ -744,7 +760,6 @@ export async function startDaemon(opts: DaemonOptions) {
744
760
  if (line !== plannerLogged) { log(`planner: ${line}${source === "automatic" ? "; pin it with ahub settings set planner <peer>" : ""}`); plannerLogged = line; }
745
761
  };
746
762
  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
763
  settlePlanner();
749
764
  const catalog = loadRoleCatalog(opts.cwd);
750
765
  for (const error of catalog.errors) log(`catalog role ${error.id}: ${error.reason}`);
@@ -1053,7 +1068,6 @@ export async function startDaemon(opts: DaemonOptions) {
1053
1068
  }, delayMs);
1054
1069
  recoveryLeaseTimer.unref?.();
1055
1070
  };
1056
- const memory = new MemoryClient(config.memory.worker_url ?? workerUrl(), 2000, log);
1057
1071
  const chain = projectChain(opts.cwd);
1058
1072
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
1059
1073
  // #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
@@ -1097,18 +1111,137 @@ export async function startDaemon(opts: DaemonOptions) {
1097
1111
  * Notes as they leave for a model (a recall preface, a brief, `ask` evidence) are screened again under the current
1098
1112
  * policy: a shared note was accepted under another project's patterns, and patterns change. A match is withheld.
1099
1113
  */
1100
- const screenMemory = <T extends { title: string | null; text: string }>(vs: T[]): T[] => withoutPii(vs, activeRouting(), opts.cwd, config.pii?.paths ?? []);
1114
+ 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
1115
  let candidates: CandidateStore | undefined;
1102
1116
  if (memoryStore) {
1103
1117
  try { candidates = openProfileCandidates(memoryBinding!); const opened = candidates; startupCleanup.push(() => opened.close()); }
1104
1118
  catch { log("memory candidate sidecar unavailable"); }
1105
1119
  }
1120
+ let captureBinding = memoryBinding;
1121
+ if (config.memory.enabled && !captureBinding && !memoryProblem) {
1122
+ try { captureBinding = new MemoryProfileBinding({ project: memoryProject, projectKey: memoryProjectRaw }); }
1123
+ catch { memoryUnavailable("memory profile unavailable; capture disabled"); }
1124
+ }
1125
+ const memory = opts.memoryClient ?? new MemoryClient({
1126
+ project: memoryProject, store: memoryStore, candidates,
1127
+ check: () => { if (memoryProblem || !captureBinding) throw new Error("memory unavailable"); captureBinding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) throw new Error("memory project changed"); },
1128
+ screen: rows => screenMemory(rows),
1129
+ publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
1130
+ capture: () => ({ memoryDir: captureBinding!.home }), log,
1131
+ });
1106
1132
  const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
1107
1133
  const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
1108
1134
  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
1135
  /** 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
1136
  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 } } : {}) });
1137
+ taskMemoryQueryNodes({ ...t, title: t.title ?? "" }, { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny });
1138
+
1139
+ const memoryCodeCache = new Map<string, { code: import("./memory-code-overlay.ts").MemoryCodeOverlay; proof: MemoryCodeHead; expires: number }>();
1140
+ const memoryCodeProofs = new Map<string, MemoryCodeHead>();
1141
+ const memoryCodeSeeds = new WeakMap<object, Set<string>>();
1142
+ let memoryCodeNextStart = 0;
1143
+ async function memoryCode(starts: readonly string[], valid: () => boolean = () => true, cacheOnly = false) {
1144
+ if (!memoryStore || !starts.length || !valid()) return unavailableMemoryCodeOverlay();
1145
+ const key = JSON.stringify([memoryRepo, starts]), now = performance.now(), cached = memoryCodeCache.get(key);
1146
+ if (cached && cached.expires > now && cached.proof.current() === cached.code.head) return cached.code;
1147
+ if (cacheOnly || now < memoryCodeNextStart) return unavailableMemoryCodeOverlay();
1148
+ memoryCodeNextStart = now + 5000;
1149
+ const proof = readMemoryCodeHead(opts.cwd); if (!proof) return unavailableMemoryCodeOverlay();
1150
+ const head = proof.head; memoryCodeProofs.set(head, proof);
1151
+ if (memoryCodeProofs.size > 32) memoryCodeProofs.delete(memoryCodeProofs.keys().next().value!);
1152
+ const code = await deriveMemoryCodeOverlay(starts, {
1153
+ context: () => ({ cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny }),
1154
+ head: () => head,
1155
+ valid: () => { checkMemory(); return !stopping && valid() && proof.current() === head; },
1156
+ publicMetadataText: text => tasks.publicMetadataText(text) && boundedSafe(text),
1157
+ neighbours: (root, head, selector, depth) => codeFacts.neighbours(root, head, selector, depth, { priority: "optional" }),
1158
+ related: (nodes, limit) => screenMemory(memoryStore!.related(memoryProject, [...nodes], limit)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about })),
1159
+ });
1160
+ 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)));
1161
+ memoryCodeCache.set(key, { code, proof, expires: performance.now() + (code.state === "known" ? 30_000 : 5000) });
1162
+ if (memoryCodeCache.size > 32) memoryCodeCache.delete(memoryCodeCache.keys().next().value!);
1163
+ return code;
1164
+ }
1165
+ function currentMemoryCode(code: import("./memory-code-overlay.ts").MemoryCodeOverlay) {
1166
+ try {
1167
+ checkMemory();
1168
+ if (!code.head || memoryCodeProofs.get(code.head)?.current() !== code.head || !tasks.publicMetadataText(JSON.stringify({ ...code, notes: [] })) || !boundedSafe(JSON.stringify({ ...code, notes: [] }))) return unavailableMemoryCodeOverlay();
1169
+ const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: (text: string) => tasks.publicMetadataText(text) && boundedSafe(text) };
1170
+ if (code.nodes.some(node => canonicalMemorySymbolNode(node.id, context) !== node.id)) return unavailableMemoryCodeOverlay();
1171
+ for (const note of code.notes) {
1172
+ const row = memoryStore?.get(note.id.slice(4), memoryProject);
1173
+ if (!row || `mem:${row.id}` !== note.id || !screenMemory([row]).length || !boundedSafe(JSON.stringify(row))) return unavailableMemoryCodeOverlay();
1174
+ }
1175
+ const seeds = memoryCodeSeeds.get(code) ?? new Set<string>();
1176
+ const neighbours = code.nodes.map(node => node.id).filter(id => !seeds.has(id));
1177
+ const rows = memoryStore && neighbours.length ? screenMemory(memoryStore.related(memoryProject, neighbours, 128)) : [];
1178
+ const notes = rows.filter(row => row.about.some(id => neighbours.includes(id)) && boundedSafe(JSON.stringify(row)))
1179
+ .sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || b.at.localeCompare(a.at)).slice(0, 16)
1180
+ .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 }));
1181
+ return { ...code, notes };
1182
+ } catch { return unavailableMemoryCodeOverlay(); }
1183
+ }
1184
+ async function taskMemoryResult(task: import("../memory/brief.ts").BriefTask, cacheOnly = false) {
1185
+ const maximum = config.memory.brief_items;
1186
+ const current = () => task.id === undefined ? undefined : board.get(task.id), initial = current();
1187
+ 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 ?? ""); };
1188
+ if (!memoryStore || initial && tasks.isPii(initial)) return { items: [], state: "unknown" as const };
1189
+ const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
1190
+ let code = unavailableMemoryCodeOverlay();
1191
+ try { if (valid() && starts.length) { const prepared = await memoryCode(starts, valid, cacheOnly); if (valid()) code = currentMemoryCode(prepared); } }
1192
+ catch { /* Optional code failure must not discard existing direct memory advice. */ }
1193
+ checkMemory();
1194
+ const latest = current(); if (latest && tasks.isPii(latest)) return { items: [], state: "unknown" as const };
1195
+ // Optional code evidence never suppresses the existing direct path/title/symbol memory brief.
1196
+ const direct = screenMemory(memoryStore.related(memoryProject, taskNodes(task), 128)).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
1197
+ const items = [...new Map([...direct, ...code.notes].map(row => [row.id, row])).values()]
1198
+ .filter(row => [row.line, ...(row.about ?? [])].every(tasks.publicMetadataText))
1199
+ .sort((a, b) => Number(b.kind === "fail") - Number(a.kind === "fail") || Number(b.kind === "decision") - Number(a.kind === "decision"))
1200
+ .slice(0, maximum);
1201
+ return { items, state: starts.length ? code.state : "known" as const, ...(code.head ? { head: code.head } : {}) };
1202
+ }
1203
+ async function taskMemoryItems(task: import("../memory/brief.ts").BriefTask) { return (await taskMemoryResult(task)).items; }
1204
+
1205
+ async function taskIntentBrief(task: import("../memory/brief.ts").BriefTask): Promise<import("../memory/brief.ts").IntentBriefContext> {
1206
+ checkMemory();
1207
+ const initial = task.id === undefined ? undefined : board.get(task.id), revision = configRevision;
1208
+ const allowed = () => {
1209
+ const current = task.id === undefined ? undefined : board.get(task.id);
1210
+ return !stopping && configRevision === revision && !!initial && !!current && current.created === task.created && current.updated === initial.updated
1211
+ && current.history.length === initial.history.length && current.owner === initial.owner && !tasks.isPii(current)
1212
+ && !turns.get(current.owner ?? "")?.private && !contextPii(current.owner ?? "");
1213
+ };
1214
+ const assertCurrent = () => { checkMemory(); if (!allowed()) throw Error("memory brief context changed"); };
1215
+ assertCurrent();
1216
+ // Derived code neighbours are additional read-time selectors, never persisted memory or authority.
1217
+ const starts = taskNodes(task).filter(node => node.startsWith("symbol:"));
1218
+ let extra: string[] = [];
1219
+ if (starts.length && tasks.memoryCodeEligible(initial!)) {
1220
+ try {
1221
+ const prepared = await memoryCode(starts, allowed);
1222
+ assertCurrent();
1223
+ extra = currentMemoryCode(prepared).nodes.map(node => node.id).filter(node => node.startsWith("symbol:")).map(node => node.slice(7));
1224
+ } catch { /* Optional code evidence never removes direct intent memory. */ }
1225
+ }
1226
+ assertCurrent();
1227
+ const stale = staleness();
1228
+ const policy = createStandalonePolicy({ denyPaths: config.local.deny, host: { screen: async value => {
1229
+ assertCurrent();
1230
+ if (value && typeof value === "object" && "id" in value) {
1231
+ const row = value as MemoryView;
1232
+ return screenMemory([row]).length === 1 && tasks.publicMetadataText(JSON.stringify(row));
1233
+ }
1234
+ // The trusted host locator is not note content (a private directory can contain this project).
1235
+ const { cwd: _cwd, ...query } = value as Record<string, unknown>;
1236
+ return boundedSafe(JSON.stringify(query));
1237
+ } } });
1238
+ return { source: createIntentBriefSource(memoryStore!, memoryProject), options: {
1239
+ project: memoryProject, cwd: opts.cwd, repo: memoryRepo, intent: task.title,
1240
+ paths: [...task.refs.paths ?? [], ...task.plan?.paths ?? []], symbols: [...task.plan?.symbols ?? [], ...extra],
1241
+ policy: () => policy, policyVersion: () => String(configRevision), allowed, assertCurrent,
1242
+ stale: row => stale(row.about), now: () => memoryStore!.readNow(),
1243
+ } };
1244
+ }
1112
1245
 
1113
1246
  const leaseNoticed = new Set<string>();
1114
1247
  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(", ")}` : ""})` : ""}`;
@@ -1169,7 +1302,7 @@ export async function startDaemon(opts: DaemonOptions) {
1169
1302
  try {
1170
1303
  checkMemory();
1171
1304
  const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
1172
- if (!r.ok && !/^(no sync remote|stopped)/.test(r.error ?? "")) log(`hub memory sync failed: ${r.error}`);
1305
+ if (!r.ok && !quietSyncError(r.error)) log(`hub memory sync failed: ${r.error}`);
1173
1306
  else if (r.warning) log(`hub memory sync: ${r.warning}`);
1174
1307
  } catch { /* A stale binding disables native memory, never the bus. */ }
1175
1308
  finally { syncRun = undefined; }
@@ -1180,7 +1313,7 @@ export async function startDaemon(opts: DaemonOptions) {
1180
1313
  /** Session-start cross-platform recall. Awaited before the peer can receive anything, so it rides on the first delivery. Fail-open. */
1181
1314
  async function ensurePreface(peer: PeerId): Promise<void> {
1182
1315
  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.
1316
+ // The hub's own long-term decisions, contracts and fails lead; amem's curated short-term notes follow, each with half the budget.
1184
1317
  let own: string | undefined;
1185
1318
  try {
1186
1319
  checkMemory();
@@ -1247,6 +1380,7 @@ export async function startDaemon(opts: DaemonOptions) {
1247
1380
  const board = new Board(join(opts.stateDir, "hub.db"));
1248
1381
  const codeFacts = new CodeFacts();
1249
1382
  const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1383
+ const memorySymbols = new Map<string, MemorySymbolNodes>();
1250
1384
  startupCleanup.push(() => board.close());
1251
1385
  startupCleanup.push(() => codeFacts.close());
1252
1386
  const supervision = new SupervisionFeed({
@@ -1409,7 +1543,8 @@ export async function startDaemon(opts: DaemonOptions) {
1409
1543
  };
1410
1544
  // Bind both legacy print transports and task-MCP calls to the child's original review request.
1411
1545
  const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
1412
- if (!definitionOf(by)?.reviewer) return undefined;
1546
+ // #792: only a reviewer backend runs bound reviews; an unavailable placeholder slot (agy) fences nothing.
1547
+ if (!definitionOf(by)?.reviewer?.backend) return undefined;
1413
1548
  const attached=bus.peers.get(by), peer=isReviewerPrintPeer(attached)?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1414
1549
  return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
1415
1550
  ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
@@ -1460,8 +1595,23 @@ export async function startDaemon(opts: DaemonOptions) {
1460
1595
  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
1596
  if ("unavailable" in brief) return;
1462
1597
  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!);
1598
+ // prepareOnly and completion share one exact-tree pass, including an unknown result.
1599
+ if (memoryStore && memorySymbols.has(key)) return;
1600
+ const observed = await codeFacts.changedSymbols(root, brief.mergeBase, brief.head);
1601
+ reviewSymbols.set(key, observed);
1602
+ const candidates = tasks.memorySymbolCandidates(task);
1603
+ if (memoryStore && candidates && !tasks.isPii(board.get(task.id) ?? task)) {
1604
+ const nodes = await resolveMemorySymbolNodes(candidates,
1605
+ { cwd: root, project: memoryProject, repo: memoryRepo, deny: config.local.deny, allowed: tasks.publicMetadataText },
1606
+ 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); });
1607
+ memorySymbols.set(key, nodes);
1608
+ }
1609
+ if (reviewSymbols.size > 64) { const oldest = reviewSymbols.keys().next().value!; reviewSymbols.delete(oldest); memorySymbols.delete(oldest); }
1610
+ },
1611
+ memorySymbolNodes: task => {
1612
+ if (tasks.isPii(task) || !task.codeSymbols || task.codeSymbols.head !== task.refs.commit) return undefined;
1613
+ const root = executionRoot(task.owner ?? "");
1614
+ return memorySymbols.get(JSON.stringify([root, task.codeSymbols.head, task.codeSymbols.mergeBase]));
1465
1615
  },
1466
1616
  reviewBrief: task => {
1467
1617
  const root = executionRoot(task.owner ?? "");
@@ -1479,9 +1629,10 @@ export async function startDaemon(opts: DaemonOptions) {
1479
1629
  candidates, candidateScreen: screenMemory, privateTurn: peer => !!turns.get(peer)?.private || contextPii(peer),
1480
1630
  memoryCheck: checkMemory,
1481
1631
  ...(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 }),
1484
- items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); }) } : {}),
1632
+ memoryStore ? taskMemoryItems : undefined,
1633
+ (peer, task, counts) => event({ type: "memory_brief", source: "amem", peer, task, ...counts }),
1634
+ items => { const stale = staleness(); return items.map(item => `${item.line}${stale(item.about ?? []) ? " (stale?)" : ""}`); },
1635
+ memoryStore ? taskIntentBrief : undefined) } : {}),
1485
1636
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
1486
1637
  checkMemory();
1487
1638
  const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
@@ -1915,11 +2066,18 @@ export async function startDaemon(opts: DaemonOptions) {
1915
2066
  cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
1916
2067
  ...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
1917
2068
  },
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"),
2069
+ 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}` }]),
2070
+ forget: peer => budget.forget(peer, "tokens", "count"),
1920
2071
  notice: line => notify(line),
1921
2072
  });
1922
2073
  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))));
2074
+ /** 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. */
2075
+ const budgetPeerNotes = (): BudgetPeerNote[] => {
2076
+ const quota = budget.status();
2077
+ 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);
2078
+ return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
2079
+ hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
2080
+ };
1923
2081
  /** 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
2082
  const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
1925
2083
  const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
@@ -2031,7 +2189,7 @@ export async function startDaemon(opts: DaemonOptions) {
2031
2189
  const bound = executionOwners.get(peer);
2032
2190
  if (bound?.socket) {
2033
2191
  const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
2034
- if (!isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2192
+ if (!isFixedBuiltinDefinition(definitionOf(peer)) || !isWsPeer(bound.owner) || bound.owner.sessionGeneration !== bound.generation || !launch?.privateRecord || launch.launchId !== bound.launchId || launch.executionCwd !== bound.cwd) {
2035
2193
  revokeTerminalExecution(peer);
2036
2194
  return undefined;
2037
2195
  }
@@ -2145,7 +2303,16 @@ export async function startDaemon(opts: DaemonOptions) {
2145
2303
  }
2146
2304
  /** Tokens a peer used, as increments. */
2147
2305
  let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
2306
+ // #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.
2307
+ const hubTokenWindows = new Map<PeerId, TokenWindow>();
2308
+ const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
2309
+ const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
2310
+ // 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.
2311
+ const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
2312
+ 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 });
2313
+ for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
2148
2314
  const addTokens = (peer: PeerId, n: number, task?: number): number => {
2315
+ if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
2149
2316
  const supervisionTurn = supervisionTurns.get(peer);
2150
2317
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
2151
2318
  if (n > 0) {
@@ -2304,6 +2471,9 @@ export async function startDaemon(opts: DaemonOptions) {
2304
2471
  if (nativeTurn && effectful) nativeTurn.effectsPending = (nativeTurn.effectsPending ?? 0) + 1;
2305
2472
  const conducted = conductorPeer(config.roles) === by && conductedMutations.has(op);
2306
2473
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
2474
+ const reviewTool = op === "hub_review" || op === "hub_task_show" ? op : undefined;
2475
+ const traceId = admittedId ?? (typeof input?.id === "string" && /^[1-9][0-9]{0,15}$/.test(input.id) && Number.isSafeInteger(Number(input.id)) ? Number(input.id) : undefined);
2476
+ if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool);
2307
2477
  taskOpsInFlight++;
2308
2478
  try {
2309
2479
  const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
@@ -2319,6 +2489,7 @@ export async function startDaemon(opts: DaemonOptions) {
2319
2489
  const hint = args[3] ? ownHeldHint(by) : "";
2320
2490
  return hint ? `${text}\n\n${hint}` : text;
2321
2491
  } catch (error) {
2492
+ if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
2322
2493
  if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
2323
2494
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
2324
2495
  const hint = args[3] ? ownHeldHint(by) : "";
@@ -2356,10 +2527,10 @@ export async function startDaemon(opts: DaemonOptions) {
2356
2527
  return JSON.stringify(shown ? { ...reviewerView(by, result as Record<string, unknown>), ...briefField(shown) } : result);
2357
2528
  }
2358
2529
  // 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.
2530
+ // shared memory and a new task could be routed to a cloud peer without matching any pattern.
2360
2531
  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
2532
  // 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.
2533
+ // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2363
2534
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
2364
2535
  const target = board.get(Number(a.id));
2365
2536
  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 +2552,17 @@ export async function startDaemon(opts: DaemonOptions) {
2381
2552
  if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
2382
2553
  }
2383
2554
  if (MEMORY_READ_TOOLS.has(op)) {
2555
+ validateMemoryRead(op, a);
2384
2556
  need("memory_read", "read shared memory");
2557
+ if (!boundedSafe(JSON.stringify(a))) throw new PreEffectToolRefusal("memory read unavailable");
2385
2558
  checkMemory();
2386
- return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness());
2559
+ const generation = connectionGeneration(by);
2560
+ const valid = () => may(by, "memory_read") && connectionGeneration(by) === generation && !turns.get(by)?.private && !contextPii(by);
2561
+ const symbolStarts = op === "hub_memory_graph" ? (a.starts as string[]).filter(node => node.startsWith("symbol:")) : [];
2562
+ const code = symbolStarts.length ? await memoryCode(symbolStarts, valid).catch(() => unavailableMemoryCodeOverlay()) : undefined;
2563
+ if (!valid()) throw new PreEffectToolRefusal("memory read unavailable");
2564
+ checkMemory();
2565
+ return memoryRead(memoryStore, memoryProject, screenMemory, op, a, staleness(), code ? currentMemoryCode(code) : undefined);
2387
2566
  }
2388
2567
  if (op === "hub_memory_correct") { need("memory_correct", "propose memory corrections"); return tasks.correct(by, a); }
2389
2568
  if (op === "hub_remember") need("remember", "save notes to shared memory");
@@ -2568,7 +2747,7 @@ export async function startDaemon(opts: DaemonOptions) {
2568
2747
  chmodSync(`${file}.tmp`, 0o600);
2569
2748
  renameSync(`${file}.tmp`, file);
2570
2749
  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);
2750
+ 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
2751
  notify(`context: ${by} checkpoint saved locally; no tasks or session changed`);
2573
2752
  return "context checkpoint saved locally; keep your tasks and session, the operator decides the next step";
2574
2753
  }
@@ -2630,13 +2809,21 @@ export async function startDaemon(opts: DaemonOptions) {
2630
2809
  const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2631
2810
  const codeSymbols = tasks.publicView(task).codeSymbols;
2632
2811
  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);
2812
+ const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
2813
+ const fresh = board.get(task.id);
2814
+ 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" };
2815
+ 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);
2816
+ }
2817
+ case "deliverables": {
2818
+ // #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
2819
+ if (a.task !== undefined && (!Number.isSafeInteger(a.task) || (a.task as number) <= 0)) throw new PreEffectToolRefusal("task must be a positive integer");
2820
+ return JSON.stringify(await tasks.deliverableList({ ...(a.task !== undefined ? { task: a.task as number } : {}), approvedOnly: a.approved === true, check: a.check === true }));
2634
2821
  }
2635
2822
  case "task_assign":
2636
2823
  case "task_assign_checked": {
2637
2824
  const peer = peerArg(a.peer, "peer");
2638
2825
  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");
2826
+ if (a.role !== undefined && a.role !== "owner" && a.role !== "reviewer" && a.role !== "planner") throw new Error("role must be owner, reviewer or planner");
2640
2827
  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
2828
  return line(await tasks.assignTo(a.id, peer, USER, true, a.role, op === "task_assign_checked" ? a.revision : undefined));
2642
2829
  }
@@ -2658,7 +2845,7 @@ export async function startDaemon(opts: DaemonOptions) {
2658
2845
  // `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
2659
2846
  const turn = turnLog?.get(String(a.turn));
2660
2847
  if (!turn) throw new Error(`no turn ${a.turn} recorded`);
2661
- if (!definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2848
+ if (!isFixedBuiltinDefinition(definitionOf(turn.peer)) || !definitionOf(turn.peer)?.session.nativeRevert || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
2662
2849
  if (turnLog!.latest(turn.peer)?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
2663
2850
  const codex = bus.peers.get(turn.peer);
2664
2851
  if (!isCodexPeer(codex)) throw new Error("codex is not attached");
@@ -2714,7 +2901,13 @@ export async function startDaemon(opts: DaemonOptions) {
2714
2901
  else delete defaultSources[peer];
2715
2902
  }
2716
2903
  };
2717
- const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
2904
+ // Action allowances have their own host session port; adding local here must not change checkpoints, context telemetry or supervision.
2905
+ const actionSession = (peer: PeerId): string | undefined => {
2906
+ const owner = bus.peers.get(peer);
2907
+ if (isLocalPeer(owner)) { const id = owner.recoveryMetadata().sessionId; return typeof id === "string" ? id : undefined; }
2908
+ return contextSession(peer);
2909
+ };
2910
+ const actionBudgets = new ActionBudgets(() => config.action_policy, actionSession);
2718
2911
  // #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
2912
  const acpActionGate = (peer: PeerId) => actionGate({ budgets: actionBudgets, policy: () => config.action_policy, root: executionRoot,
2720
2913
  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 +2926,18 @@ export async function startDaemon(opts: DaemonOptions) {
2733
2926
  return { enforced: true };
2734
2927
  }
2735
2928
  const owner = bus.peers.get(peer);
2929
+ if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
2930
+ const generation = owner?.sessionGeneration;
2931
+ return owner?.state === "busy" && typeof generation === "string" && generation.length > 0 ? { enforced: true } : { enforced: false, reason: "no current managed Claude implementer child" };
2932
+ }
2933
+ 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" };
2934
+ if (isLocalPeer(owner)) return owner.state !== "offline" ? { enforced: true } : { enforced: false, reason: "the local worker is offline" };
2935
+ if (isPiPeer(owner)) return owner.state !== "offline" && owner.acceptingTools ? { enforced: true } : { enforced: false, reason: "Pi has no verified live tool bridge" };
2736
2936
  if (isAcpPeer(owner) && owner.actionGated) {
2737
2937
  const mode = owner.permissionModeState;
2738
2938
  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
2939
  }
2740
- return { enforced: false };
2940
+ return { enforced: false, reason: "this adapter has no hub pre-tool approval path" };
2741
2941
  };
2742
2942
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2743
2943
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
@@ -3018,6 +3218,7 @@ export async function startDaemon(opts: DaemonOptions) {
3018
3218
  ...(() => { const warning = mainCheckoutWarning(opts.cwd, tasks.publicMetadataText, checkoutBranch); return warning ? { mainCheckout: warning } : {}; })(),
3019
3219
  startModes: Object.fromEntries([...START_MODE_PEERS, ...configuredIds].map(peer => [peer, startModeOf(config.peers, peer, declarations, adapterCatalog)])),
3020
3220
  memoryProfile: memoryProfileStatus(includeAttentionItems),
3221
+ memoryRead: memory.readStatus(),
3021
3222
  lifecycleStages: [...lifecycleConfig.stages],
3022
3223
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
3023
3224
  ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
@@ -3041,7 +3242,7 @@ export async function startDaemon(opts: DaemonOptions) {
3041
3242
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
3042
3243
  codexProxyPort: opts.codexProxyPort,
3043
3244
  peers: Object.fromEntries(
3044
- 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 ?? [] } : {}) }]; }),
3245
+ 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), ...(isFixedBuiltinDefinition(definitionOf(id)) && (isCodexPeer(p) || isQwenDualPeer(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 ?? [] } : {}) }]; }),
3045
3246
  ),
3046
3247
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
3047
3248
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -3116,7 +3317,7 @@ export async function startDaemon(opts: DaemonOptions) {
3116
3317
  settlingSuperseded = true;
3117
3318
  try {
3118
3319
  for (const row of bus.queueList(undefined, true)) {
3119
- if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:")) continue;
3320
+ if (row.state !== "needs_review" || row.reason?.startsWith("requested stop:") || row.reason?.startsWith(REVIEWER_FALLBACK_UNCONFIRMED)) continue;
3120
3321
  const reason = supersededDeliveryReason(row.originals, board, row.peer);
3121
3322
  if (!reason) continue;
3122
3323
  try { bus.resolveDelivery(row.id, row.revision, "completed", reason, "hub"); }
@@ -3589,6 +3790,28 @@ export async function startDaemon(opts: DaemonOptions) {
3589
3790
  const result = await requestPermission(req, signal);
3590
3791
  return result.outcome === "answered" ? result.optionId : undefined;
3591
3792
  }
3793
+ /** The managed local/Pi execution boundary: a classed call asks before any native mode or tool-wide grant. */
3794
+ async function classedToolPermission(peer: string, root: string, title: string, tool: string | undefined, input: unknown, signal: AbortSignal | undefined, current: () => boolean, observe?: ApprovalObserver): Promise<ToolApproval | undefined> {
3795
+ if (!tool || input === undefined) return undefined;
3796
+ if (stopping || signal?.aborted || !current()) return "aborted";
3797
+ // runTool executes in this host root and ignores model-supplied cwd/workdir fields.
3798
+ const effective = input && typeof input === "object" && !Array.isArray(input) ? { ...input, cwd: root, workdir: root } : input;
3799
+ let gate: ActionGateVerdict | undefined;
3800
+ try { gate = acpActionGate(peer)({ names: [tool], input: effective }, actionSession(peer) ?? "", title); }
3801
+ catch { gate = { title: "Action approval: the always-ask check failed; command withheld", denyOnly: true }; }
3802
+ if (!gate) return undefined;
3803
+ const options: PermissionRequest["options"] = [{ optionId: "allow", name: "Allow once", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }];
3804
+ if (gate.denyOnly) options.splice(0, 1);
3805
+ if (!gate.denyOnly && gate.budget) options.push({ optionId: ACTION_BUDGET_OPTION, name: `Allow ${gate.budget.label} for this session`, kind: "allow_once" });
3806
+ const picked = await requestPermission({ peer, title: gate.title, tool, classed: true, options }, signal, observe);
3807
+ if (picked.outcome === "expired") return "expired";
3808
+ if (stopping || signal?.aborted || !current()) return "aborted";
3809
+ if (picked.outcome !== "answered") return false;
3810
+ if (!gate.denyOnly && picked.optionId === ACTION_BUDGET_OPTION) {
3811
+ try { return gate.budget?.approve() === true; } catch { return false; }
3812
+ }
3813
+ return !gate.denyOnly && picked.optionId === "allow";
3814
+ }
3592
3815
  async function requestPermission(req: PermissionRequest, signal?: AbortSignal, observe?: ApprovalObserver): Promise<PermissionResult> {
3593
3816
  const requestEpoch = permissionAnswerEpoch(req.peer);
3594
3817
  const observed = (source: "person" | "automatic" | "expired" | "aborted") => {
@@ -3813,6 +4036,7 @@ export async function startDaemon(opts: DaemonOptions) {
3813
4036
  const owner = bus.peers.get(peer);
3814
4037
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(isWsPeer(owner) ? "tui" : !owner ? startMode(peer) : "headless", owner);
3815
4038
  if (stopNotice) return { ok: false, error: stopNotice };
4039
+ if (isQwenDualPeer(owner)) return { ok: false, error: "Qwen belongs to its own terminal; end it there (the hub never signals a person's Qwen)" };
3816
4040
  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" };
3817
4041
  if (isPiPeer(owner) && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3818
4042
  if (owner.state === "offline" && !(isAcpPeer(owner) && owner.pendingOwnedProcess)) return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
@@ -3912,6 +4136,12 @@ export async function startDaemon(opts: DaemonOptions) {
3912
4136
  if (args.model !== undefined && !["dgx/coding", "dgx/fast", "mlx/fast"].includes(args.model)) return { ok: false, error: "unknown Pi model alias" };
3913
4137
  }
3914
4138
  const existing = bus.peers.get(peer);
4139
+ if (adapter?.start.createTui && existing) {
4140
+ // One Qwen at a time, in one form: a headless worker and the person's TUI would both read the same session's work. The TUI is judged by the process it holds, not by peer
4141
+ // state: a detached TUI reads offline while the person's Qwen keeps running, and replacing that adapter would strand it and admit a second Qwen beside it.
4142
+ if (isQwenDualPeer(existing) && existing.holdsTerminal()) return args.mode === "tui" ? { ok: true, already: true } : { ok: false, error: "Qwen runs in its own terminal; end it there before starting a headless one" };
4143
+ if (args.mode === "tui" && !isQwenDualPeer(existing) && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
4144
+ }
3915
4145
  if (args.replaceSession) {
3916
4146
  if (recoveryActive()) return { ok: false, error: "recovery is holding session replacement" };
3917
4147
  if (!args.sessionFile || args.sessionId || args.fresh) return { ok: false, error: "Pi replacement requires --replace-session --session-file <managed-file>, without --session-id or fresh" };
@@ -3959,12 +4189,49 @@ export async function startDaemon(opts: DaemonOptions) {
3959
4189
  if (adapter && configuredIds.includes(adapter.kind) && args.sessionId !== undefined && args.sessionId !== adapter.session.currentId(existing)) {
3960
4190
  return { ok: false, error: "configured ACP is already running a different native session; stop it before selecting another" };
3961
4191
  }
3962
- return { ok: true, already: true, ...(isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
4192
+ return { ok: true, already: true, ...(isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(existing) ? { proxyUrl: existing.proxyUrl } : {}) };
3963
4193
  }
3964
4194
  if (!adapter?.start.reconfigureWhileAttached) {
3965
4195
  if (isPiPeer(existing)) await stopPiForReplacement(existing);
3966
4196
  else await existing?.stop();
3967
4197
  }
4198
+ if (adapter?.start.createTui && args.mode === "tui") {
4199
+ // #286 Part 3: Qwen's own TUI, driven through its dual-output files. The person's terminal runs it; the hub prepares the files and returns the launch.
4200
+ if (declarations[peer]) return { ok: false, error: "declared instances support headless starts only" };
4201
+ if (!onRoster(peer)) return { ok: false, error: "qwen is off roster; a person can add it with ahub roster add qwen" };
4202
+ if (existing && existing.state !== "offline") return { ok: false, error: "Qwen is running headless; run ahub stop qwen before starting its TUI" };
4203
+ // The compat entry of the dual-output contract, not the headless ACP entry: 0.26.1 only.
4204
+ const verdict = await qwenDualVersionAsync(config.qwen_cmd, executionCwd);
4205
+ if (!verdict.ok) return { ok: false, error: verdict.reason };
4206
+ if (verdict.note) log(verdict.note);
4207
+ if (args.sessionId !== undefined && !adapter.session.crashIdentity?.({ sessionId: args.sessionId }).sessionId) return { ok: false, error: "invalid Qwen session identity" };
4208
+ actionBudgets.revoke(peer);
4209
+ const nativeRoot = nativeStateDir(peer);
4210
+ mkdirSync(nativeRoot, { recursive: true });
4211
+ const qwenState = join(nativeRoot, "qwen");
4212
+ const tui: QwenDualPeer = adapter.start.createTui({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["qwen-dual"]({
4213
+ cwd: executionCwd, stateDir: qwenState, cmd: config.qwen_cmd, ...(args.model ? { model: args.model } : {}), ...(args.sessionId ? { sessionId: args.sessionId } : {}),
4214
+ mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: toolEnv(peer, true) }],
4215
+ watchdogMs: config.watchdog_ms, preamble: sessionRoleContract(peer),
4216
+ // The hub's own tools only, by their exact `mcp__agent-hub__<name>`; everything else asks the console. Never a prefix, never a title.
4217
+ autoApprove: name => !stopping && HUB_TOOL_TITLES.has(name),
4218
+ // `stateDir` is the hub's own state dir: the adapter refuses any Qwen write or command that names it (the input file is a prompt channel).
4219
+ guardDir: opts.stateDir,
4220
+ onPermission, actionGate: (call, session) => acpActionGate(peer)({ names: call.names, input: call.input }, session),
4221
+ onStalled: () => writeStatus(),
4222
+ onNotice: text => { try { notify(text); } catch { /* the notice is advisory */ } },
4223
+ onExit: () => { notify(`Qwen's terminal ended; start it again with ahub qwen --mode tui`); writeStatus(); },
4224
+ log,
4225
+ }) }) as QwenDualPeer;
4226
+ recoveryTaskPreface(peer); await ensurePreface(peer);
4227
+ executionOwners.set(peer, { owner: tui, cwd: executionCwd });
4228
+ try {
4229
+ await tui.start();
4230
+ bus.add(tui);
4231
+ if (tui.state !== "offline") tui.onState?.(tui.state);
4232
+ } catch (error) { await tui.stop().catch(stop => log(`qwen tui stop after a failed start: ${(stop as Error).message}`)); throw error; }
4233
+ return { ok: true, mode: "tui", ...(tui.adopted ? { adopted: true } : { launch: tui.launch }) };
4234
+ }
3968
4235
  if (adapter?.start.daemonPort === "owned-acp") {
3969
4236
  if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
3970
4237
  // #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
@@ -4080,9 +4347,11 @@ export async function startDaemon(opts: DaemonOptions) {
4080
4347
  if (adapter?.start.daemonPort === "codex-appserver") {
4081
4348
  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
4349
  let codexSteering = false;
4350
+ actionBudgets.revoke(peer);
4083
4351
  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
4352
  admitAttach: () => membershipAllowed(peer),
4085
4353
  permissionMode: permissionMode(peer),
4354
+ actionGate: acpActionGate(peer), onPermission,
4086
4355
  onTokens: (added) => void addTokens(peer, added),
4087
4356
  // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
4088
4357
  // 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 +4450,11 @@ export async function startDaemon(opts: DaemonOptions) {
4181
4450
  let piReply: Envelope | undefined;
4182
4451
  // Tools the person allowed "always" for this Pi start; a new start gets a new set (#209).
4183
4452
  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> => {
4453
+ actionBudgets.revoke(peer);
4454
+ const piPermit = async (title: string, tool: string, signal?: AbortSignal, path?: string, valid: () => boolean = () => true, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
4455
+ if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4456
+ const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => valid() && pi.acceptingTools && bus.peers.get(peer) === pi, observe);
4457
+ if (classed !== undefined) return classed;
4185
4458
  if (stopping || signal?.aborted || !valid() || !pi.acceptingTools || bus.peers.get(peer) !== pi) return "aborted";
4186
4459
  const selected = permissionMode(peer);
4187
4460
  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 +4504,8 @@ export async function startDaemon(opts: DaemonOptions) {
4231
4504
  const nativeTurn = pi.observationTurn;
4232
4505
  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
4506
  ...(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);
4507
+ permit: async (title, tool, toolSignal, path, _observe, originalPath, input) => {
4508
+ const picked = humanBash ? (toolSignal?.aborted || context?.isCurrent() !== true ? "aborted" : true) : await piPermit(title, tool ?? name, toolSignal, path, context?.isCurrent, context?.onApproval, originalPath, input);
4236
4509
  return picked; // never turn a nonempty expiry/abort string into authorization
4237
4510
  },
4238
4511
  });
@@ -4342,9 +4615,13 @@ export async function startDaemon(opts: DaemonOptions) {
4342
4615
  if (existing?.state === "busy") return { ok: false, error: "local is busy; retry when it is idle" };
4343
4616
  if (existing) { mute(existing); await existing.stop(); }
4344
4617
  const capture = config.memory.enabled
4345
- ? new Capture(memory, { project: chain.at(-1)!, cwd: executionCwd, skip: skipTools(), deny: config.local.deny })
4618
+ ? new Capture(memory, { project: memoryProject, cwd: executionCwd, deny: config.local.deny })
4346
4619
  : undefined;
4347
- const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string): Promise<ToolApproval> => {
4620
+ actionBudgets.revoke(peer);
4621
+ const permit = async (title: string, tool?: string, signal?: AbortSignal, path?: string, observe?: ApprovalObserver, originalPath?: string, input?: unknown): Promise<ToolApproval> => {
4622
+ if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4623
+ const classed = await classedToolPermission(peer, executionCwd, title, tool, input, signal, () => bus.peers.get(peer) === local, observe);
4624
+ if (classed !== undefined) return classed;
4348
4625
  if (stopping || signal?.aborted || bus.peers.get(peer) !== local) return "aborted";
4349
4626
  const selected = permissionMode(peer);
4350
4627
  if (selected === "never-ask" || (selected === "ask-when-needed" && !!tool && PI_EDIT_TOOLS.has(tool) && !!path && grantablePath(realPath(executionCwd), path, originalPath, config.protect_instruction_files))) {
@@ -4457,7 +4734,8 @@ export async function startDaemon(opts: DaemonOptions) {
4457
4734
  privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
4458
4735
  bindingCurrent: (task,binding) => tasks.reviewBindingCurrent(peer,task,binding),
4459
4736
  taskMcp: (task, current) => createReviewerMcpSession({ task, current, tools: [...TASK_TOOLS, ...CONDUCTOR_TOOLS],
4460
- call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)) }),
4737
+ call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)),
4738
+ onTool: (tool, refusal) => { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); } }),
4461
4739
  admit: env => {
4462
4740
  if (env.private || contextPii(peer) || !reviewOnlyPeer(peer) || !tasks.reviewBindingCurrent(peer, Number(env.refs?.task),
4463
4741
  { head: env.refs?.commit, flowRevision: Number(env.refs?.reviewRevision) })) return false;
@@ -4471,16 +4749,26 @@ export async function startDaemon(opts: DaemonOptions) {
4471
4749
  facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
4472
4750
  verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
4473
4751
  onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
4752
+ onNoVerdict: (diagnostic, current) => {
4753
+ const task = board.get(diagnostic.task);
4754
+ const visible = current() && bus.peers.get(peer) === reviewer && !!task && !tasks.isPii(task) && !contextPii(peer)
4755
+ && tasks.reviewBindingCurrent(peer, diagnostic.task, diagnostic);
4756
+ // Screen the complete refusal before any sanitization or bound; no task text or model output is logged.
4757
+ const hostSafe = visible && !diagnostic.refusalUncertain && (diagnostic.hostRefusal === undefined || boundedSafe(diagnostic.hostRefusal) && tasks.publicMetadataText(diagnostic.hostRefusal));
4758
+ const safe = visible && hostSafe && diagnostic.refusal !== undefined && boundedSafe(diagnostic.refusal) && tasks.publicMetadataText(diagnostic.refusal) ? diagnostic.refusal : undefined;
4759
+ log(reviewerNoVerdictLine(peer, diagnostic, safe, !visible));
4760
+ },
4474
4761
  onUnavailable: (failure, current) => {
4475
4762
  const attached = () => current() && bus.peers.get(peer) === reviewer;
4476
- if (!attached()) return;
4763
+ if (!attached()) return false;
4477
4764
  if (failure.cause === "start_failed") {
4478
4765
  // #722: the class (a closed list) is the only detail; never a path, native text or token.
4479
4766
  const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
4480
4767
  log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
4481
4768
  reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
4482
4769
  }
4483
- return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
4770
+ let confirmed = false;
4771
+ return tasks.retryReviewer(failure.task, peer, failure, attached, () => { confirmed = true; }).then(() => confirmed);
4484
4772
  },
4485
4773
  log,
4486
4774
  }) }) as ReviewerPrintPeer;
@@ -4496,6 +4784,10 @@ export async function startDaemon(opts: DaemonOptions) {
4496
4784
  }
4497
4785
  reviewerUnavailable.delete(peer);
4498
4786
  executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
4787
+ reviewerToolObservers.set(peer, (task, tool, refusal) => {
4788
+ try { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); }
4789
+ catch { /* Diagnostics never change a host tool's result or authority. */ }
4790
+ });
4499
4791
  bus.add(reviewer);
4500
4792
  return { ok: true };
4501
4793
  }
@@ -4567,7 +4859,7 @@ export async function startDaemon(opts: DaemonOptions) {
4567
4859
  const summary = bus.queueSummary(peer);
4568
4860
  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
4861
  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 })) } : {}) };
4862
+ ...(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
4863
  }),
4572
4864
  taskCounts: board.counts(), approvals: [...permissions.values()].map(p => ({ peer: p.peer, tool: p.tool, at: p.createdAt })),
4573
4865
  };
@@ -4664,9 +4956,25 @@ export async function startDaemon(opts: DaemonOptions) {
4664
4956
  entries: rows.map((v) => shape(v)), ...(node ? { node, graph: memoryStore.graph(memoryProject, node, hops, 200), ...(selected ? { detail: shape(selected, true) } : {}) } : {}) } };
4665
4957
  } catch (e) { log(`hub memory view failed: ${(e as Error).message}`); return {}; } // an unreadable store never breaks the dashboard
4666
4958
  }
4959
+ async function uiMemorySnapshot(after: number, input?: Record<string, unknown>, session?: DashboardSession) {
4960
+ const query = input?.memory as Record<string, unknown> | undefined;
4961
+ 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;
4962
+ const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
4963
+ const snapshot = uiSnapshot(after, input, session);
4964
+ if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
4965
+ const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
4966
+ const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
4967
+ 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)
4968
+ && (!query?.since || typeof query.since !== "string" || !note.at || note.at >= query.since)
4969
+ && (!search || (() => { const row = memoryStore?.get(note.id.slice(4), memoryProject); return !!row && `${row.title ?? ""} ${row.text}`.toLowerCase().includes(search); })()));
4970
+ Object.assign(snapshot.memory, { code: published });
4971
+ }
4972
+ return snapshot;
4973
+ }
4667
4974
  function uiSnapshot(after: number, _input?: Record<string, unknown>, session?: DashboardSession) {
4668
4975
  const quota = budget.status();
4669
4976
  const peers = [...new Set([...Object.keys(quota), ...bus.knownPeers()])];
4977
+ const budgetNoteOf = new Map(budgetPeerNotes().map(note => [note.peer, note] as const));
4670
4978
  // #264 (phase 3): turn, checking and links are additive optional fields that older pages ignore,
4671
4979
  // so PROTOCOL and EVENTS_SCHEMA stay unchanged. They carry ids and enums only.
4672
4980
  const turnView = (peer: PeerId) => {
@@ -4744,10 +5052,10 @@ export async function startDaemon(opts: DaemonOptions) {
4744
5052
  tasks: boardTasks.map((task) => {
4745
5053
  const view = tasks.publicView(task);
4746
5054
  // 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 } : {}) };
5055
+ 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
5056
  }),
4749
5057
  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))) }])),
5058
+ 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
5059
  permissions: [...permissions.values()].map(({ push }) => {
4752
5060
  const req = JSON.parse(push);
4753
5061
  // Local tool titles quote file contents and commands, including those of PII turns.
@@ -4804,7 +5112,7 @@ export async function startDaemon(opts: DaemonOptions) {
4804
5112
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4805
5113
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4806
5114
  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));
5115
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled), peer => plannerEligible.has(peer));
4808
5116
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4809
5117
  }
4810
5118
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
@@ -5061,7 +5369,7 @@ export async function startDaemon(opts: DaemonOptions) {
5061
5369
  },
5062
5370
  });
5063
5371
  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.`;
5372
+ 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
5373
  return { pendingRestart: allPendingRestart(), retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
5066
5374
  }
5067
5375
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
@@ -5477,6 +5785,8 @@ export async function startDaemon(opts: DaemonOptions) {
5477
5785
  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
5786
  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
5787
  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";
5788
+ if ((Array.isArray(config.planner) || typeof config.planner === "string" && extraIds.includes(config.planner) || extraIds.some(id => declarations[id]!.roles?.includes("planner")) || board.hasPlannerState())
5789
+ && (!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
5790
  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
5791
  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
5792
  return undefined;
@@ -5598,6 +5908,7 @@ export async function startDaemon(opts: DaemonOptions) {
5598
5908
  }
5599
5909
 
5600
5910
  function claudePermissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined {
5911
+ if (!isFixedBuiltinDefinition(definitionOf(peer))) return undefined;
5601
5912
  const row = readClaudeLaunchProof(opts.stateDir, peer, instanceId);
5602
5913
  return row?.permissionHook ? row : undefined;
5603
5914
  }
@@ -5612,7 +5923,7 @@ export async function startDaemon(opts: DaemonOptions) {
5612
5923
  // A Codex proxy outlives its TUI and keeps its own copy of the mode: clear that too, or the next TUI that attaches
5613
5924
  // to it would run a mode the status no longer shows.
5614
5925
  // It is never refused here: a leftover non-ask mode must always be undoable while the peer is away.
5615
- const note = isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5926
+ const note = isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.clearPermissionMode() ? "The detached session's own approval policy was never reported, so its thread keeps what it last got; a new ahub codex session starts in ask" : undefined;
5616
5927
  if (note) log(`permission mode codex: ${note}`);
5617
5928
  const from = permissionMode(peer);
5618
5929
  permissionModes.set(peer, "ask"); permissionModeSources.set(peer, "human runtime command"); pendingPermissionDefaults.delete(peer);
@@ -5620,7 +5931,7 @@ export async function startDaemon(opts: DaemonOptions) {
5620
5931
  log(`permission mode ${peer}: ${from} -> ask (offline hub choice cleared)`); writeStatus();
5621
5932
  return { ok: true, peer, permissionMode: "ask", ...(note ? { note } : {}) };
5622
5933
  }
5623
- if (isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5934
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5624
5935
  if (!owner || owner.state === "offline") return { ok: false, error: `${peer} is not attached; start it through ahub first` };
5625
5936
  if (mode === undefined) return { ok: true, peer, permissionMode: permissionDisplay(peer, owner) };
5626
5937
  if (!isPermissionMode(mode)) return { ok: false, error: "mode must be ask, ask-when-needed or never-ask" };
@@ -5629,7 +5940,7 @@ export async function startDaemon(opts: DaemonOptions) {
5629
5940
  const launch = claudePermissionLaunch(peer);
5630
5941
  if (!launch || !isWsPeer(owner) || claudePermissionGenerations.get(peer) !== owner.sessionGeneration) return { ok: false, error: "Claude has no verified ahub claude permission hook; restart with ahub claude and run a tool first" };
5631
5942
  if (launch.unattended) return { ok: false, error: "Claude started --unattended already skips prompts; run ahub permission claude ask after it stops to clear the hub choice, then restart without --unattended" };
5632
- } else if (isCodexPeer(owner)) {
5943
+ } else if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner)) {
5633
5944
  if (!owner.proxyAttached) return { ok: false, error: "Codex TUI is not behind the hub proxy; reconnect with ahub codex" };
5634
5945
  if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
5635
5946
  }
@@ -5661,7 +5972,7 @@ export async function startDaemon(opts: DaemonOptions) {
5661
5972
  }
5662
5973
  const owner = bus.peers.get(peer);
5663
5974
  if (definitionOf(peer)?.permission.trackUnattended && nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended cannot confirm a permission default; run ahub permission codex ask after it stops, or restart without --unattended" };
5664
- if (!starting.has(peer) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5975
+ if (!starting.has(peer) && isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && owner.state === "offline") return { ok: false, error: "connect the Codex TUI through the hub proxy before confirming its default" };
5665
5976
  if (!starting.has(peer) && owner && owner.state !== "offline") {
5666
5977
  const result = await changePermission(peer, "never-ask", true);
5667
5978
  if (!result.ok) return result;
@@ -5692,9 +6003,9 @@ export async function startDaemon(opts: DaemonOptions) {
5692
6003
  const result = await permissionChange(peer, async () => {
5693
6004
  if (stopping) return { ok: false, error: "hub is stopping" };
5694
6005
  const selected = permissionMode(peer);
5695
- if (isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
6006
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && nativeUnattended(peer) && selected !== "ask") return { ok: false, error: "Codex --unattended cannot take a non-ask mode; run ahub permission codex ask, or restart without --unattended" };
5696
6007
  if (!readOnlyPeer(peer) && !(isAcpPeer(owner) && owner.permissionModeState === "unmanaged") && owner.getPermissionMode() !== selected) {
5697
- if (isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
6008
+ if (isFixedBuiltinDefinition(definitionOf(peer)) && isCodexPeer(owner) && !owner.proxyAttached) owner.setStartupPermissionMode(selected);
5698
6009
  else await owner.setPermissionMode(selected);
5699
6010
  }
5700
6011
  // The bus may immediately drain at add(): do not expose an idle adapter until its current choice is applied.
@@ -5773,7 +6084,7 @@ export async function startDaemon(opts: DaemonOptions) {
5773
6084
  preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
5774
6085
  preparedSessionRoleIdentities.delete(c.peer);
5775
6086
  ws.claim(sock, msg.channel === false);
5776
- if (declarations[c.peer] && definitionOf(c.peer)?.transport === "claude-channel") {
6087
+ if (declarations[c.peer] && isFixedBuiltinDefinition(definitionOf(c.peer)) && definitionOf(c.peer)?.transport === "claude-channel") {
5777
6088
  revokeTerminalExecution(c.peer);
5778
6089
  const launch = readClaudeLaunchProof(opts.stateDir, c.peer, instanceId);
5779
6090
  try {
@@ -6263,7 +6574,7 @@ export async function startDaemon(opts: DaemonOptions) {
6263
6574
  case "ui":
6264
6575
  if (c.role !== "console") return void reply({ t: "ui", ok: false, error: "ui is a console command" });
6265
6576
  try {
6266
- dashboard ??= startDashboard({ snapshot: uiSnapshot, action: uiAction });
6577
+ dashboard ??= startDashboard({ snapshot: uiMemorySnapshot, action: uiAction });
6267
6578
  writeStatus();
6268
6579
  // #269: `ahub ui --settings` is the same person's command; its ticket marks the session it opens.
6269
6580
  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 +6663,8 @@ export async function startDaemon(opts: DaemonOptions) {
6352
6663
  case "ui_snapshot":
6353
6664
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
6354
6665
  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));
6666
+ 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" }));
6667
+ return;
6356
6668
  case "ui_action":
6357
6669
  if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
6358
6670
  if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
@@ -6480,7 +6792,7 @@ export async function startDaemon(opts: DaemonOptions) {
6480
6792
  // Screened before the caps: withheld entries never crowd out readable evidence.
6481
6793
  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
6794
  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) }));
6795
+ return [...hits, ...near].map((v) => ({ id: `mem:${v.id}`, text: memoryLine(v).slice(13) }));
6484
6796
  } } : {}),
6485
6797
  project: chain.at(-1)!,
6486
6798
  logFile,
@@ -6500,7 +6812,7 @@ export async function startDaemon(opts: DaemonOptions) {
6500
6812
  project: chain.at(-1)!,
6501
6813
  metadata: { peer: "hub", asked_by: USER, kind: "finding", source: "ahub ask" },
6502
6814
  });
6503
- saved = ok ? "saved to shared memory as a model-written answer" : "memory worker unavailable; nothing saved";
6815
+ saved = ok ? "saved as a candidate model-written answer; a person must review it" : "memory store unavailable; nothing saved";
6504
6816
  }
6505
6817
  }
6506
6818
  reply({ t: "ask", ok: true, ...res, ...(saved ? { saved } : {}) });
@@ -6510,7 +6822,7 @@ export async function startDaemon(opts: DaemonOptions) {
6510
6822
  case "budget":
6511
6823
  if (c.role !== "console") {
6512
6824
  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 });
6825
+ 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
6826
  }
6515
6827
  if (recoveryActive()) return void reply({ t: "budget", ok: false, error: "recovery is holding mutations" });
6516
6828
  if (msg.resume) {
@@ -6519,9 +6831,9 @@ export async function startDaemon(opts: DaemonOptions) {
6519
6831
  const used = Number(msg.set.used);
6520
6832
  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
6833
  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" }]);
6834
+ 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
6835
  }
6524
- return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
6836
+ return void reply({ t: "budget", ok: true, budget: budget.status(), peers: budgetPeerNotes(), kimiSource: kimiUsage.status(), gate: config.budget.gate });
6525
6837
  case "usage": {
6526
6838
  // #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
6527
6839
  const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
@@ -6626,6 +6938,7 @@ export async function startDaemon(opts: DaemonOptions) {
6626
6938
  executionBudget.close();
6627
6939
  syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
6628
6940
  await syncRun?.catch(() => undefined);
6941
+ memory.close();
6629
6942
  candidates?.close();
6630
6943
  memoryStore?.close();
6631
6944
  board.close();