@staix/agent-hub 0.34.1 → 0.35.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 (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +35 -6
  3. package/docs/adding-an-ai-tool.md +2 -2
  4. package/docs/addons/claude-mem.md +4 -4
  5. package/docs/addons/open-code-review.md +1 -1
  6. package/docs/agent-notes/README.md +6 -0
  7. package/docs/agent-notes/adapters.md +9 -5
  8. package/docs/agent-notes/budget.md +1 -1
  9. package/docs/agent-notes/bus.md +1 -1
  10. package/docs/agent-notes/daemon.md +4 -3
  11. package/docs/agent-notes/local-worker.md +1 -1
  12. package/docs/agent-notes/models.md +2 -0
  13. package/docs/agent-notes/tasks.md +1 -1
  14. package/docs/agent-notes/tests.md +5 -0
  15. package/docs/design/adapters.md +110 -2
  16. package/docs/design/agent-hub-architecture.md +63 -1
  17. package/docs/design/agent-hub.md +6 -0
  18. package/docs/design/bus.md +18 -0
  19. package/docs/design/execution-reference.md +146 -0
  20. package/docs/design/task-board.md +10 -0
  21. package/docs/dogfood.md +33 -12
  22. package/docs/events.md +38 -20
  23. package/docs/memory.md +21 -7
  24. package/docs/operations.md +539 -24
  25. package/docs/quickstart.md +12 -4
  26. package/docs/research.md +71 -13
  27. package/docs/roles.md +1 -1
  28. package/docs/sdlc.md +9 -7
  29. package/docs/security.md +87 -11
  30. package/docs/smoke.md +14 -0
  31. package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
  32. package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
  33. package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
  34. package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
  35. package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
  36. package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
  37. package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
  38. package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
  39. package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
  40. package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
  41. package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
  42. package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
  43. package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
  44. package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
  45. package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
  46. package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
  47. package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
  48. package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
  49. package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
  50. package/docs/verification/2026-11-09-0.35.0.md +129 -0
  51. package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
  52. package/docs/verification/claude-permission-transport-native.py +10 -3
  53. package/docs/verification/claude-shell-wrapper-native.py +8 -8
  54. package/docs/verified.json +245 -15
  55. package/package.json +3 -3
  56. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  57. package/plugins/agent-hub/kimi-hook.js +20397 -0
  58. package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
  59. package/plugins/agent-hub/server.js +2701 -664
  60. package/scripts/check-docs.mjs +12 -1
  61. package/src/adapters/acp-model-policy.ts +56 -0
  62. package/src/adapters/acp.ts +175 -5
  63. package/src/adapters/agy/entry.ts +8 -5
  64. package/src/adapters/agy/hooks.ts +65 -15
  65. package/src/adapters/channel-peer.ts +33 -1
  66. package/src/adapters/claude-implementer.ts +16 -3
  67. package/src/adapters/codex-appserver.ts +118 -8
  68. package/src/adapters/codex-review-isolation.ts +3 -1
  69. package/src/adapters/compat.ts +11 -6
  70. package/src/adapters/definitions/builtin-metadata.ts +8 -1
  71. package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
  72. package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
  73. package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
  74. package/src/adapters/definitions/reviewer-variants.ts +3 -1
  75. package/src/adapters/kimi-review-policy-worker.js +2 -0
  76. package/src/adapters/kimi-review-policy-worker.ts +85 -0
  77. package/src/adapters/kimi-review-policy.ts +68 -0
  78. package/src/adapters/kimi-review.ts +41 -41
  79. package/src/adapters/kimi-tui-facts.ts +153 -0
  80. package/src/adapters/kimi-wire-usage.ts +33 -8
  81. package/src/adapters/local-worker.ts +59 -20
  82. package/src/adapters/model-token.ts +3 -0
  83. package/src/adapters/pi.ts +27 -5
  84. package/src/adapters/registry-types.ts +34 -3
  85. package/src/adapters/registry.ts +1 -0
  86. package/src/adapters/reviewer-policy-watch.ts +86 -0
  87. package/src/adapters/reviewer-print.ts +80 -18
  88. package/src/cli/agents.ts +1 -1
  89. package/src/cli/facts-hook.ts +4 -2
  90. package/src/cli/help.ts +3 -2
  91. package/src/cli/identity.ts +3 -0
  92. package/src/cli/kimi-facts-hook.ts +11 -0
  93. package/src/cli/kimi-plugin.ts +157 -0
  94. package/src/cli/main.ts +48 -9
  95. package/src/cli/model.ts +52 -0
  96. package/src/cli/output.ts +23 -1
  97. package/src/cli/reset.ts +1 -1
  98. package/src/cli/status-lines.ts +8 -4
  99. package/src/hub/budget.ts +1 -1
  100. package/src/hub/bus.ts +40 -2
  101. package/src/hub/code-facts.ts +10 -3
  102. package/src/hub/completions.ts +3 -1
  103. package/src/hub/config-apply.ts +2 -1
  104. package/src/hub/control-client.ts +1 -1
  105. package/src/hub/daemon.ts +1050 -106
  106. package/src/hub/deliverable-files.ts +38 -5
  107. package/src/hub/dogfood-profile.ts +1 -1
  108. package/src/hub/envelope.ts +1 -1
  109. package/src/hub/events.ts +28 -18
  110. package/src/hub/execution-lineage.ts +120 -0
  111. package/src/hub/execution-reference.ts +36 -0
  112. package/src/hub/hub-tools.ts +21 -2
  113. package/src/hub/instances.ts +4 -3
  114. package/src/hub/kimi-events.ts +46 -0
  115. package/src/hub/kimi-usage.ts +132 -29
  116. package/src/hub/lifecycle.ts +31 -15
  117. package/src/hub/limits.ts +13 -4
  118. package/src/hub/manager.ts +15 -2
  119. package/src/hub/memory-policy.ts +2 -1
  120. package/src/hub/memory-sync-attestation.ts +54 -0
  121. package/src/hub/memory-sync-state.ts +20 -5
  122. package/src/hub/model-overrides.ts +115 -0
  123. package/src/hub/native-hook-launch.ts +12 -3
  124. package/src/hub/operational-view.ts +2 -2
  125. package/src/hub/peers.ts +27 -0
  126. package/src/hub/permission-mode.ts +1 -1
  127. package/src/hub/person-figures.ts +88 -0
  128. package/src/hub/report.ts +38 -4
  129. package/src/hub/research-dataset.ts +158 -0
  130. package/src/hub/research-memory.ts +68 -0
  131. package/src/hub/research-quality.ts +118 -0
  132. package/src/hub/research-record.ts +279 -0
  133. package/src/hub/research.ts +126 -27
  134. package/src/hub/restart.ts +14 -0
  135. package/src/hub/settings.ts +3 -0
  136. package/src/hub/startup-event-seeds.ts +51 -0
  137. package/src/hub/startup-readiness.ts +65 -0
  138. package/src/hub/startup-timing.ts +38 -0
  139. package/src/hub/tasks.ts +29 -9
  140. package/src/hub/ui.ts +9 -5
  141. package/src/hub/visual-assets.ts +50 -0
  142. package/src/hub/visual.ts +257 -0
  143. package/src/memory/brief.ts +169 -14
  144. package/src/memory/capture.ts +2 -1
  145. package/src/memory/recall.ts +46 -5
  146. package/src/memory/record-provenance.ts +82 -0
  147. package/src/memory/records.ts +116 -0
  148. package/src/models/route/adec.ts +190 -0
  149. package/src/models/route/config.ts +37 -3
  150. package/src/models/route/decision-evidence.ts +83 -0
  151. package/src/models/route/decision.ts +143 -0
  152. package/src/models/route/runtime.ts +13 -6
  153. package/src/ui/index.html +134 -6
  154. package/src/ui/plugin.ts +65 -4
  155. package/templates/AGENTS.block.md +1 -1
  156. package/templates/config.json +1 -1
  157. package/templates/profiles/general.json +1 -1
  158. package/templates/profiles/research.json +1 -1
  159. package/templates/profiles/writing.json +1 -1
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import { GeneratedNoteRegistry, type GeneratedNoteProof } from "../memory/record-provenance.ts";
2
+ import { RecordCapture, type RecordCapturePolicy } from "../memory/records.ts";
3
+ import { VisualStore, validateVisualInput, validateVisualPayload, VISUAL_ID_PATTERN, type VisualRecord, type VisualSummary } from "./visual.ts";
4
+ import { resolveVisualAssets } from "./visual-assets.ts";
5
+ import type { DeliverablePorts } from "./deliverable-files.ts";
6
+ import { readStartupEventSeeds } from "./startup-event-seeds.ts";
7
+ import { StartupTimings } from "./startup-timing.ts";
1
8
  import { repoGitArgs, repoGitEnv } from "./snapshots.ts";
2
9
  import { createReviewMaterialReader, type ReviewReadRequest, type ReviewMaterialBatch } from "./review-material.ts";
3
10
  import { readSdlcPr } from "./sdlc.ts";
@@ -35,7 +42,7 @@ import type { DocEvidence } from "./sdlc-release.ts";
35
42
  import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, externalModuleIdentity, externalModuleIdentityCurrent, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
36
43
  import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
37
44
  import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
38
- import { NativeHookLaunches } from "./native-hook-launch.ts";
45
+ import { NativeHookLaunches, type NativeHookLaunch } from "./native-hook-launch.ts";
39
46
  import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
40
47
  import { ContainerRunner, RemoteRunner, remoteRunnerConfig, type RemoteRunnerConfig, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
41
48
  import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
@@ -56,7 +63,7 @@ import { listenWithoutPortCollision } from "./listen.ts";
56
63
  import { needs, type NeedsInput } from "../ui/needs.ts";
57
64
  import { loadPetConfig, PET_WINDOW_RETIRED_NOTICE, stripLegacyPetWindow } from "../pet/config.ts";
58
65
  import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
59
- import { policyPaths, matchesPolicy } from "./path-policy.ts";
66
+ import { policyPaths, policyRealPath, matchesPolicy } from "./path-policy.ts";
60
67
  import { CodeFacts, type CodeFactResult, type CodeSymbol } from "./code-facts.ts";
61
68
  import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
62
69
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
@@ -75,6 +82,7 @@ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
75
82
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
76
83
  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";
77
84
  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";
85
+ import type { NativeFactsTranscript } from "../adapters/registry-types.ts";
78
86
  import type { ReviewScope } from "./review-scope.ts";
79
87
  import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, isPlannerOnly, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
80
88
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, withoutInertInstancePolicy, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
@@ -105,7 +113,7 @@ import { QuestionDesk } from "./question-desk.ts";
105
113
  import type { ScannedFile } from "./schedules.ts";
106
114
  import { corpusConfig } from "./corpus-config.ts";
107
115
  import { ALL_MACHINE_LOCAL, configRefusal, configRepositoryPresent, configTracked, stripUntrusted } from "./config-trust.ts";
108
- import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
116
+ import { authorizeConfigLeaf, configApply, configIdentityScope, configChanges, validateRawConfig, validateEffectiveConfig, validateOperationalConfig, validatePetConfig } from "./config-apply.ts";
109
117
  import { resolveSettledRoles } from "./settled-roles.ts";
110
118
  import { CONFIG_SOURCE_FILES, configFingerprint, configSourceTexts, parseConfigSources, type ConfigSourceTexts, type ConfigSources } from "./config-reload.ts";
111
119
  import { issueConductorRecoveryAuthority, authorizeConductorRecovery, assertConductorRecovery, RecoveryAuthorityError, type ConductorRecoveryBinding } from "./recovery-authority.ts";
@@ -116,9 +124,10 @@ import { planFingerprint, registeredProjects, type UpgradePlan } from "../cli/up
116
124
  import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
117
125
  import { createConfigEditor } from "./config-edit-host.ts";
118
126
  import { readEditable } from "./config-edit.ts";
119
- import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
127
+ import { eventLog, readEvents, type HubEvent, type RoleIdentity } from "./events.ts";
120
128
  import { SpecTaskInspector } from "./specs-task.ts";
121
- import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
129
+ import { KimiUsage, kimiSeedRows, type KimiRowAttribution, type KimiUsageScheduler } from "./kimi-usage.ts";
130
+ import { KimiEventWriter } from "./kimi-events.ts";
122
131
  import { TokenWindow } from "./token-window.ts";
123
132
  import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
124
133
  import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
@@ -133,6 +142,8 @@ import { startModelRelay, type ModelRelay } from "../models/relay.ts";
133
142
  import { ensureMlx, type MlxOptions } from "../models/mlx.ts";
134
143
  import { PiToolReceipts, packPiShellResult } from "../pi/tool-receipts.ts";
135
144
  import { PreEffectToolRefusal } from "./tool-refusal.ts";
145
+ import { ModelOverrides, ModelOverrideLatches, type RuntimeModelStatus, type ModelSource } from "./model-overrides.ts";
146
+ import { isNativeModelToken } from "../adapters/model-token.ts";
136
147
  import { processSignature, processLiveness } from "../pi/process-signature.ts";
137
148
  import { PiSessionBookmarkMismatch, PiSessionUnresumable, recordPiReplacement, validatePiSession } from "../pi/session.ts";
138
149
  import { profile, proxyEnv, sandboxAvailable, type SandboxNetwork } from "../local/sandbox.ts";
@@ -156,7 +167,7 @@ import { Capture } from "../memory/capture.ts";
156
167
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
157
168
  import { CorpusIndex } from "./corpus.ts";
158
169
  import { corpusEvidence } from "./corpus-evidence.ts";
159
- import { Briefs, type BriefTask } from "../memory/brief.ts";
170
+ import { Briefs, preferRecordItems, type NativeItem, type BriefTask } from "../memory/brief.ts";
160
171
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
161
172
  import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
162
173
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
@@ -200,12 +211,14 @@ import { ControlClient, DAEMON_FEATURES, HANDOFF_CONNECT_MS, HANDOFF_LOOKUP_CONN
200
211
  import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
201
212
  import { attachEvent, originOf, type Origin } from "./provenance.ts";
202
213
  import { mintSessionId, sessionGate } from "./session-identity.ts";
203
- import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
214
+ import { MAX_HOP, isInvocationKind, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
204
215
  import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter, type PeerTakeoverClaim } from "./peers.ts";
205
216
  import { ClaudeTodoSource, DoingTracker, readAiTitle, DOING_STUB, SESSION_TITLE_HINT, sessionTitleText, sessionTitleBindings, unnamedSessionLabel, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
206
217
  import { MemoryClient } from "../memory/client.ts";
207
218
  import { VERSION } from "../version.ts";
208
219
  import { appendRecords, taskRecords } from "./research.ts";
220
+ import { readEvidence } from "./research-record.ts";
221
+ import { memoryFigures, onlyTaskMemory, UNKNOWN_MEMORY_FIGURES, type MemoryFigures } from "./research-memory.ts";
209
222
  import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
210
223
  import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
211
224
  import { projectChain } from "@staix/agent-mem";
@@ -225,11 +238,11 @@ import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
225
238
  import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
226
239
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
227
240
  import type { TerminalOpener } from "./terminal-open.ts";
228
- import { grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
241
+ import { isHubWritePath, grantablePath, instructionProtection, isPermissionMode, permissionBoundary, permissionDefaults, permissionGrant, PI_EDIT_TOOLS, type PermissionMode } from "./permission-mode.ts";
229
242
  import { ContextWindows, DEFAULT_CONTEXT, readNativeContext, type ContextConfig } from "./context-window.ts";
230
243
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
231
244
  import { detectTooling } from "./tooling.ts";
232
- import { looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
245
+ import { CaptureStore, configuredPolicy, recordsEnabled, looksSecret, DEFAULT_LEASE_MS, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
233
246
  import { memoryProjectKey } from "@staix/agent-mem";
234
247
  import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
235
248
  import { syncMemory, createIntentBriefSource, createStandalonePolicy } from "@staix/agent-mem";
@@ -237,7 +250,8 @@ import { SourceRegistry } from "./sources.ts";
237
250
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
238
251
  import { nativeRecall } from "../memory/recall.ts";
239
252
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
240
- import { classifyMemorySyncFailure, initialMemorySyncState, memorySyncDeclinedDetails, memorySyncFailureDetailHash, memorySyncView, memorySyncPlacementCounts, reduceMemorySync } from "./memory-sync-state.ts";
253
+ import { hostPlacementAttestation, memorySyncErrorText, writerRefusalOf, type WriterRefusal } from "./memory-sync-attestation.ts";
254
+ import { classifyMemorySyncFailure, initialMemorySyncState, memorySyncDeclinedDetails, memorySyncFailureDetailHash, memorySyncView, memorySyncNextStep, memorySyncPlacementCounts, reduceMemorySync } from "./memory-sync-state.ts";
241
255
  import { createMemorySyncPolicy, inspectDeclinedMemoryRows } from "./memory-sync-policy.ts";
242
256
  import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
243
257
 
@@ -307,6 +321,8 @@ export interface HubConfig {
307
321
  checks: { timeout_s: number; runner?: "container" | "remote"; remote?: RemoteRunnerConfig; [cls: string]: string | number | RemoteRunnerConfig | undefined };
308
322
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
309
323
  snapshots: { enabled: boolean; keep: number };
324
+ /** Unbound visuals expire; task-bound records retain the task lifetime. */
325
+ visuals: { retention_days: number };
310
326
  /** #1057: the worktree shelf. `quiet_days` shelves a worktree idle that long (0 = never); `alert_over` is the settled count that raises one inbox alert (0 = never). */
311
327
  worktrees: { quiet_days: number; alert_over: number };
312
328
  /** #1053: `ttl_s` expires an unanswered question (default 86400); `wait_ms` overrides an adapter definition's `questionWaitMs` per kind. */
@@ -385,6 +401,7 @@ export const DEFAULT_CONFIG: HubConfig = {
385
401
  snapshots: { enabled: false, keep: 20 },
386
402
  worktrees: { quiet_days: 7, alert_over: 10 },
387
403
  questions: { ttl_s: 86_400, wait_ms: {} },
404
+ visuals: { retention_days: 30 },
388
405
  research: { enabled: false },
389
406
  bench: { enabled: false },
390
407
  peers: {},
@@ -400,14 +417,14 @@ export const DEFAULT_CONFIG: HubConfig = {
400
417
  export { stateDirFor };
401
418
 
402
419
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
403
- const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
420
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_visual_show", "hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
404
421
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
405
422
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
406
423
 
407
424
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
408
425
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
409
426
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
410
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "worktrees", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet", "questions"];
427
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "worktrees", "visuals", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet", "questions"];
411
428
 
412
429
  /** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
413
430
  function rawResearchRunnersPresent(cwd: string): boolean {
@@ -424,6 +441,18 @@ function rawResearchRunnersPresent(cwd: string): boolean {
424
441
  return present;
425
442
  }
426
443
 
444
+ /** Raw declaration presence is the floor: defaults in the in-memory config are not an opt-in. */
445
+ function rawVisualsPresent(cwd: string): boolean {
446
+ const texts = configSourceTexts(cwd);
447
+ for (const name of CONFIG_FILES) {
448
+ if (texts[name] === null) continue;
449
+ const raw: unknown = JSON.parse(texts[name]!);
450
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
451
+ if (Object.hasOwn(raw, "visuals")) return true;
452
+ }
453
+ return false;
454
+ }
455
+
427
456
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
428
457
  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[] } {
429
458
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
@@ -650,6 +679,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
650
679
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
651
680
  worktrees: { quiet_days: wholeDays(file.worktrees?.quiet_days, DEFAULT_CONFIG.worktrees.quiet_days), alert_over: wholeDays(file.worktrees?.alert_over, DEFAULT_CONFIG.worktrees.alert_over) },
652
681
  questions: questionsConfig(file.questions),
682
+ visuals: { retention_days: typeof file.visuals?.retention_days === "number" && Number.isSafeInteger(file.visuals.retention_days) && file.visuals.retention_days >= 1 && file.visuals.retention_days <= 3650 ? file.visuals.retention_days : DEFAULT_CONFIG.visuals.retention_days },
653
683
  research: { enabled: file.research?.enabled === true, ...(file.research?.runners !== undefined ? { runners: parseResearchRunners(file.research.runners) } : {}) }, // opt-in: anything but true is off
654
684
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
655
685
  peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
@@ -686,6 +716,10 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
686
716
  }
687
717
 
688
718
  export interface DaemonOptions {
719
+ /** Host-only fixture override; production reads the active memory home's and execution repository's public Record policy. */
720
+ effectiveRecordPolicy?: (cwd: string) => { enabled: boolean; version: string } | undefined;
721
+ /** Host-only numeric startup reader observation; never read from config or control input. Observer failure changes no replay state. */
722
+ startupSeedObserved?: (observation: { readPasses: number; readBytes: number }) => void;
689
723
  /** Host-only test/embedding port; never read from project configuration or a tool. */
690
724
  memoryClient?: MemoryClient;
691
725
  /** Host-only remote-default evidence seam; never read from config or control messages. */
@@ -710,6 +744,8 @@ export interface DaemonOptions {
710
744
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
711
745
  /** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
712
746
  kimiHome?: string;
747
+ /** Host-only delayed-drain scheduler; tests fire it explicitly. Production leaves it unset and uses unreferenced timers. */
748
+ kimiUsageScheduler?: KimiUsageScheduler;
713
749
  /** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
714
750
  toolWriteReplayMaxEntries?: number;
715
751
  /** Host-only evidence seam; never supplied by project config or control messages. */
@@ -863,13 +899,16 @@ function capturedRecoverySourceDigest(): string | undefined {
863
899
  }
864
900
 
865
901
  export async function startDaemon(opts: DaemonOptions) {
902
+ const startupTimings = new StartupTimings();
866
903
  // Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
867
904
  assertHubDbCompatible(join(opts.stateDir, "hub.db"));
905
+ startupTimings.mark("schema");
868
906
  if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
869
907
  const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
870
908
  resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
871
909
  const projectId = opts.projectId ?? randomUUID();
872
910
  const sourcePackageDigest = capturedRecoverySourceDigest();
911
+ startupTimings.mark("source");
873
912
  const instanceId = opts.instanceId ?? randomUUID();
874
913
  const startupCleanup: (() => void | Promise<void>)[] = [];
875
914
  let ready = false;
@@ -1121,6 +1160,7 @@ export async function startDaemon(opts: DaemonOptions) {
1121
1160
  let publicLine:string|undefined,view:ReturnType<typeof operationalView>|undefined;
1122
1161
  for(const [sock,current] of operationalLogSubscribers)try {current();if(sock.getBufferedAmount()>64*1024)throw new Error("slow log consumer");view??=publicOperationalView();publicLine??=view.line(text);sock.send(JSON.stringify({t:"operational_log",line:publicLine,complete:view.complete,...(!view.complete?{incompleteReason:view.incompleteReason}:{})}));}catch{operationalLogSubscribers.delete(sock);try{sock.close(4403,"operational log authority ended");}catch{/* The subscription is already gone. */}}
1123
1162
  };
1163
+ startupTimings.mark("configuration");
1124
1164
  let runningRoutingDefault = recoveryDefaultRouting;
1125
1165
  if (runningRoutingDefault === undefined) try { runningRoutingDefault = recoveryRoutingFallback(); } catch { /* unknown defaults refuse conductor recovery */ }
1126
1166
  let runningRouting = recoveryRouting ?? currentRouting(opts.cwd, log);
@@ -1242,12 +1282,42 @@ export async function startDaemon(opts: DaemonOptions) {
1242
1282
  // first writes once its peers have stopped and their turns ended.
1243
1283
  const researchGraceMs = opts.researchGraceMs ?? 10_000;
1244
1284
  const researchPending = new Map<number, { since: number; timer: ReturnType<typeof setTimeout> }>();
1285
+ /**
1286
+ * #1279: the counter figures of one task, read from agent-mem's sidecar at the moment the record is written (the sidecar, the store and the memory project are bound further down, and exist by
1287
+ * then). Undefined when memory or the sidecar is off, so the record's counters stay unknown; a task that is private reports none, whatever the sidecar holds.
1288
+ */
1289
+ const memoryFiguresFor = (taskId: number): MemoryFigures => {
1290
+ const row = board.get(taskId);
1291
+ if (!candidates || !memoryStore || !row || tasks.isPii(row)) return { ...UNKNOWN_MEMORY_FIGURES };
1292
+ try {
1293
+ const store = memoryStore, project = memoryProject;
1294
+ const planContext = { cwd: opts.cwd, project, repo: memoryRepo, deny: config.local.deny };
1295
+ const briefTask = { id: row.id, created: row.created, title: "", refs: { paths: row.refs.paths ?? [] }, plan: { paths: row.plan?.paths ?? [], symbols: row.plan?.symbols ?? [] } };
1296
+ // The store has no read by id (`get` and `list` each rebuild the whole live view), so it is listed at most once per record, and only when the task has a rendered counter at all: a task with
1297
+ // none never touches it. Only the rendered entries are screened, not the store. A hidden or unreadable entry is simply absent, and the figures then call their fail counts unknown.
1298
+ let listed: Map<string, ReturnType<MemoryStore["list"]>[number]> | undefined;
1299
+ const view = (id: string) => {
1300
+ listed ??= new Map(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map(v => [v.id, v]));
1301
+ const found = listed.get(id);
1302
+ return found ? screenMemory([found])[0] : undefined;
1303
+ };
1304
+ return memoryFigures({
1305
+ counters: candidates.counters(project),
1306
+ taskKey: taskKey(project, row),
1307
+ view,
1308
+ matchingPlan: fails => matchingPlanFails(briefTask, fails, planContext),
1309
+ });
1310
+ } catch { return { ...UNKNOWN_MEMORY_FIGURES }; }
1311
+ };
1245
1312
  const writeResearch = (taskId: number) => {
1246
1313
  const pending = researchPending.get(taskId);
1247
1314
  if (pending) clearTimeout(pending.timer);
1248
1315
  researchPending.delete(taskId);
1249
1316
  try {
1250
- appendRecords(projectId, taskRecords(readEvents(join(opts.stateDir, "events.jsonl")), projectId, { version: VERSION, source: "live" }).filter((r) => r.task === taskId));
1317
+ // #1277: the board and journal are read through a read-only handle, so the record carries its v2 blocks; the family lookup is the project's own (instances resolve there).
1318
+ // Scoped to the one task: the live writer runs on the daemon thread and must not parse every task's history and the whole journal for one record (a backfill reads them all).
1319
+ const evidence = { ...readEvidence(opts.stateDir, projectId, taskId), familyOf: (peer: string) => adapterCatalog.familyOf(peer, declarations), memory: onlyTaskMemory(taskId, memoryFiguresFor) };
1320
+ appendRecords(projectId, taskRecords(readEvents(join(opts.stateDir, "events.jsonl")), projectId, { version: VERSION, source: "live" }, evidence).filter((r) => r.task === taskId));
1251
1321
  } catch (error) {
1252
1322
  if (!researchLogged) log(`research record not written: ${(error as Error).message}`);
1253
1323
  researchLogged = true;
@@ -1298,7 +1368,13 @@ export async function startDaemon(opts: DaemonOptions) {
1298
1368
  sock.data.closed = true;
1299
1369
  for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
1300
1370
  sock.data.invocationRequests?.clear();
1301
- nativeHookLaunches.endOwner(sock);
1371
+ for (const { peer } of nativeHookLaunches.endOwner(sock)) {
1372
+ const run = runs.get(peer);
1373
+ if (!run) continue;
1374
+ if (budget.record(peer)) completionStore.dropRun(run.runTurn);
1375
+ else completionDone(peer, run, "interrupted");
1376
+ runs.close(peer);
1377
+ }
1302
1378
  if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
1303
1379
  consoles.delete(sock);
1304
1380
  operationalLogSubscribers.delete(sock);
@@ -1321,8 +1397,9 @@ export async function startDaemon(opts: DaemonOptions) {
1321
1397
  }), opts.controlPort === 0 ? [opts.codexAppPort, opts.codexProxyPort] : []);
1322
1398
  // Written once this run owns the port, before any connection can attach a peer: a daemon that fails to bind (another
1323
1399
  // is running) never marks a run boundary in the live one's events.
1324
- event({ type: "hub_start", instance: instanceId });
1400
+ event({ type: "hub_start", instance: instanceId, personAttribution: true });
1325
1401
  startupCleanup.push(() => server.stop(true));
1402
+ startupTimings.mark("control");
1326
1403
 
1327
1404
 
1328
1405
  const recoveryOperation = process.env.AGENTHUB_RECOVERY_OPERATION;
@@ -1374,12 +1451,14 @@ export async function startDaemon(opts: DaemonOptions) {
1374
1451
  const { terminalHistoryLimit, ...busReaders } = busOperationalReaders(() => config);
1375
1452
  const adapterTiming = adapterOperationalReaders(() => config);
1376
1453
  const modelTiming = modelOperationalReaders(() => config);
1454
+ startupTimings.mark("recovery");
1377
1455
  const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
1378
1456
  // What the crash left in flight, per recipient: opening the journal just marked these needs_review.
1379
1457
  const lost = new Map<PeerId, JournalDelivery[]>();
1380
1458
  if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) { lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]); crashLostIds.add(d.id); }
1381
1459
  // Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
1382
1460
  // A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
1461
+ startupTimings.mark("journal");
1383
1462
  const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
1384
1463
  if (peer === USER || peer === HUB || !Object.hasOwn(config.capabilities, peer)) return true;
1385
1464
  const list = config.capabilities[peer];
@@ -1769,6 +1848,7 @@ export async function startDaemon(opts: DaemonOptions) {
1769
1848
  }, delayMs);
1770
1849
  recoveryLeaseTimer.unref?.();
1771
1850
  };
1851
+ startupTimings.mark("bus");
1772
1852
  const chain = projectChain(opts.cwd);
1773
1853
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
1774
1854
  // #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
@@ -1862,7 +1942,10 @@ export async function startDaemon(opts: DaemonOptions) {
1862
1942
  publicText: text => !looksSecret(text) && withoutPii([{ title: "", text }], activeRouting(), opts.cwd, config.pii?.paths ?? []).length === 1,
1863
1943
  capture: () => ({ memoryDir: captureBinding!.home }), log,
1864
1944
  });
1945
+ // ponytail: generated-note proofs are bounded to this daemon lifetime; after restart, unknown notes remain untouched.
1946
+ const generatedRecordNotes = new GeneratedNoteRegistry();
1865
1947
  const memoryTelemetry = candidates && memoryStore ? new MemoryTelemetry(candidates, memoryStore, memoryProject, screenMemory, checkMemory) : undefined;
1948
+ startupTimings.mark("memory");
1866
1949
  const staleness = () => createStalenessProbe({ cwd: opts.cwd, privatePaths: config.pii?.paths ?? [], deny: config.local.deny });
1867
1950
  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)}`;
1868
1951
  /** A task's own nodes: what its title names (an issue, a path), its refs and plan paths, plan symbols, and the task itself. */
@@ -2033,18 +2116,23 @@ export async function startDaemon(opts: DaemonOptions) {
2033
2116
  /** Machine sync, when a person set a remote; one log line per failure, never a blocked turn. Stopping aborts and awaits it. */
2034
2117
  function syncNow(): Promise<void> {
2035
2118
  if (!memoryStore || syncRun || stopping) return syncRun ?? Promise.resolve();
2036
- const recordFailure = (cause: ReturnType<typeof classifyMemorySyncFailure> & { declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false }, errorText?: string) => {
2037
- if (quietSyncError(errorText)) {
2119
+ let attestation: ReturnType<typeof hostPlacementAttestation> | undefined;
2120
+ const recordFailure = (cause: ReturnType<typeof classifyMemorySyncFailure> & { declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false; writerCategory?: string; writerCauseClass?: string }, errorText?: string) => {
2121
+ if (cause.cause !== "writer" && quietSyncError(errorText)) {
2038
2122
  const state = errorText?.startsWith("stopped") ? "stopped" : "not-configured";
2039
2123
  memorySyncState = reduceMemorySync(memorySyncState, { type: "quiet", at: Date.now(), state }).state;
2040
2124
  return;
2041
2125
  }
2042
2126
  const reduced = reduceMemorySync(memorySyncState, { type: "failure", at: Date.now(), ...cause });
2043
2127
  memorySyncState = reduced.state;
2044
- if (reduced.logFailure && !quietSyncError(errorText)) log(`hub memory sync failed: ${cause.cause}${cause.cause === "declined" ? ` (${cause.declinedCount ?? 0} declined)` : ""}`);
2128
+ if (reduced.logFailure) log(`hub memory sync failed: ${cause.cause}${cause.cause === "declined" ? ` (${cause.declinedCount ?? 0} declined)` : cause.cause === "writer" ? ` (${cause.writerCategory ?? "unknown"}, ${cause.writerCauseClass ?? "unavailable"}); next: ${memorySyncNextStep("writer")}` : ""}`);
2045
2129
  };
2130
+ /** #1173: a refused writer attestation is its own closed cause with its category and next step, never a silent skip or a generic unknown. */
2131
+ const writerFailure = (refusal: WriterRefusal) => ({ cause: "writer" as const, writerCategory: refusal.category, writerCauseClass: refusal.causeClass });
2046
2132
  const recordUnexpectedFailure = (error: unknown) => {
2047
- const errorText = typeof error === "string" ? error : error instanceof Error ? error.message : undefined;
2133
+ const refusal = attestation?.refusal() ?? writerRefusalOf(error);
2134
+ if (refusal) { recordFailure(writerFailure(refusal), undefined); return; }
2135
+ const errorText = memorySyncErrorText(error);
2048
2136
  if (quietSyncError(errorText)) {
2049
2137
  recordFailure(classifyMemorySyncFailure(error), errorText);
2050
2138
  return;
@@ -2056,11 +2144,16 @@ export async function startDaemon(opts: DaemonOptions) {
2056
2144
  checkMemory();
2057
2145
  const policy = currentMemorySyncPolicy();
2058
2146
  const policyVersion = memorySyncPolicyVersion();
2147
+ // Fresh per pass: agent-mem re-measures every writer install on each check; the legacy transport ignores it.
2148
+ attestation = hostPlacementAttestation(memoryStore!.profile.home);
2059
2149
  const r = await (opts.memorySync ?? syncMemory)(memoryStore!, {
2060
2150
  signal: syncStop.signal,
2151
+ placementAttestation: attestation.attest,
2061
2152
  screen: row => policy.screen(row, true).ok,
2062
2153
  policyVersion: () => memorySyncPolicyVersion(),
2063
2154
  });
2155
+ const refused = attestation.refusal();
2156
+ if (refused) { recordFailure(writerFailure(refused)); return; }
2064
2157
  if (memorySyncPolicyVersion() !== policyVersion) {
2065
2158
  recordFailure({ cause: "binding" }, "binding changed");
2066
2159
  return;
@@ -2108,7 +2201,14 @@ export async function startDaemon(opts: DaemonOptions) {
2108
2201
  own = memoryStore ? nativeRecall([...goals, ...recallMemoryLines(screenMemory(memoryStore.list({ project: memoryProject, tier: "long", limit: Number.MAX_SAFE_INTEGER }).filter((v) => v.kind !== "finding")), 30, staleness())], config.memory.enabled ? Math.floor(config.memory.inject_tokens / 2) : config.memory.inject_tokens, (line) => sanitize(line.replace(/\s+/g, " "))) : undefined;
2109
2202
  }
2110
2203
  catch (e) { log(`hub memory recall failed: ${(e as Error).message}`); }
2111
- const theirs = config.memory.enabled ? await recallFor(peer, memory, chain, own ? Math.ceil(config.memory.inject_tokens / 2) : config.memory.inject_tokens) : undefined;
2204
+ const recordRecallTask: BriefTask = { owner: peer, title: "", refs: {} };
2205
+ const recordRecallAllowed = () => !reviewOnlyPeer(peer) && !contextPii(peer) && !reviewObserverInput(peer) && recordReadAllowed(undefined, recordReadCwd(recordRecallTask));
2206
+ const recordRecallTokens = own ? Math.ceil(config.memory.inject_tokens / 2) : config.memory.inject_tokens;
2207
+ const theirs = config.memory.enabled ? await recallFor(peer, memory, chain, recordRecallTokens, {
2208
+ allowed: recordRecallAllowed, policyVersion: () => recordReadVersion(recordRecallTask),
2209
+ project: original => projectRecordRecall(peer, original, recordRecallTokens),
2210
+ observe: counts => { if (recordRecallAllowed()) log(`records recall before=${counts.beforeBytes} after=${counts.afterBytes}`); },
2211
+ }) : undefined;
2112
2212
  const block = [own, theirs].filter(Boolean).join("\n\n");
2113
2213
  if (!block || recalled.has(peer)) return; // nothing yet (worker down, no memory): the peer's next session tries again
2114
2214
  recalled.add(peer);
@@ -2162,7 +2262,13 @@ export async function startDaemon(opts: DaemonOptions) {
2162
2262
  try { notify(`conductor ${action.actor}: ${action.action}${action.task === undefined ? "" : ` #${action.task}`}${action.peer ? ` ${action.peer}` : ""}`); } catch { /* never throw after a task/hold write */ }
2163
2263
  };
2164
2264
  const inboxPolling = new InboxPolling();
2265
+ startupTimings.mark("model_runtime");
2165
2266
  const board = new Board(join(opts.stateDir, "hub.db"));
2267
+ const visuals = new VisualStore(join(opts.stateDir, "hub.db"));
2268
+ startupCleanup.push(() => visuals.close());
2269
+ // Held recovery must compare the source's exact rows before any retention mutation.
2270
+ if (!recoveryActive() && !bus.isRecoveryHeld) visuals.prune(config.visuals.retention_days, Date.now(), task => !!board.get(task));
2271
+ startupTimings.mark("board");
2166
2272
  const reviewInputHeld = (peer: PeerId, envs: readonly Envelope[]): boolean => {
2167
2273
  if (envs.some(isReviewObserverExcluded)) return true;
2168
2274
  const taskIds = envs.flatMap(env => {
@@ -2397,6 +2503,12 @@ export async function startDaemon(opts: DaemonOptions) {
2397
2503
  const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
2398
2504
  const memorySymbols = new Map<string, MemorySymbolNodes>();
2399
2505
  startupCleanup.push(() => board.close());
2506
+ const modelOverrides = new ModelOverrides(join(opts.stateDir, "hub.db"));
2507
+ const modelLatches = new ModelOverrideLatches(modelOverrides);
2508
+ const modelManagedOwners = new WeakSet<PeerAdapter>();
2509
+ const modelAcpOwners = new WeakMap<PeerAdapter, AcpPeer>();
2510
+ const modelLaunchModels = new WeakMap<PeerAdapter, string>();
2511
+ startupCleanup.push(() => modelOverrides.close());
2400
2512
  startupCleanup.push(() => codeFacts.close());
2401
2513
  const supervision = new SupervisionFeed({
2402
2514
  conductor: () => conductorPeer(config.roles) ?? undefined,
@@ -2838,6 +2950,9 @@ export async function startDaemon(opts: DaemonOptions) {
2838
2950
  {
2839
2951
  allowed: corpusBriefAllowed, policyVersion: corpusPolicyVersion,
2840
2952
  read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
2953
+ }, {
2954
+ allowed: recordReadAllowed, policyVersion: recordReadVersion, read: readRecordSnapshot,
2955
+ observe: (_peer, task, counts) => { const row = task === undefined ? undefined : board.get(task); if (row && recordReadAllowed(row)) log(`records brief before=${counts.beforeBytes} after=${counts.afterBytes} replaced=${counts.replacedNotes} records=${counts.records}`); },
2841
2956
  }),
2842
2957
  ...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
2843
2958
  const read = () => {
@@ -2851,7 +2966,7 @@ export async function startDaemon(opts: DaemonOptions) {
2851
2966
  };
2852
2967
  return controlWriteTelemetry ? controlWriteTelemetry.observe("memory", controlWritePrivacyGuard(true, true, undefined, task.id), read) : read();
2853
2968
  } } : {}),
2854
- ...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
2969
+ ...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onGeneratedNote: (proof: GeneratedNoteProof) => generatedRecordNotes.record(proof), onNote: (id: string) => void suggestEdges(id) } } : {}),
2855
2970
  notify,
2856
2971
  recordOverlap: (task, owner, others) => event({ type: "overlap", task, owner, others }),
2857
2972
  checkEnded: owner => settleCheck(owner), // #1054: a run waited on its owner's completion check
@@ -3134,14 +3249,29 @@ export async function startDaemon(opts: DaemonOptions) {
3134
3249
  // same peer cannot undo a settlement.
3135
3250
  const turnEnded = new Map<PeerId, number>();
3136
3251
  const nativeCompletedAt = new Map<PeerId, number>();
3137
- const nativeStopsSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(event => event.type === "native_turn_end" && event.id ? [event.id] : []));
3252
+ startupTimings.mark("task_runtime");
3253
+ const startupSeeds = readStartupEventSeeds(join(opts.stateDir, "events.jsonl"));
3254
+ try { opts.startupSeedObserved?.({ readPasses: startupSeeds.readPasses, readBytes: startupSeeds.readBytes }); } catch { /* Diagnostic observers cannot change replay or readiness. */ }
3255
+ const nativeStopsSeen = startupSeeds.nativeStopsSeen;
3256
+ startupTimings.mark("native_stop_seed");
3138
3257
  const activeAt = new Map<PeerId, number>();
3139
3258
  interface NativeTurnStart { at: number; baseline?: string; generation: string; session: string; launch: string }
3140
3259
  const nativeTurnStart = new Map<PeerId, NativeTurnStart>();
3141
3260
  const stopUnconfirmedNoticed = new WeakSet<NativeTurnStart>();
3142
3261
  const nativeStopWaits = new Map<PeerId, { start: NativeTurnStart; wait: ReturnType<typeof waitForClaudeCompletion> }>();
3143
- /** Every Claude hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
3262
+ /** Every Claude or managed native hook call's own start-up and the hub's time for it, summed per turn (issue #108): reported at Stop. */
3144
3263
  const hookStats = new Map<PeerId, { n: number; startupMs: number; hubMs: number; maxStartupMs: number }>();
3264
+ const tallyHook = (peer: PeerId, callStarted: number, startedMs: unknown) => {
3265
+ const st = hookStats.get(peer) ?? { n: 0, startupMs: 0, hubMs: 0, maxStartupMs: 0 };
3266
+ const startup = typeof startedMs === "number" && Number.isFinite(startedMs) ? Math.max(0, startedMs) : 0;
3267
+ hookStats.set(peer, { n: st.n + 1, startupMs: st.startupMs + startup, hubMs: st.hubMs + (performance.now() - callStarted), maxStartupMs: Math.max(st.maxStartupMs, startup) });
3268
+ };
3269
+ const emitHookStats = (peer: PeerId) => {
3270
+ const st = hookStats.get(peer);
3271
+ if (!st) return;
3272
+ event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
3273
+ hookStats.delete(peer);
3274
+ };
3145
3275
  /**
3146
3276
  * Between native turns now: Claude stopped after its last tool call began (its channel going offline says nothing
3147
3277
  * about its session, whose hooks may still run); any other peer's adapter is not in a turn. The adapter's own state,
@@ -3168,7 +3298,8 @@ export async function startDaemon(opts: DaemonOptions) {
3168
3298
  }
3169
3299
  if ((h.event === "proposed" || h.event === "backlog pulled" || h.event === "catalog role changed") && (t.role ? catalog.roles.has(t.role) : h.event === "catalog role changed")) event({ type: "role", task: t.id, actor: h.by, stage: "assigned", roles: t.role ? [roleIdentity(catalog.roles.get(t.role)!)] : [] });
3170
3300
  event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, ...(t.profile ? { profile: t.profile, profileClass: t.profileClass } : {}), ...(t.role ? { role: t.role, ...(catalog.roles.has(t.role) ? { roleIdentity: roleIdentity(catalog.roles.get(t.role)!) } : {}) } : {}), pii: tasks.isPii(t), ...(t.planningRevision ? { release: t.release ?? null, priority: t.priority ?? null, plannedStart: t.plannedStart ?? null, due: t.due ?? null } : {}), ...(h.reason ? { reason: h.reason } : {}) });
3171
- if (config.research.enabled && t.state === "approved" && (h.event === "approved" || h.event === "done")) recordResearch(t.id);
3301
+ // #1277 AC6: a cancelled task is a terminal task too, and the guarded merge writes its receipt after the approval, so its result completes the record's merge block.
3302
+ if (config.research.enabled && ((t.state === "approved" && (h.event === "approved" || h.event === "done" || h.event === "merge result")) || (t.state === "cancelled" && h.event === "cancelled"))) recordResearch(t.id);
3172
3303
  if (memoryStore && (t.state === "in_progress" || h.event === "done") && !tasks.isPii(t)) renewLeases(t);
3173
3304
  if ((h.event === "proposed" || h.event === "backlog pulled") && projections.enabled && turnLog) {
3174
3305
  const created = taskProposalAt(t);
@@ -3193,6 +3324,161 @@ export async function startDaemon(opts: DaemonOptions) {
3193
3324
  };
3194
3325
  const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
3195
3326
  const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
3327
+ const recordOwners = new WeakMap<PeerAdapter, { cwd: string; session(): string | undefined }>();
3328
+ const recordInputs = new Map<PeerId, { owner: PeerAdapter | undefined; originals: readonly Envelope[]; tainted?: true }>();
3329
+ const recordFrames = new Map<PeerId, { owner: PeerAdapter; turn: NonNullable<ReturnType<typeof turns.get>>; capture: RecordCapture }>();
3330
+ function recordPolicy(cwd: string): { enabled: boolean; version: string } | undefined {
3331
+ try {
3332
+ if (opts.effectiveRecordPolicy) {
3333
+ const policy = opts.effectiveRecordPolicy(cwd);
3334
+ return policy && typeof policy.enabled === "boolean" && typeof policy.version === "string" && policy.version ? { enabled: policy.enabled, version: policy.version } : undefined;
3335
+ }
3336
+ const binding = captureBinding;
3337
+ if (!binding || memoryProblem) return;
3338
+ binding.assertCurrent();
3339
+ const enabled = recordsEnabled(binding.home, cwd);
3340
+ // This revision binds only the public flag; fresh configuredPolicy screens below bind current privacy policy.
3341
+ return { enabled, version: enabled ? "records:true" : "records:false" };
3342
+ }
3343
+ catch { return; }
3344
+ }
3345
+ function recordPublic(text: string): boolean { return !looksSecret(text) && boundedSafe(text) && tasks.publicMetadataText(text); }
3346
+ function recordPathDenied(path: string, cwd = opts.cwd): boolean { return isDenied(path, config.local.deny) || matchesPolicy(cwd, path, config.pii?.paths ?? [], true) || matchesPolicy(opts.cwd, resolve(cwd, path), config.pii?.paths ?? [], true); }
3347
+ function recordInputAllowed(peer: PeerId, envs: readonly Envelope[]): boolean {
3348
+ try { return !!envs.length && !reviewInputHeld(peer, envs) && !reviewObserverInput(peer) && !envs.some(env => env.private || isReviewObserverExcluded(env) || isInvocationKind(env) || remoteEnvelope(env) || env.to?.some(id => id.includes("@"))); }
3349
+ catch { return false; }
3350
+ }
3351
+ function recordOwnerEligible(owner: PeerAdapter): boolean {
3352
+ const recordPeerId = owner.id, input = recordInputs.get(recordPeerId), proxy = frontendProxyState(recordPeerId, owner);
3353
+ if (proxy.present && (!proxy.port || proxy.port.attached)) return false;
3354
+ return config.memory.enabled && !stopping && !recoveryActive() && !bus.isRecoveryHeld && recordOwners.has(owner) && bus.peers.get(recordPeerId) === owner
3355
+ && membershipAllowed(recordPeerId) && owner.state === "busy" && !reviewOnlyPeer(recordPeerId) && !definitionOf(recordPeerId)?.reviewer && !contextPii(recordPeerId)
3356
+ && !!input && !input.tainted && input.owner === owner && recordInputAllowed(recordPeerId, input.originals);
3357
+ }
3358
+ function noteRecordSteered(peer: PeerId, originals: readonly Envelope[]): void {
3359
+ const input = recordInputs.get(peer), owner = bus.peers.get(peer);
3360
+ if (!input) { recordInputs.set(peer, { owner, originals: [], tainted: true }); return; }
3361
+ if (!input.tainted && (!owner || input.owner !== owner || !recordInputAllowed(peer, originals))) input.tainted = true;
3362
+ }
3363
+ function closeRecordFrame(peer: PeerId): void { recordFrames.get(peer)?.capture.close(); recordFrames.delete(peer); }
3364
+ function bindRecordOwner(owner: PeerAdapter, cwd: string, session: () => string | undefined): void {
3365
+ recordOwners.set(owner, { cwd, session });
3366
+ owner.recordInstruction = () => {
3367
+ if (!recordOwnerEligible(owner)) return;
3368
+ const turn = turns.get(owner.id), bound = recordOwners.get(owner), prior = recordFrames.get(owner.id);
3369
+ if (!turn || !bound) return;
3370
+ if (prior?.owner === owner && prior.turn === turn) return prior.capture.instruction();
3371
+ closeRecordFrame(owner.id);
3372
+ const session = bound.session();
3373
+ if (!session) return;
3374
+ const effective = recordPolicy(cwd);
3375
+ if (!effective?.enabled || !captureBinding || memoryProblem) return;
3376
+ const binding = captureBinding, profile = memorySyncPolicyVersion(), revision = configRevision, generation = owner.sessionGeneration;
3377
+ const current = () => {
3378
+ if (!recordOwnerEligible(owner) || turns.get(owner.id) !== turn || bound.session() !== session || owner.sessionGeneration !== generation || configRevision !== revision || captureBinding !== binding || memoryProblem) return false;
3379
+ const policy = recordPolicy(cwd);
3380
+ if (policy?.enabled !== true || policy.version !== effective.version || memorySyncPolicyVersion() !== profile) return false;
3381
+ try { binding.assertCurrent(); return memoryProjectFor(opts.cwd) === memoryProject; } catch { return false; }
3382
+ };
3383
+ const policy: RecordCapturePolicy = { records: true, current, project: memoryProject, cwd, peer: owner.id, turn: turn.id, session, at: new Date(turn.start).toISOString(), memoryDir: binding.home,
3384
+ publicText: text => { try { return current() && configuredPolicy(binding.home, cwd).screenStandalone(text) && recordPublic(text) && current(); } catch { return false; } },
3385
+ denyPath: path => recordPathDenied(path, cwd) };
3386
+ const capture = new RecordCapture({ eligible: () => recordOwnerEligible(owner) && turns.get(owner.id) === turn, policy: () => current() ? policy : undefined,
3387
+ observe: counts => { if (current()) log(`records capture accepted=${counts.accepted} dropped=${counts.dropped} bytes=${counts.bytes}`); } });
3388
+ recordFrames.set(owner.id, { owner, turn, capture });
3389
+ return capture.instruction();
3390
+ };
3391
+ }
3392
+ function recordReadCwd(task?: Pick<BriefTask, "owner">): string {
3393
+ const owner = task?.owner ? bus.peers.get(task.owner) : undefined;
3394
+ return owner ? recordOwners.get(owner)?.cwd ?? opts.cwd : opts.cwd;
3395
+ }
3396
+ function recordReadAllowed(task?: BriefTask, cwd = recordReadCwd(task)): boolean {
3397
+ try { return config.memory.enabled && !stopping && !recoveryActive() && !bus.isRecoveryHeld && !memoryProblem && !!captureBinding
3398
+ && (task ? corpusBriefAllowed(task) : true) && recordPolicy(cwd)?.enabled === true; } catch { return false; }
3399
+ }
3400
+ function recordReadVersion(task?: BriefTask): string { const cwd = recordReadCwd(task); return JSON.stringify([memorySyncPolicyVersion(), cwd, recordPolicy(cwd)]); }
3401
+ function readRecordSnapshot(task?: BriefTask, peer?: PeerId): import("../memory/brief.ts").RecordBriefSnapshot | undefined {
3402
+ const selectedPeer = task?.owner ?? peer;
3403
+ if (selectedPeer && (reviewOnlyPeer(selectedPeer) || contextPii(selectedPeer) || reviewObserverInput(selectedPeer))) return;
3404
+ const context = task ?? (peer ? { owner: peer, title: "", refs: {} } : undefined), cwd = recordReadCwd(context), owner = selectedPeer ? bus.peers.get(selectedPeer) : undefined;
3405
+ if (!recordReadAllowed(task, cwd)) return;
3406
+ const version = recordReadVersion(context), binding = captureBinding!;
3407
+ let store: CaptureStore | undefined;
3408
+ try {
3409
+ binding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) return;
3410
+ const policy = configuredPolicy(binding.home, cwd);
3411
+ store = new CaptureStore({ memoryDir: binding.home, project: memoryProject, cwd, readOnly: true });
3412
+ const snapshot = store.readBriefRecords(32);
3413
+ if (!recordReadAllowed(task, cwd) || recordReadVersion(context) !== version) return;
3414
+ const paths = task ? [...task.refs.paths ?? [], ...task.plan?.paths ?? []] : undefined;
3415
+ const publicRow = (row: (typeof snapshot.rows)[number], currentPolicy: ReturnType<typeof configuredPolicy>) => row.event === "Record" && !row.subagent && Array.isArray(row.paths)
3416
+ && currentPolicy.screenStandalone(row) && currentPolicy.screenStandalone(row.excerpt) && currentPolicy.screenStandalone(row.paths) && recordPublic(JSON.stringify(row))
3417
+ && sanitize(row.excerpt) === row.excerpt && row.paths.every(anchor => typeof anchor === "string" && !recordPathDenied(anchor.split("#")[0]!, cwd));
3418
+ const rows = snapshot.rows.filter(row => publicRow(row, policy)
3419
+ && (!paths || row.paths.length === 0 || row.paths.some(anchor => paths.some(path => anchor === path || anchor.split("#")[0] === path))));
3420
+ const current = (text?: string) => {
3421
+ try {
3422
+ if (selectedPeer && (bus.peers.get(selectedPeer) !== owner || reviewOnlyPeer(selectedPeer) || contextPii(selectedPeer) || reviewObserverInput(selectedPeer))) return false;
3423
+ if (captureBinding !== binding || recordReadCwd(context) !== cwd || !recordReadAllowed(task, cwd) || recordReadVersion(context) !== version) return false;
3424
+ binding.assertCurrent(); if (memoryProjectFor(opts.cwd) !== memoryProject) return false;
3425
+ const fresh = configuredPolicy(binding.home, cwd);
3426
+ return rows.every(row => publicRow(row, fresh)) && (text === undefined || typeof text === "string" && fresh.screenStandalone(text) && recordPublic(text));
3427
+ } catch { return false; }
3428
+ };
3429
+ if (!current()) return;
3430
+ return { rows, replaceable: generatedRecordNotes.matching([...new Set(rows.flatMap(row => row.paths))], 32), partial: snapshot.partial, current };
3431
+ } catch { return; } finally { try { store?.close(); } catch { /* Read preference never changes the old brief. */ } }
3432
+ }
3433
+ function projectRecordRecall(peer: PeerId, original: string, tokens: number): import("../memory/recall.ts").RecordRecallProjection | undefined {
3434
+ const header = "# [amem] recent curated context\n\n### Curated short-term notes\n";
3435
+ if (typeof original !== "string" || original.length > 64 * 1024 || Buffer.byteLength(original) > 64 * 1024 || !original.startsWith(header) || original.includes("\r") || !Number.isSafeInteger(tokens) || tokens < 1) return;
3436
+ const owner = bus.peers.get(peer), context: BriefTask = { owner: peer, title: "", refs: {} };
3437
+ const allowed = () => bus.peers.get(peer) === owner && !reviewOnlyPeer(peer) && !contextPii(peer) && !reviewObserverInput(peer) && recordReadAllowed(undefined, recordReadCwd(context));
3438
+ if (!allowed()) return;
3439
+ const version = recordReadVersion(context), current = () => allowed() && recordReadVersion(context) === version;
3440
+ if (!current()) return;
3441
+ const body = original.slice(header.length).split("\n"), items: NativeItem[] = [], ids = new Set<string>(), protectedIds = new Set<string>();
3442
+ const row = /^(mem:[a-f0-9]{32}) (decision|finding|contract|fail): /;
3443
+ for (const line of body) {
3444
+ const match = row.exec(line);
3445
+ if (!match) { if (line.startsWith("mem:")) return; continue; }
3446
+ const id = match[1]!;
3447
+ if (ids.has(id) || items.length >= 32) return;
3448
+ ids.add(id); items.push({ id, kind: match[2]!, line });
3449
+ if (!generatedRecordNotes.get(id)) protectedIds.add(id);
3450
+ }
3451
+ const snapshot = readRecordSnapshot(undefined, peer);
3452
+ let projectedText = original;
3453
+ const projectionCurrent = () => current() && (snapshot?.current === undefined || snapshot.current(original) === true && snapshot.current(projectedText) === true);
3454
+ if (!snapshot || !projectionCurrent()) return;
3455
+ const staticBytes = Buffer.byteLength(original) - Buffer.byteLength(items.map(item => item.line).join("\n"));
3456
+ const preferred = preferRecordItems(items, snapshot, 32, protectedIds, Math.max(0, tokens * 3 - staticBytes));
3457
+ if (!projectionCurrent()) return;
3458
+ const output: string[] = []; let index = 0;
3459
+ for (const line of body) {
3460
+ const match = row.exec(line);
3461
+ if (!match) { output.push(line); continue; }
3462
+ if (preferred.items.some(item => item.id === match[1])) {
3463
+ while (index < preferred.items.length && !ids.has(preferred.items[index]!.id)) output.push(preferred.items[index++]!.line);
3464
+ if (preferred.items[index]?.id !== match[1]) return;
3465
+ output.push(line); index++;
3466
+ } else {
3467
+ while (index < preferred.items.length && !ids.has(preferred.items[index]!.id)) output.push(preferred.items[index++]!.line);
3468
+ }
3469
+ }
3470
+ while (index < preferred.items.length) { const item = preferred.items[index++]!; if (ids.has(item.id)) return; output.push(item.line); }
3471
+ const after = header + output.join("\n"); projectedText = after;
3472
+ if (!projectionCurrent() || Buffer.byteLength(after) > Math.max(Buffer.byteLength(original), tokens * 3)) return;
3473
+ return { before: original, after, records: preferred.records, current: projectionCurrent };
3474
+ }
3475
+ bus.onFinalAnswer = (peer, raw, input) => {
3476
+ const frame = recordFrames.get(peer);
3477
+ if (!frame || !recordOwnerEligible(frame.owner) || turns.get(peer) !== frame.turn || !recordInputAllowed(peer, input.out) || !recordInputAllowed(peer, input.originals)) return;
3478
+ frame.capture.finish(raw);
3479
+ };
3480
+ bus.onFinalAnswerBytes = (peer, counts) => { const owner = bus.peers.get(peer); if (owner && recordOwnerEligible(owner)) log(`records forwarded before=${counts.before} after=${counts.after}`); };
3481
+
3196
3482
  // Hoisted for the Tasks ports above; only the returned guard reads fully initialized daemon state.
3197
3483
  function controlWritePrivacyGuard(admittedAtReceive: boolean, inputPublicAtReceive: boolean, peer?: PeerId, taskId?: number) {
3198
3484
  return () => {
@@ -3400,28 +3686,32 @@ export async function startDaemon(opts: DaemonOptions) {
3400
3686
  notify: (line) => { notify(line); feedBudgetPauses(); },
3401
3687
  }, accountMembers, accountOf));
3402
3688
  budget.setRecoveryHold(recoveryActive());
3403
- // ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
3404
- // 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
3405
- // read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
3406
- const startupEvents = readEvents(join(opts.stateDir, "events.jsonl"));
3689
+ // Share the single streaming startup pass; quota, Kimi and token-window replay keep their existing predicates.
3690
+ startupTimings.mark("budget");
3691
+ const startupEvents = startupSeeds.events;
3407
3692
  budget.seedResets(startupEvents.flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
3408
3693
  // #285 phase 2: Kimi's consumption is the hub's own count of its session wire log (`src/adapters/kimi-wire-usage.ts`), not an ACP total.
3694
+ const kimiEvents = new KimiEventWriter({ append: event, read: () => readEvents(join(opts.stateDir, "events.jsonl")) }, startupEvents);
3409
3695
  const kimiUsage = new KimiUsage({
3410
3696
  home: opts.kimiHome ?? process.env.KIMI_CODE_HOME ?? join(homedir(), ".kimi-code"),
3411
3697
  now: () => Date.now(),
3412
3698
  ceiling: () => config.budget.kimi_tokens_5h,
3413
3699
  session: peer => contextSession(peer),
3414
- record: (peer, row, counted) => {
3415
- addTokens(peer, counted);
3416
- event({ type: "usage", peer, source: "kimi_wire", id: row.id, measuredAt: new Date(row.at).toISOString(), inputTokens: row.inputOther, outputTokens: row.output,
3417
- cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
3418
- ...taskAttribution(peer), ...(row.model ? { servedModel: row.model } : {}) });
3700
+ schedule: opts.kimiUsageScheduler ?? ((delay, run) => {
3701
+ const timer = setTimeout(run, delay); timer.unref?.();
3702
+ return () => clearTimeout(timer);
3703
+ }),
3704
+ record: (peer, row, counted, attributed) => {
3705
+ kimiEvents.write(peer, row, counted, attributed);
3706
+ applyKimiTokens(peer, counted, attributed);
3419
3707
  },
3420
3708
  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}` }]),
3421
3709
  forget: peer => budget.forget(peer, "tokens", "count"),
3422
3710
  notice: line => notify(line),
3423
3711
  });
3712
+ startupCleanup.push(() => kimiUsage.stop());
3424
3713
  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))));
3714
+ startupTimings.mark("usage_seed");
3425
3715
  /** 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. */
3426
3716
  const budgetPeerNotes = (): BudgetPeerNote[] => {
3427
3717
  const quota = budget.status();
@@ -3429,15 +3719,16 @@ export async function startDaemon(opts: DaemonOptions) {
3429
3719
  return budgetNotes({ peers, windows: peer => quota[accountOf(peer)]?.windows.length ?? 0, definition: peer => definitionOf(peer), softTokens: () => kimiUsage.status(),
3430
3720
  hubTokens5h: peer => hubTokenWindows.get(peer)?.sum(Date.now()) });
3431
3721
  };
3432
- /** 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. */
3722
+ /** A peer whose definition keeps host-owned soft token accounting (Kimi): its rows are read at a turn start (the floor), a turn end, a usage_update and a bounded delayed drain. */
3433
3723
  const softTokenPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.resetSoftTokensOnResume;
3434
- const collectKimiUsage = (peer: PeerId): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
3724
+ const collectKimiUsage = (peer: PeerId, terminal = false): void => { if (!softTokenPeer(peer)) return; try { kimiUsage.collect(peer, false, terminal); } catch (error) { log(`kimi usage: ${(error as Error).name}`); } };
3435
3725
  startupCleanup.push(() => budget.close());
3436
3726
  // #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
3437
3727
  const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
3438
3728
  startupCleanup.push(() => usageRollup.close());
3439
3729
  const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
3440
3730
  pollUsageRollup();
3731
+ startupTimings.mark("usage_rollup");
3441
3732
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
3442
3733
  const runId = Date.now().toString(36);
3443
3734
  let turnSeq = 0;
@@ -3447,11 +3738,14 @@ export async function startDaemon(opts: DaemonOptions) {
3447
3738
  const task = result.task === undefined ? undefined : board.get(result.task);
3448
3739
  return { ...result, ...(task && tasks.isPii(task) ? { pii: true as const } : {}) };
3449
3740
  };
3450
- const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string }>();
3741
+ const supervisionTurns = new Map<PeerId, { id: string; at: number; tokens?: number; session?: string; nativeTurn?: string }>();
3451
3742
  /** #1054: who started the turn a delivery begins. The person's message (console, `ahub say`, an acon send: from `user`, never the hub's own `answer` envelope) opens a run; ids only. */
3452
3743
  const pendingPersonDelivery = new Map<PeerId, { envelope: string; private: boolean }>();
3744
+ /** A native turn whose transcript, not its hook payload, proved a person origin. Native prompt text is never available here, so the result stays private. */
3745
+ const pendingNativePerson = new Set<PeerId>();
3453
3746
  bus.onDeliver = (peer, originals) => {
3454
3747
  if (peer.includes("@")) return;
3748
+ recordInputs.set(peer, { owner: bus.peers.get(peer), originals: [...originals] });
3455
3749
  resetReviewObserverSessionIfChanged(peer);
3456
3750
  if (!presenceOnlyDelivery(originals)) {
3457
3751
  const pendingBefore = pendingReviewObserverInputs.get(peer), previous = pendingBefore ?? currentReviewObserverInputs.get(peer), reviewInputScoped = reviewInputHeld(peer, originals), turnId = turns.get(peer)?.id;
@@ -3579,7 +3873,7 @@ export async function startDaemon(opts: DaemonOptions) {
3579
3873
  supervisionTurns.set(peer, { id: turn?.id ?? `supervision-${randomUUID()}`, at: turn?.start ?? Date.now(), ...(turn?.tokens ? { tokens: turn.tokens } : {}), ...(contextSession(peer) ? { session: contextSession(peer) } : {}) });
3580
3874
  };
3581
3875
  bus.onPulled = (peer, originals) => { notePulledReviewInput(peer, originals); };
3582
- bus.onSteering = (peer, originals) => { noteSteeredReviewInput(peer, originals); };
3876
+ bus.onSteering = (peer, originals) => { try { noteSteeredReviewInput(peer, originals); } finally { noteRecordSteered(peer, originals); } };
3583
3877
  bus.onDeliveryFailed = peer => {
3584
3878
  pendingDeliveryTask.delete(peer); pendingPersonDelivery.delete(peer); deliveredAwaitingTurn.delete(peer); supervisionTurns.delete(peer);
3585
3879
  const pending = pendingReviewObserverInputs.get(peer);
@@ -4014,9 +4308,20 @@ export async function startDaemon(opts: DaemonOptions) {
4014
4308
  const noQuotaPeer = (peer: PeerId): boolean => !!definitionOf(peer)?.budget?.noQuotaReason;
4015
4309
  const hubTokenWindow = (peer: PeerId): TokenWindow => hubTokenWindows.get(peer) ?? hubTokenWindows.set(peer, new TokenWindow(5 * 3_600_000)).get(peer)!;
4016
4310
  // 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.
4311
+ startupTimings.mark("token_runtime");
4017
4312
  const seedNow = Date.now(), seedRows = new Map<PeerId, { at: number; n: number }[]>();
4018
4313
  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 });
4019
4314
  for (const [peer, rows] of seedRows) hubTokenWindow(peer).seed(rows, seedNow);
4315
+ const applyKimiTokens = (peer: PeerId, n: number, attributed: KimiRowAttribution): void => {
4316
+ if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
4317
+ const supervisionTurn = supervisionTurns.get(peer);
4318
+ if (supervisionTurn && Number.isFinite(n) && n >= 0 && (supervisionTurn.nativeTurn ?? supervisionTurn.id) === attributed.turn) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
4319
+ if (n > 0) {
4320
+ const turn = turns.get(peer);
4321
+ if (turn && turn.id === attributed.turn) turn.tokens += n;
4322
+ }
4323
+ };
4324
+ startupTimings.mark("token_seed");
4020
4325
  const addTokens = (peer: PeerId, n: number, task?: number): number => {
4021
4326
  if (n > 0 && noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(n));
4022
4327
  const supervisionTurn = supervisionTurns.get(peer);
@@ -4035,11 +4340,21 @@ export async function startDaemon(opts: DaemonOptions) {
4035
4340
  const claudeContextSeen = new Map<string, string>();
4036
4341
  const contextTailSeen = new Map<PeerId, string>();
4037
4342
  const retainedCliAudits = new Set<string>();
4343
+ /**
4344
+ * The period of the sweep that drains the CLI audit outbox (the interval below runs `drainCliAuditsNow` on it). A row is attributed to the peer's task only when it is at most this many
4345
+ * periods old at the drain: an ordinary row is drained within one period, so fifteen leaves room for a slow sweep, while a row left by a stalled hub or an unreadable outbox is not matched to
4346
+ * whatever task is open when it is finally read. The bound is derived from the period, so the two cannot drift apart.
4347
+ */
4348
+ const CLI_AUDIT_SWEEP_MS = 1000;
4349
+ const CLI_AUDIT_FRESH_MS = 15 * CLI_AUDIT_SWEEP_MS;
4038
4350
  function drainCliAuditsNow(): void {
4039
4351
  for (const audit of drainCliAudits(opts.stateDir)) {
4040
4352
  const delegated = audit.outcome === "delegated";
4041
4353
  const refused = audit.outcome !== "run" && !delegated;
4042
- if (!event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}) })) {
4354
+ // #1278: the audit row is written when the command runs and read here on a later sweep, so the peer's open tasks now may not be the ones then. A row older than the drain's own
4355
+ // freshness bound is `unattributed` (what is known), never matched to whatever is open at drain time; a fresh one uses the rule `turn_end` uses.
4356
+ const fresh = Date.now() - audit.at <= CLI_AUDIT_FRESH_MS && Date.now() >= audit.at;
4357
+ if (!event({ type: "agent_cli", peer: audit.peer, command: audit.command, refused, ...(delegated ? { delegated: true } : {}), ...(fresh ? taskAttribution(audit.peer) : { attribution: "unattributed" as const }) })) {
4043
4358
  throw new Error("CLI audit event was not retained");
4044
4359
  }
4045
4360
  retainedCliAudits.add(audit.id); // Only after the event append succeeded.
@@ -4135,7 +4450,7 @@ export async function startDaemon(opts: DaemonOptions) {
4135
4450
  const reading = bound ? readNativeContext(definitionOf(claudePeer), c.context, bound, c.at) : undefined;
4136
4451
  if (reading) contexts.report(claudePeer, reading, bound);
4137
4452
  } catch { /* absent or unreadable native telemetry is unknown */ }
4138
- }, 1000),
4453
+ }, CLI_AUDIT_SWEEP_MS),
4139
4454
  setInterval(() => budget.tick(), 30_000),
4140
4455
  setInterval(pollUsageRollup, 10_000),
4141
4456
  // #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
@@ -4159,6 +4474,10 @@ export async function startDaemon(opts: DaemonOptions) {
4159
4474
  void tasks.runSecurityScan().catch(error => log(`sdlc security scan failed: ${sanitize((error as Error).message).slice(0, 160)}`));
4160
4475
  }, opts.securityScanEveryMs ?? 3_600_000),
4161
4476
  setInterval(() => {
4477
+ if (!stopping && !recoveryActive() && !bus.isRecoveryHeld) {
4478
+ try { visuals.prune(config.visuals.retention_days, Date.now(), task => !!board.get(task)); }
4479
+ catch { /* retention retries on the existing maintenance tick */ }
4480
+ }
4162
4481
  if (!ready || !memoryBinding) return;
4163
4482
  const before = memoryProblem;
4164
4483
  memoryBinding.status(memoryStore);
@@ -4546,7 +4865,7 @@ export async function startDaemon(opts: DaemonOptions) {
4546
4865
  const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
4547
4866
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
4548
4867
  const [by, op, input] = args;
4549
- if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw new PreEffectToolRefusal("read-only instance cannot mutate hub state");
4868
+ if (readOnlyPeer(by) && !READ_ONLY_HUB_TOOLS.has(op)) throw op === "hub_visual" ? visualOperational("role") : new PreEffectToolRefusal("read-only instance cannot mutate hub state");
4550
4869
  const sourceCurrent = op === "hub_source_read" ? sourceReadCurrent(by, args[3] ?? false, args[4] ?? false) : undefined;
4551
4870
  const nativeTurn = turns.get(by);
4552
4871
  const effectful = ownerToolEffect(op, input);
@@ -4569,13 +4888,13 @@ export async function startDaemon(opts: DaemonOptions) {
4569
4888
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
4570
4889
  auditConduct({ kind: "conduct", actor: by, action: op.slice(4), ...(id === undefined ? {} : { task: id }) });
4571
4890
  }
4572
- const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" ? ownHeldHint(by) : "";
4891
+ const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" && op !== "hub_visual" && op !== "hub_visual_show" ? ownHeldHint(by) : "";
4573
4892
  return hint ? `${text}\n\n${hint}` : text;
4574
4893
  } catch (error) {
4575
4894
  if (reviewTool && traceId !== undefined) reviewerToolObservers.get(by)?.(traceId, reviewTool, error instanceof Error ? error.message : "review tool refused");
4576
4895
  if (nativeTurn && effectful && !(error instanceof PreEffectToolRefusal) && turns.get(by) === nativeTurn) nativeTurn.taskEffect = true;
4577
4896
  // Keep proven pre-effect refusal identity/code for native receipts; only add body-free metadata.
4578
- const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" ? ownHeldHint(by) : "";
4897
+ const hint = args[3] && op !== "hub_source_read" && op !== "hub_set_title" && op !== "hub_evidence_record" && op !== "hub_visual" && op !== "hub_visual_show" ? ownHeldHint(by) : "";
4579
4898
  if (hint && error instanceof Error) error.message += `\n\n${hint}`;
4580
4899
  throw error;
4581
4900
  } finally {
@@ -4741,7 +5060,197 @@ export async function startDaemon(opts: DaemonOptions) {
4741
5060
  catch (error) { finishInvocation(row, "refused", { reason: "the hub could not queue the request; nothing ran" }); throw new PreEffectToolRefusal(error instanceof Error ? error.message : "the request could not be queued"); }
4742
5061
  return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
4743
5062
  }
5063
+ const visualUnavailable = () => new PreEffectToolRefusal("visual unavailable under the current privacy or project policy; use a public visual in an ordinary context and retry");
5064
+ const visualOperational = (cause: "stopping" | "recovery" | "authority" | "role" | "membership" | "observer" | "storage" | "concurrency" | "screen" | "policy") => new PreEffectToolRefusal({
5065
+ stopping: "visual unavailable: hub is stopping; wait for the hub to restart and retry",
5066
+ recovery: "visual operation is held by controlled recovery; check ahub status, wait for recovery release and retry",
5067
+ authority: "visual request authority changed; reconnect your current peer session and retry",
5068
+ role: "visual publication requires remember capability and a writable peer role; ask the person to update the peer role or capability and retry",
5069
+ membership: "visual peer is off roster; ask the person to enable it and retry",
5070
+ observer: "visual publication is held by an open review observer scope; finish or settle the review and start an ordinary turn",
5071
+ storage: "visual delivery storage is unavailable; inspect ahub status and retry after storage recovers",
5072
+ concurrency: "visual generation, task or policy changed while checking; inspect current task/session and retry",
5073
+ screen: "visual privacy screen is unavailable or returned no clear verdict; shorten the visual if it exceeds the screen limit, or restore the configured screen, check ahub status and retry",
5074
+ policy: "visual file policy is unavailable; repair the project configuration and retry",
5075
+ }[cause]);
5076
+ type VisualFilePolicy = { pii: string[]; deny: string[] };
5077
+ let visualPolicyCache: { key: string; value: VisualFilePolicy } | undefined;
5078
+ // These two non-machine-local path policies use ordinary per-key local-over-shared precedence.
5079
+ // Reading a candidate cannot relax accepted runtime restrictions until they are actually adopted.
5080
+ const visualFilePolicy = (): VisualFilePolicy => {
5081
+ try {
5082
+ const texts = configSourceTexts(opts.cwd);
5083
+ const key = JSON.stringify([texts["config.json"], texts["config.local.json"], config.pii?.paths ?? [], config.local.deny]);
5084
+ if (visualPolicyCache?.key === key) return visualPolicyCache.value;
5085
+ let pii: string[] = [], deny: string[] = [];
5086
+ for (const name of CONFIG_FILES) {
5087
+ if (texts[name] === null) continue;
5088
+ const raw: unknown = JSON.parse(texts[name]!);
5089
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw visualOperational("policy");
5090
+ const input = raw as Record<string, unknown>;
5091
+ if (input.pii !== undefined) {
5092
+ const policies: Record<string, unknown> = { pii: input.pii };
5093
+ validateRawConfig(policies, declarations, adapterCatalog);
5094
+ const value = input.pii as Record<string, unknown>;
5095
+ if (Object.hasOwn(value, "paths")) pii = policyPaths(value.paths, "pii.paths");
5096
+ }
5097
+ if (input.local !== undefined) {
5098
+ if (!input.local || typeof input.local !== "object" || Array.isArray(input.local)) throw visualOperational("policy");
5099
+ const value = input.local as Record<string, unknown>;
5100
+ if (Object.hasOwn(value, "deny")) {
5101
+ const localPolicy: Record<string, unknown> = { deny: value.deny }, policies: Record<string, unknown> = {};
5102
+ policies.local = localPolicy;
5103
+ validateRawConfig(policies, declarations, adapterCatalog);
5104
+ deny = value.deny as string[];
5105
+ }
5106
+ }
5107
+ }
5108
+ const value = { pii: [...new Set([...(config.pii?.paths ?? []), ...pii])], deny: [...new Set([...config.local.deny, ...deny])] };
5109
+ visualPolicyCache = { key, value }; return value;
5110
+ } catch { throw visualOperational("policy"); }
5111
+ };
5112
+ const visualSafeText = (text: string, policy: VisualFilePolicy): boolean => boundedSafe(text) && tasks.publicMetadataText(text)
5113
+ && !mentionsPrivatePath(text, [], [...policy.pii, ...policy.deny], opts.cwd);
5114
+ const visualPathPublic = (path: string, policy: VisualFilePolicy): boolean => {
5115
+ try {
5116
+ const root = realPath(opts.cwd), typed = resolve(root, path);
5117
+ const publicName = (value: string) => {
5118
+ const name = relative(root, value);
5119
+ return insideRoot(name) && !isHubWritePath(name) && !isDenied(name, policy.deny) && !isDenied(value, policy.deny)
5120
+ && !matchesPolicy(root, name, policy.pii, true) && visualSafeText(name, policy);
5121
+ };
5122
+ return publicName(typed) && publicName(policyRealPath(typed));
5123
+ } catch { return false; }
5124
+ };
5125
+ const visualTaskPublic = (task?: number, captured?: VisualFilePolicy): boolean => {
5126
+ try {
5127
+ const policy = captured ?? visualFilePolicy();
5128
+ if (task === undefined) return true;
5129
+ const current = board.get(task);
5130
+ return !!current && !tasks.isPii(current) && [...(current.refs.paths ?? []), ...(current.plan?.paths ?? [])].every(path =>
5131
+ visualPathPublic(path, policy));
5132
+ } catch { return false; }
5133
+ };
5134
+ const visualPorts = (policy = visualFilePolicy()): DeliverablePorts => ({ cwd: opts.cwd, deny: policy.deny, private: path =>
5135
+ !visualPathPublic(path, policy) });
5136
+ const visualPublic = (record: VisualRecord | VisualSummary, policy?: VisualFilePolicy): boolean => {
5137
+ try {
5138
+ return !stopping && (record.task !== undefined || record.created >= Date.now() - config.visuals.retention_days * 86_400_000) && !turns.get(record.from)?.private && !contextPii(record.from) && visualTaskPublic(record.task, policy)
5139
+ && visualSafeText(record.title, policy ?? visualFilePolicy()) && visualSafeText(record.from, policy ?? visualFilePolicy());
5140
+ } catch { return false; }
5141
+ };
5142
+ const visualRecord = (id: unknown, captured?: VisualFilePolicy): VisualRecord | undefined => {
5143
+ if (typeof id !== "string" || !new RegExp(VISUAL_ID_PATTERN).test(id)) return undefined;
5144
+ try {
5145
+ const policy = captured ?? visualFilePolicy(), record = visuals.get(id);
5146
+ if (!record || !visualPublic(record, policy)) return undefined;
5147
+ validateVisualPayload(record.payload, text => visualSafeText(text, policy));
5148
+ const ports = visualPorts(policy);
5149
+ for (const file of record.files) {
5150
+ if (ports.private(file.path)) return undefined;
5151
+ }
5152
+ return record;
5153
+ } catch { return undefined; }
5154
+ };
5155
+ const visualView = (record: VisualRecord) => ({ id: record.id, ...(record.task === undefined ? {} : { task: record.task }), from: record.from,
5156
+ kind: record.kind, title: record.title, payload: record.payload, created: record.created });
5157
+ const visualList = (task?: number): VisualSummary[] => {
5158
+ try { const policy = visualFilePolicy(); return visuals.list({ ...(task === undefined ? {} : { task }), limit: 20 },
5159
+ (record, paths) => visualPublic(record, policy) && paths.every(path => visualPathPublic(path, policy))); }
5160
+ catch { return []; }
5161
+ };
5162
+ async function visualTool(by: PeerId, op: string, a: Record<string, unknown>, piiTurn: boolean): Promise<string> {
5163
+ if (!a || typeof a !== "object" || Array.isArray(a)) throw new PreEffectToolRefusal("visual arguments must be an object; retry with the documented fields");
5164
+ const authority = childToolAuthority.getStore();
5165
+ const actor = (publishing: boolean) => {
5166
+ if (stopping) throw visualOperational("stopping");
5167
+ let current = false;
5168
+ try { current = !authority || authority.by === by && authority.current(); } catch { /* stale authority */ }
5169
+ if (!current) throw visualOperational("authority");
5170
+ if (piiTurn || turns.get(by)?.private || contextPii(by)) throw visualUnavailable();
5171
+ if (!membershipAllowed(by)) throw visualOperational("membership");
5172
+ if (!publishing) return;
5173
+ if (recoveryActive() || bus.isRecoveryHeld) throw visualOperational("recovery");
5174
+ if (readOnlyPeer(by) || !may(by, "remember")) throw visualOperational("role");
5175
+ if (reviewObserverHeld(by) || bus.lastDeliveredTo(by).some(isReviewObserverExcluded)) throw visualOperational("observer");
5176
+ if (!bus.deliveryJournalAvailable()) throw visualOperational("storage");
5177
+ };
5178
+ actor(op !== "hub_visual_show");
5179
+ if (op === "hub_visual_show") {
5180
+ if (Object.keys(a).some(key => key !== "id")) throw new PreEffectToolRefusal("visual show takes only id; retry with a visual id");
5181
+ const record = visualRecord(a.id, visualFilePolicy());
5182
+ if (!record) throw visualUnavailable();
5183
+ return JSON.stringify(visualView(record));
5184
+ }
5185
+ if (Object.keys(a).some(key => !["payload", "task", "to"].includes(key))) throw new PreEffectToolRefusal("visual has an unsupported field; use payload, task and to only");
5186
+ if (a.task !== undefined && (!Number.isSafeInteger(a.task) || Number(a.task) < 1)) throw new PreEffectToolRefusal("visual task must be a positive integer; retry with a task id");
5187
+ const task = a.task as number | undefined, before = task === undefined ? undefined : board.get(task);
5188
+ if (task !== undefined && !before) throw new PreEffectToolRefusal("visual task is unavailable; choose an existing public task and retry");
5189
+ const generation = connectionGeneration(by), turn = turns.get(by);
5190
+ const policyVersion = (filePolicy: VisualFilePolicy) => JSON.stringify({ revision: configRevision, filePolicy, pii: config.pii, deny: config.local.deny, signals: activeRouting().signals, constraints: activeRouting().constraints, mlx: config.mlx, model: activeRouting().local.fixed_model });
5191
+ const initialPolicy = visualFilePolicy(), policy = policyVersion(initialPolicy);
5192
+ const current = (): VisualFilePolicy => {
5193
+ actor(true);
5194
+ const filePolicy = visualFilePolicy();
5195
+ if (policyVersion(filePolicy) !== policy || connectionGeneration(by) !== generation || turns.get(by) !== turn)
5196
+ throw visualOperational("concurrency");
5197
+ if (task !== undefined) {
5198
+ const now = board.get(task);
5199
+ if (!before || !now || now.created !== before.created || now.updated !== before.updated || now.history.length !== before.history.length)
5200
+ throw visualOperational("concurrency");
5201
+ }
5202
+ if (!visualTaskPublic(task, filePolicy)) throw visualUnavailable();
5203
+ return filePolicy;
5204
+ };
5205
+ current();
5206
+ const recipients = typeof a.to === "string" && a.to.length ? [a.to] : a.to;
5207
+ if (a.to !== undefined && (!Array.isArray(recipients) || recipients.some(id => typeof id !== "string" || !id.length)))
5208
+ throw new PreEffectToolRefusal("visual to must be a peer id or array of peer ids; retry with local recipients");
5209
+ const to = a.to === undefined ? [USER] : Array.isArray(recipients) && recipients.length ? [...new Set(recipients as string[])] : bus.knownPeers().filter(id => !parseRemotePeerId(id));
5210
+ if (to.some(id => id !== USER && (!bus.knownPeers().includes(id) || parseRemotePeerId(id)))) throw new PreEffectToolRefusal("visual recipient unavailable; choose a known local peer or user and retry");
5211
+ const id = randomUUID(), priority = a.to === undefined ? "fyi" : "status";
5212
+ const early = newEnvelope(by, `[visual ${id}]`, { priority, to, refs: { visual: id, ...(task === undefined ? {} : { task: String(task) }) } });
5213
+ const preflight = (stub: Envelope, dry: boolean) => {
5214
+ try { bus.checkRemotePublication(stub); } catch { throw new PreEffectToolRefusal("visual delivery unavailable; choose local recipients and retry"); }
5215
+ const offRoster = bus.offRosterRecipients(stub).filter(peer => peer !== USER);
5216
+ const audience = new Set(stub.to?.filter(peer => peer !== by && peer !== USER));
5217
+ if (offRoster.length && offRoster.length === audience.size) throw new PreEffectToolRefusal("visual recipients are off roster; choose an available local peer and retry");
5218
+ const hushed = bus.hushed(stub);
5219
+ if (hushed.length && hushed.length === bus.audience(stub).length) throw new PreEffectToolRefusal("visual delivery is held by the current silence policy; wait for the hold to clear");
5220
+ const effective = offRoster.length ? { ...stub, to: stub.to!.filter(peer => !offRoster.includes(peer)) } : stub;
5221
+ const refused = dry
5222
+ ? (by === USER || by === HUB || by === DIGEST || stub.priority === "fyi" ? undefined : limiter.peek(by, effective.to, effective.priority, effective.body))
5223
+ : admit(effective);
5224
+ if (refused) throw new PreEffectToolRefusal("visual delivery is rate limited; wait before retrying");
5225
+ };
5226
+ // No screen, image read, or consuming admission has occurred at this point.
5227
+ preflight(early, true);
5228
+ const input = validateVisualInput(a.payload, text => visualSafeText(text, initialPolicy));
5229
+ if (tasks.screenOn()) {
5230
+ const screen = opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model }));
5231
+ const verdict = await screen(JSON.stringify(input)).catch(() => undefined);
5232
+ current();
5233
+ if (verdict?.label === "pii") throw visualUnavailable();
5234
+ if (verdict?.label !== "clear") throw visualOperational("screen");
5235
+ }
5236
+ const beforeRead = current(), resolved = await resolveVisualAssets(input, id, visualPorts(beforeRead));
5237
+ const finalPolicy = current();
5238
+ const payload = validateVisualPayload(resolved.payload, text => visualSafeText(text, finalPolicy));
5239
+ const title = "title" in payload ? payload.title ?? "" : "caption" in payload ? payload.caption ?? "" : "";
5240
+ const record: VisualRecord = { id, ...(task === undefined ? {} : { task }), from: by, kind: payload.kind, title, payload, files: resolved.files, created: Date.now() };
5241
+ if (!visualPublic(record, finalPolicy) || resolved.files.some(file => !visualPathPublic(file.path, finalPolicy))) throw visualUnavailable();
5242
+ const stub = newEnvelope(by, `[visual ${id}] ${payload.kind}: ${sanitize(title)}`, { priority, to, refs: { visual: id, ...(task === undefined ? {} : { task: String(task) }) } });
5243
+ preflight(stub, false); // Recheck after every await; consume shared admission exactly once.
5244
+ if (!recoveryActive() && !bus.isRecoveryHeld) visuals.prune(config.visuals.retention_days, Date.now(), task => !!board.get(task));
5245
+ visuals.insert(record);
5246
+ try {
5247
+ event({ type: "visual", id, kind: payload.kind, bytes: Buffer.byteLength(JSON.stringify(payload)), files: resolved.files.length, ...(task === undefined ? {} : { task }) });
5248
+ bus.publish(stub);
5249
+ } catch { throw new Error(`visual ${id} was stored but its stub delivery failed; inspect the visual before posting again`); }
5250
+ return `visual ${id}`;
5251
+ }
4744
5252
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
5253
+ if (op === "hub_visual" || op === "hub_visual_show") return visualTool(by, op, a, piiTurn);
4745
5254
  const reviewTurn = reviewObserverHeld(by) || bus.lastDeliveredTo(by).some(isReviewObserverExcluded);
4746
5255
  if (reviewTurn || op === "hub_review" && reviewCaptureExcluded(op, JSON.stringify(a))) progress.clearPeer(by);
4747
5256
  if (op === "hub_ask") {
@@ -5085,6 +5594,9 @@ export async function startDaemon(opts: DaemonOptions) {
5085
5594
  const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
5086
5595
  if (!piiTurn && !reviewTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
5087
5596
  citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
5597
+ // #1279: the reasons of a spec acknowledgement are hub-screened text too, and one key per task keeps a repeat idempotent.
5598
+ const reasons = Array.isArray(a.spec_ack) ? a.spec_ack.flatMap((row: unknown) => typeof (row as { reason?: unknown })?.reason === "string" ? [(row as { reason: string }).reason] : []).join("\n") : "";
5599
+ if (reasons) citeMemory(by, reasons, `spec_ack:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
5088
5600
  }
5089
5601
  // A silent cohort (issue #107): the member that integrates is asked to check its work first; no done yet.
5090
5602
  const last = t.history.at(-1);
@@ -5098,10 +5610,25 @@ export async function startDaemon(opts: DaemonOptions) {
5098
5610
  case "hub_sdlc_record":
5099
5611
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
5100
5612
  return line(await tasks.recordSdlc(by, a.id, a.receipt));
5101
- case "hub_review":
5102
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
5103
- case "hub_remember":
5104
- return tasks.remember(by, a);
5613
+ case "hub_review": {
5614
+ const reviewed = await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings);
5615
+ // #1279: a verdict note that names a brief entry is a citation. The same exclusions as a done summary apply (a PII or review-observer turn, withheld text), and the key binds it to this verdict.
5616
+ const verdict = reviewed.history.findLast(h => h.by === by && (h.event === "approved" || h.event === "changes_requested"));
5617
+ if (!piiTurn && !reviewTurn && !tasks.isPii(reviewed) && verdict && !verdict.withheld && typeof a.note === "string" && a.note && verdict.note?.startsWith(a.note)) {
5618
+ citeMemory(by, a.note, `review:${reviewed.id}:${reviewed.history.indexOf(verdict)}`, [newEnvelope(by, "", { refs: { task: String(reviewed.id) } })]);
5619
+ }
5620
+ return line(reviewed);
5621
+ }
5622
+ case "hub_remember": {
5623
+ const { message: saved, outcome } = await tasks.rememberOutcome(by, a);
5624
+ // #1279: a saved note that names another entry cites it. `tasks.remember` already refused a PII task, a private path and a secret, so only a note it saved gets here.
5625
+ // #802: the structured outcome decides, never the reply wording. Only `not_saved` skips the citation: a candidate note held for a person's review was cited under the old wording test too, and stays so.
5626
+ if (!piiTurn && !reviewTurn && typeof a.text === "string" && a.text && outcome !== "not_saved") {
5627
+ const task = a.task !== undefined && Number.isSafeInteger(Number(a.task)) ? Number(a.task) : undefined;
5628
+ citeMemory(by, a.text, `remember:${task ?? "none"}:${createHash("sha256").update(a.text).digest("hex").slice(0, 16)}`, task !== undefined ? [newEnvelope(by, "", { refs: { task: String(task) } })] : []);
5629
+ }
5630
+ return saved;
5631
+ }
5105
5632
  case "hub_checkpoint": {
5106
5633
  const summary = String(a.summary ?? "").trim();
5107
5634
  if (!summary) throw new Error("summary is required");
@@ -5509,6 +6036,24 @@ export async function startDaemon(opts: DaemonOptions) {
5509
6036
  // Question text lives in hub.db and the envelope to the asking peer only: never a log line, an event, a notice or memory capture.
5510
6037
  const questionStore = new QuestionStore(join(opts.stateDir, "hub.db"));
5511
6038
  startupCleanup.push(() => questionStore.close());
6039
+ const questionAttributions = new Map<string, TaskAttribution>();
6040
+ /**
6041
+ * The attribution of a question event. It is resolved ONLY when the question opens (`asked`, or `unavailable`, which has no set), by the rule `turn_end` uses, and cached by set. Every later event
6042
+ * of a set repeats the cached answer. A set with no cached answer (the hub restarted while the question was open, which `QuestionDesk.repair` leaves answerable, or the bounded map evicted it)
6043
+ * is `unattributed`: re-resolving it would put the person's answer, with its full latency, on whatever task the peer works on NOW, which is the read-time attribution this exists to prevent.
6044
+ */
6045
+ const withQuestionAttribution = (e: Extract<HubEvent, { type: "question" }>): HubEvent => {
6046
+ let attribution = questionAttributions.get(e.set);
6047
+ if (!attribution) {
6048
+ if (e.event === "asked" || e.event === "unavailable") {
6049
+ attribution = taskAttribution(e.peer);
6050
+ if (e.set !== "-") { questionAttributions.set(e.set, attribution); if (questionAttributions.size > 512) questionAttributions.delete(questionAttributions.keys().next().value!); }
6051
+ } else attribution = { attribution: "unattributed" };
6052
+ }
6053
+ // A closed question's entry is released; a pending one keeps it for its answer.
6054
+ if (e.event === "answered" || e.event === "superseded" || e.event === "cancelled" || e.event === "expired") questionAttributions.delete(e.set);
6055
+ return { ...e, ...attribution };
6056
+ };
5512
6057
  const questionDesk = new QuestionDesk({
5513
6058
  store: questionStore, now: () => Date.now(),
5514
6059
  waitMs: peer => config.questions.wait_ms[definitionOf(peer)?.kind ?? peer] ?? definitionOf(peer)?.questionWaitMs ?? 45_000,
@@ -5519,7 +6064,10 @@ export async function startDaemon(opts: DaemonOptions) {
5519
6064
  isPrivate: (peer, task, piiTurn) => piiTurn === true || contextPii(peer) || (task !== undefined && (() => { const row = board.get(task); return !row || tasks.isPii(row); })()),
5520
6065
  held: () => stopping || recoveryActive() || bus.isRecoveryHeld,
5521
6066
  publish: env => { bus.publish(env); },
5522
- event: e => { event(e); if (e.type === "question" && e.event !== "asked" && e.event !== "pending" && e.event !== "unavailable") queueMicrotask(() => settleCheck(e.peer)); }, // #1054: a closed question may be the last thing a run waited on
6067
+ // #1278: a question is attributed once, when it is asked, by the rule `turn_end` uses (the task of the delivery the peer is working on, else its single open task, else unattributed), and
6068
+ // every later event of the same set carries that same answer: the person answers minutes later, when the peer's open tasks may have changed. The task a question names for itself is the
6069
+ // peer's claim and is not used. An `unavailable` question has no set and is attributed on the spot.
6070
+ event: e => { event(e.type === "question" ? withQuestionAttribution(e) : e); if (e.type === "question" && e.event !== "asked" && e.event !== "pending" && e.event !== "unavailable") queueMicrotask(() => settleCheck(e.peer)); }, // #1054: a closed question may be the last thing a run waited on
5523
6071
  peerKnown: peer => bus.peers.has(peer) || (bus.knownPeers().includes(peer) && membershipAllowed(peer)),
5524
6072
  });
5525
6073
  // #1053: expiry (`questions.ttl_s`), a changed native session, an envelope a refused publish still owes, and old settled rows. Pushed here, after the desk exists, like the other late timers.
@@ -5531,6 +6079,8 @@ export async function startDaemon(opts: DaemonOptions) {
5531
6079
  startupCleanup.push(() => completionStore.close());
5532
6080
  const runs = new RunTracker();
5533
6081
  const checkOwners = new Map<PeerId, number>();
6082
+ const nativeBackgroundWaits = new Map<PeerId, ReturnType<typeof setTimeout>>();
6083
+ const nativeBackgroundBackoff = new Map<PeerId, number>();
5534
6084
  /**
5535
6085
  * A delivery handed to the peer whose turn has not started yet: the bus has already taken it out of its queue and the peer is still idle, so neither the queue nor the busy state
5536
6086
  * shows the continuation in that window. Cleared at the turn start or a failed delivery. ponytail: bounded to 10 s, so a delivery that never starts a turn (an adapter that
@@ -5541,8 +6091,8 @@ export async function startDaemon(opts: DaemonOptions) {
5541
6091
  /** A turn of the open run ended in a failure the adapter reported: a closed fact, never its text. */
5542
6092
  const runFailed = (peer: PeerId): void => { try { runs.note(peer, { failed: true }); } catch { /* advisory */ } };
5543
6093
  const settles = (peer: PeerId): boolean => {
5544
- // A pull-only attach (Kimi's TUI) has no hub turn machinery and no signal; the definition declares `settle` for its headless form, which attaches as a hub peer.
5545
- return !!definitionOf(peer)?.settle && !peer.includes("@") && bus.peers.get(peer)?.pullOnly !== true;
6094
+ // A pull-only native TUI earns a signal only through its launcher-bound native facts. An unattached pull-only peer still creates no run.
6095
+ return !!definitionOf(peer)?.settle && !peer.includes("@") && (bus.peers.get(peer)?.pullOnly !== true || nativeHookLaunches.current(peer) !== undefined);
5546
6096
  };
5547
6097
  /** Deliveries queued for the peer that will start another turn: all of them but the hub's own `presence` preface (a `fyi` never reaches the queue). */
5548
6098
  // The bus drains its queue into a dispatching journal row before the idle state reaches this tap, so the in-memory queue alone would read empty while a continuation is already on its
@@ -5562,10 +6112,37 @@ export async function startDaemon(opts: DaemonOptions) {
5562
6112
  const checksOwnedBy = (peer: PeerId): number => (checkOwners.get(peer) ?? 0) + tasks.checksOwnedBy(peer);
5563
6113
  // The peer's OWN adapter state, not the displayed one (a pause shows a busy peer as paused, and its work goes on): an owned child or a continuation the adapter still runs keeps it busy.
5564
6114
  const ownedChildrenOf = (peer: PeerId): number => (bus.peers.get(peer)?.state === "busy" ? 1 : 0);
6115
+ /**
6116
+ * #1278: the task a run's completion belongs to, resolved when the run opens (its first turn) by the rule `turn_end` uses and read when it settles, by which time the peer's open tasks have
6117
+ * moved on. In memory only: a run that a restart settles `interrupted` has lost it and reads `unattributed`, which is what is known.
6118
+ */
6119
+ const runAttributions = new Map<string, TaskAttribution>();
6120
+ const nativeBackgroundOf = (peer: PeerId): number | null | undefined => {
6121
+ const launch = nativeHookLaunches.current(peer), port = definitionOf(peer)?.hooks?.factsHook;
6122
+ if (!launch?.session || !launch.transcript || !port) return undefined;
6123
+ return port.read(launch.session, launch.transcript).background ?? null;
6124
+ };
6125
+ const completeNativeTurn = (peer: PeerId, launch: NativeHookLaunch, completion: NonNullable<NativeFactsTranscript["completion"]>): boolean => {
6126
+ const owner = bus.peers.get(peer);
6127
+ if (!launch.active || !isWsPeer(owner) || owner.state === "offline" || owner.sessionGeneration !== launch.generation || nativeHookLaunches.current(peer) !== launch) return false;
6128
+ launch.completed = completion.id; launch.active = false;
6129
+ turnEnded.set(peer, Date.now()); nativeCompletedAt.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
6130
+ event({ type: "native_turn_end", peer, id: completion.id }); owner.nativeStopped(launch.session!);
6131
+ const run = runs.get(peer);
6132
+ if (completion.reason === "cancelled") {
6133
+ if (run) { completionStore.dropRun(run.runTurn); runs.quietStop(peer); }
6134
+ } else {
6135
+ if (completion.reason === "failed") runFailed(peer);
6136
+ try { if (run) { runs.signalled(peer); settleCheck(peer); } } catch { log(`completion settle for ${peer} failed`); }
6137
+ }
6138
+ return true;
6139
+ };
5565
6140
  const completionDone = (peer: PeerId, run: Run, outcome: "done" | "failed" | "limit" | "interrupted"): void => {
5566
6141
  const made = completionStore.settle({ peer, runTurn: run.runTurn, lastTurn: run.lastTurn, startedAt: run.startedAt, ...(run.promptEnvelope ? { promptEnvelope: run.promptEnvelope } : {}), ...(run.replyEnvelope ? { replyEnvelope: run.replyEnvelope } : {}), private: run.private }, outcome, Date.now(), `cmp-${randomUUID().replaceAll("-", "").slice(0, 12)}`);
5567
6142
  if (!made) return;
5568
- event({ type: "completion", id: made.id, peer, outcome, ms: made.settledAt - made.startedAt });
6143
+ const attribution = runAttributions.get(run.runTurn) ?? { attribution: "unattributed" as const };
6144
+ runAttributions.delete(run.runTurn);
6145
+ event({ type: "completion", id: made.id, peer, outcome, ms: made.settledAt - made.startedAt, ...attribution });
5569
6146
  };
5570
6147
  /**
5571
6148
  * The one settle check. It runs at every point the pending-work facts can change: a turn end, a queue change, a permission or question closing, a check ending. A run settles
@@ -5573,10 +6150,29 @@ export async function startDaemon(opts: DaemonOptions) {
5573
6150
  */
5574
6151
  const settleCheck = (peer: PeerId): void => {
5575
6152
  try {
6153
+ const launch = nativeHookLaunches.current(peer), port = definitionOf(peer)?.hooks?.factsHook;
6154
+ if (launch?.active && launch.session && launch.transcript && port) {
6155
+ const completion = port.read(launch.session, launch.transcript).completion;
6156
+ if (completion && completion.index > (launch.baseline ?? -1) && completion.id !== launch.completed) completeNativeTurn(peer, launch, completion);
6157
+ }
5576
6158
  const run = runs.get(peer);
5577
6159
  if (!run || stopping) return;
5578
- const state = settleVerdict({ signal: run.signalled, queuedContinuations: queuedContinuations(peer), openPermissions: openPermissionsOf(peer), openQuestions: questionStore.openCount(peer), ownedChildren: ownedChildrenOf(peer), runningChecks: checksOwnedBy(peer), heldDeliveries: heldDeliveries(peer) });
5579
- if (state !== "settled") return;
6160
+ const nativeBackground = nativeBackgroundOf(peer);
6161
+ const state = settleVerdict({ signal: run.signalled, queuedContinuations: queuedContinuations(peer), openPermissions: openPermissionsOf(peer), openQuestions: questionStore.openCount(peer), ownedChildren: ownedChildrenOf(peer), runningChecks: checksOwnedBy(peer), heldDeliveries: heldDeliveries(peer), nativeBackground });
6162
+ if (state !== "settled") {
6163
+ // Native task notifications are optional observations. Re-read the inventory and final turn until both are settled or the run/launch ends.
6164
+ // ponytail: a quiet peer backs off from 250 ms to 1 s while each read may scan a 16 MiB index and an 8 MiB wire tail. Upgrade: incremental offsets.
6165
+ if ((launch?.active || nativeBackground === null || (nativeBackground ?? 0) > 0) && !nativeBackgroundWaits.has(peer)) {
6166
+ const delay = nativeBackgroundBackoff.get(peer) ?? 250;
6167
+ const timer = setTimeout(() => { nativeBackgroundWaits.delete(peer); settleCheck(peer); }, delay);
6168
+ nativeBackgroundBackoff.set(peer, Math.min(1000, delay * 2));
6169
+ nativeBackgroundWaits.set(peer, timer); timer.unref?.();
6170
+ }
6171
+ return;
6172
+ }
6173
+ const waiting = nativeBackgroundWaits.get(peer);
6174
+ if (waiting) { clearTimeout(waiting); nativeBackgroundWaits.delete(peer); }
6175
+ nativeBackgroundBackoff.delete(peer);
5580
6176
  // Persist FIRST and drop the in-memory run only after the store call returned: a write that throws (SQLite busy or locked) leaves the run open, so a later settle check retries it, and
5581
6177
  // the open-run row never outlives a run the daemon no longer tracks (a false `interrupted` after a restart, and a peer index that silently refuses the next run).
5582
6178
  // A budget pause stays a budget need (ahub budget): the run ends without a completion rather than reading as a result.
@@ -5886,7 +6482,11 @@ export async function startDaemon(opts: DaemonOptions) {
5886
6482
  join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", opts.cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`), path => pathEvidence(path, lstatSync));
5887
6483
  // Optional native usage: only the instance-fenced explicit Claude session is read.
5888
6484
  // IDs persist in telemetry, so a restart/resume never counts the same native message twice.
5889
- const claudeNativeUsageSeen = new Set(readEvents(join(opts.stateDir, "events.jsonl")).filter(e => e.type === "usage" && e.source === "claude_transcript").map(e => (e as { id: string }).id));
6485
+ startupTimings.mark("services");
6486
+ const claudeNativeUsageSeen = startupSeeds.claudeNativeUsageSeen;
6487
+ startupTimings.mark("transcript_usage_seed");
6488
+ const agyNativeUsageSeen = new Set(startupEvents.filter(e => e.type === "usage" && e.source === "agy_transcript").map(e => (e as { id: string }).id));
6489
+ const agyTurnContext = new WeakMap<object, { hubTurnId?: string; baseline: number; attribution: TaskAttribution }>();
5890
6490
  const collectClaudeUsage = () => { for (const peer of channelPeers) {
5891
6491
  const session = claudeSession(peer);
5892
6492
  if (!session.sessionId || !session.transcriptPath) continue;
@@ -5922,6 +6522,151 @@ export async function startDaemon(opts: DaemonOptions) {
5922
6522
  // the newest `older` of them while they are empty, so an upgrade from such a hub verifies the board it was handed; a
5923
6523
  // filled one stays in the row and never matches.
5924
6524
  const ADDED_COLUMNS = [["deps", "[]"], ["plan", "{}"]] as const;
6525
+ // Person-owned runtime choices are separate from reload-bound configuration and native serving evidence.
6526
+ const modelBoundaries = new Map<string, { owner: PeerAdapter; token: object; turn?: object; nativeStarted?: true }>();
6527
+ const modelUnsupported = (peer: string): RuntimeModelStatus["unsupported"] | undefined => {
6528
+ const definition = definitionOf(peer), owner = bus.peers.get(peer);
6529
+ if (definition && isFixedBuiltinDefinition(definition) && definition.transport === "acp") {
6530
+ const state = owner ? modelAcpOwners.get(owner)?.modelOverrideState : undefined;
6531
+ if (!definition.modelOverride?.acp || !state?.available) return {
6532
+ reasonCode: "acp_setter_unavailable", kind: definition.kind, ...(state?.version ? { version: state.version } : {}),
6533
+ reason: `unsupported live: ${definition.kind} ${state?.version ?? "unknown"} lacks a setter receipt or compat admission`,
6534
+ nextStep: `restart this peer with ahub restart ${peer}`,
6535
+ };
6536
+ }
6537
+ if (!definition || !isFixedBuiltinDefinition(definition) || !definition.modelOverride) return {
6538
+ reasonCode: "unverified_setter", ...(definition ? { kind: definition.kind } : {}),
6539
+ reason: `unsupported live: ${definition?.kind ?? "retained peer"} (version unverified) has no verified model setter`, nextStep: `restart this peer with ahub restart ${peer}`,
6540
+ };
6541
+ if (owner && !modelManagedOwners.has(owner)) return { reasonCode: "unmanaged_attachment", kind: definition.kind, reason: "unsupported live: this attachment has no verified live model setter", nextStep: `change the native setting or restart this peer with ahub restart ${peer}` };
6542
+ return undefined;
6543
+ };
6544
+ const modelTokenAvailable = (peer: string, model: string): boolean => {
6545
+ if (!definitionOf(peer)?.modelOverride?.acp) return true;
6546
+ const owner = bus.peers.get(peer);
6547
+ return !!owner && modelAcpOwners.get(owner)?.acceptsModelOverride(model) === true;
6548
+ };
6549
+ const modelFallback = (peer: string): { model?: string; source: Exclude<ModelSource, "override"> } => {
6550
+ const declared = declarations[peer]?.model;
6551
+ if (isNativeModelToken(declared)) return { model: declared, source: "instance" };
6552
+ const owner = bus.peers.get(peer), model = owner ? modelLaunchModels.get(owner) : undefined;
6553
+ if (model !== undefined) return { model, source: "launch" };
6554
+ const baseline = owner ? modelAcpOwners.get(owner)?.modelOverrideState.baseline : undefined;
6555
+ return { ...(baseline ? { model: baseline } : {}), source: "native default" };
6556
+ };
6557
+ const registerModelOwner = (owner: PeerAdapter): void => {
6558
+ const definition = definitionOf(owner.id);
6559
+ if (!definition || !isFixedBuiltinDefinition(definition) || !definition.modelOverride) return;
6560
+ if (definition.modelOverride.acp && !modelAcpOwners.get(owner)?.modelOverrideState.available) return;
6561
+ modelManagedOwners.add(owner);
6562
+ try {
6563
+ const launch = owner.recoveryMetadata?.()?.launch;
6564
+ const model = launch && typeof launch === "object" && !Array.isArray(launch) ? (launch as Record<string, unknown>).model : undefined;
6565
+ if (isNativeModelToken(model)) modelLaunchModels.set(owner, model);
6566
+ } catch { /* No explicit launch evidence: leave native default unknown. Never read this hook on a poll. */ }
6567
+ };
6568
+ const runtimeModelRow = (peer: string) => modelLatches.status(peer, bus.peers.get(peer), modelFallback(peer), modelUnsupported(peer));
6569
+ const MODEL_DISPLAY_ROWS_MAX = 512;
6570
+ const runtimeModelRows = (): RuntimeModelStatus[] | undefined => {
6571
+ const ids = new Set<string>();
6572
+ const add = (id: string) => { if (PEER_ID.test(id)) ids.add(id); return ids.size <= MODEL_DISPLAY_ROWS_MAX; };
6573
+ // Stop at the first overflow: existing legacy attachments are not admission-bounded by this display ceiling.
6574
+ for (const id of SUPPORTED_PEERS) if (!add(id)) return undefined;
6575
+ for (const id of configuredIds) if (!add(id)) return undefined;
6576
+ for (const id of extraIds) if (!add(id)) return undefined;
6577
+ for (const peer of restored?.peers ?? []) if (peer.externalInstance && !add(peer.id)) return undefined;
6578
+ for (const id of externalInstanceIds) if (!add(id)) return undefined;
6579
+ for (const id of externalRefusedRows.keys()) if (!add(id)) return undefined;
6580
+ for (const id of bus.peers.keys()) if (!add(id)) return undefined;
6581
+ for (const row of modelOverrides.list()) if (!add(row.peer)) return undefined;
6582
+ return [...ids].sort().map(runtimeModelRow);
6583
+ };
6584
+ const runtimeModelProjection = () => {
6585
+ try { const rows = runtimeModelRows(); if (rows) return { runtimeModels: rows }; }
6586
+ catch { /* Model metadata failure is not whole-daemon status failure. The explicit list keeps its refusal. */ }
6587
+ return { runtimeModels: [] as RuntimeModelStatus[], runtimeModelsUnavailable: true as const };
6588
+ };
6589
+ const modelSelection = (peer: string) => {
6590
+ const owner = bus.peers.get(peer);
6591
+ if (!owner || modelUnsupported(peer)) throw new Error("model selection has no current supported owner; inspect ahub status and restart the peer before retrying");
6592
+ let boundary = modelBoundaries.get(peer);
6593
+ if (!boundary || boundary.owner !== owner) {
6594
+ if (boundary) modelLatches.forget(peer);
6595
+ boundary = { owner, token: {}, ...(turns.get(peer) ? { turn: turns.get(peer) } : {}) };
6596
+ modelBoundaries.set(peer, boundary);
6597
+ }
6598
+ const captured = boundary;
6599
+ const selection = modelLatches.capture(peer, owner, captured.token, () => !stopping && bus.peers.get(peer) === owner
6600
+ && modelBoundaries.get(peer) === captured && (!captured.turn || turns.get(peer) === captured.turn));
6601
+ return { model: selection.model, applied: (nativeModel?: string) => { selection.applied(nativeModel); if (!stopping) writeStatus(); }, rejected: () => {
6602
+ if (stopping || bus.peers.get(peer) !== owner || modelBoundaries.get(peer) !== captured || captured.nativeStarted
6603
+ || captured.turn && turns.get(peer) !== captured.turn) return;
6604
+ if (modelLatches.end(peer, captured.token, true)) { modelBoundaries.delete(peer); writeStatus(); }
6605
+ } };
6606
+ };
6607
+ const acpModelSelection = (peer: string) => {
6608
+ if (modelUnsupported(peer)) {
6609
+ if (modelOverrides.get(peer)) throw new Error("retained model override has no current receipt-verified ACP setter; clear it with ahub model clear or restart a supported peer before retrying");
6610
+ return undefined; // An unavailable optional setter must not break ordinary no-override ACP work.
6611
+ }
6612
+ return modelSelection(peer);
6613
+ };
6614
+ type ModelGrant = { source: "cli" | "dashboard"; scope: object | string; current: () => boolean; expiresAt?: number };
6615
+ type ModelPreview = { peer: string; action: "set" | "clear"; model?: string; expiresAt: number; scope: object | string; source: "cli" | "dashboard";
6616
+ revision: string; runtime: string; config: number; owner: PeerAdapter | undefined; generation: string | undefined; afterTurn: string | undefined };
6617
+ const modelPreviews = new Map<string, ModelPreview>();
6618
+ const MODEL_CONFIRM_MS = 60_000, MODEL_PREVIEWS_MAX = 64;
6619
+ const modelFailure = (error: string) => ({ ok: false as const, error });
6620
+ function modelCommand(input: Record<string, unknown>, grant: ModelGrant): Record<string, unknown> {
6621
+ if (!grant.current()) return modelFailure("model changes are person-only and this authority expired; run ahub model in a person's terminal or reopen ahub ui --settings");
6622
+ if (stopping || recoveryActive() || bus.isRecoveryHeld) return modelFailure("recovery or shutdown is holding model overrides; inspect ahub status and retry after release");
6623
+ if (input.op === "list") {
6624
+ const rows = runtimeModelRows();
6625
+ return rows ? { ok: true, models: rows } : modelFailure("model list exceeds its display bound; close unused peer sessions and restart if needed, or clear unused overrides with ahub model clear, then list again");
6626
+ }
6627
+ let preview: ModelPreview;
6628
+ if (input.op === "confirm") {
6629
+ const token = typeof input.token === "string" ? input.token : "", retained = modelPreviews.get(token);
6630
+ if (!retained || retained.scope !== grant.scope || retained.source !== grant.source || retained.expiresAt <= Date.now()) return modelFailure("model confirmation expired or belongs to another session; preview the model change again");
6631
+ modelPreviews.delete(token); preview = retained;
6632
+ if (preview.config !== configRevision || preview.revision !== modelOverrides.revision(preview.peer) || preview.owner !== bus.peers.get(preview.peer)
6633
+ || preview.runtime !== JSON.stringify(runtimeModelRow(preview.peer)) || preview.generation !== connectionGeneration(preview.peer)
6634
+ || preview.afterTurn !== turns.get(preview.peer)?.id) return modelFailure("peer, configuration or model choice changed after preview; refresh and preview the change again");
6635
+ } else {
6636
+ const action = input.op === "preview" ? input.action : input.op, peer = input.peer;
6637
+ if ((action !== "set" && action !== "clear") || typeof peer !== "string" || !PEER_ID.test(peer)) return modelFailure("invalid model request; use model set <peer> <model> or model clear <peer>");
6638
+ if (!definitionOf(peer) && !(action === "clear" && modelOverrides.get(peer))) return modelFailure("model peer is unknown; run ahub model list and choose a current or retained peer");
6639
+ if (action === "set" && !isNativeModelToken(input.model)) return modelFailure("invalid native model token; use 1 to 128 ASCII letters, digits or ._:/@+- with an initial letter or digit");
6640
+ const unsupported = modelUnsupported(peer);
6641
+ if (action === "set" && unsupported) return modelFailure(`${unsupported.reason}; ${unsupported.nextStep}`);
6642
+ if (action === "set" && !modelTokenAvailable(peer, input.model as string)) return modelFailure("requested model is not advertised by this receipt-verified ACP session; inspect native model choices and retry");
6643
+ const expiresAt = Math.min(Date.now() + MODEL_CONFIRM_MS, grant.expiresAt ?? Infinity);
6644
+ preview = { peer, action, ...(action === "set" ? { model: input.model as string } : {}), expiresAt, scope: grant.scope, source: grant.source,
6645
+ revision: modelOverrides.revision(peer), runtime: JSON.stringify(runtimeModelRow(peer)), config: configRevision, owner: bus.peers.get(peer), generation: connectionGeneration(peer), afterTurn: turns.get(peer)?.id };
6646
+ if (input.op === "preview") {
6647
+ for (const [token, old] of modelPreviews) if (old.expiresAt <= Date.now()) modelPreviews.delete(token);
6648
+ if (modelPreviews.size >= MODEL_PREVIEWS_MAX) return modelFailure("too many model previews remain open; wait a minute and preview again");
6649
+ const token = randomUUID(); modelPreviews.set(token, preview);
6650
+ return { ok: true, model: runtimeModelRow(peer), confirmation: { token, expiresAt, peer, action, previousModel: modelOverrides.get(peer)?.model ?? null,
6651
+ nextModel: preview.model ?? null, appliesFrom: "next_turn", ...(preview.afterTurn ? { afterTurn: preview.afterTurn } : {}) } };
6652
+ }
6653
+ if (grant.source !== "cli") return modelFailure("dashboard model changes require preview and confirmation; preview the change again");
6654
+ }
6655
+ if (!grant.current()) return modelFailure("model authority expired before the write; reopen the person settings session and preview again");
6656
+ const unsupported = modelUnsupported(preview.peer);
6657
+ if (preview.action === "set" && unsupported) return modelFailure(`${unsupported.reason}; ${unsupported.nextStep}`);
6658
+ if (preview.action === "set" && !modelTokenAvailable(preview.peer, preview.model!)) return modelFailure("requested model is not advertised by this receipt-verified ACP session; inspect native model choices and retry");
6659
+ const previousModel = modelOverrides.get(preview.peer)?.model ?? null;
6660
+ if (preview.action === "set") modelOverrides.set(preview.peer, preview.model!); else modelOverrides.clear(preview.peer);
6661
+ const audited = event({ type: "model_change", peer: preview.peer, previousModel, model: preview.model ?? null, by: "person", source: grant.source,
6662
+ appliesFrom: "next_turn", ...(preview.afterTurn ? { afterTurn: preview.afterTurn } : {}) });
6663
+ writeStatus();
6664
+ if (!audited) return { ok: false, saved: true, error: "model override was saved but its model_change audit could not be appended; run ahub model list before retrying and repair events.jsonl" };
6665
+ return { ok: true, model: runtimeModelRow(preview.peer), appliesFrom: "next_turn", ...(preview.afterTurn ? { afterTurn: preview.afterTurn } : {}) };
6666
+ }
6667
+ const modelCommandSafe = (input: Record<string, unknown>, grant: ModelGrant): Record<string, unknown> => {
6668
+ try { return modelCommand(input, grant); } catch { return modelFailure("model override state could not be read or written; inspect ahub status and hub.db before retrying"); }
6669
+ };
5925
6670
  const integrity = (older = 0) => {
5926
6671
  const queues = Object.fromEntries(Object.keys(bus.snapshot(false).queues).sort().map((id) => [id, bus.queueIds(id)]));
5927
6672
  const boardState = board.list().sort((a, b) => a.id - b.id).map((t) => {
@@ -5937,7 +6682,11 @@ export async function startDaemon(opts: DaemonOptions) {
5937
6682
  const researchRunners = board.runnerReceiptIntegrity();
5938
6683
  const researchEvidence = board.researchEvidenceIntegrity();
5939
6684
  const reviewComments = board.reviewCommentsIntegrity();
5940
- return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(researchEvidence.links || researchEvidence.revisions ? { researchEvidence } : {}), ...(reviewComments.comments || reviewComments.packages ? { reviewComments } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
6685
+ const modelState = modelOverrides.integrity();
6686
+ const visualIntegrity = visuals.integrity();
6687
+ if (!Number.isSafeInteger(visualIntegrity.count) || visualIntegrity.count < 0 || !/^[a-f0-9]{64}$/.test(visualIntegrity.digest))
6688
+ throw new Error("visual recovery integrity unavailable; inspect the retained store before recovery");
6689
+ return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(modelState.count ? { modelOverrides: modelState } : {}), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(researchEvidence.links || researchEvidence.revisions ? { researchEvidence } : {}), ...(reviewComments.comments || reviewComments.packages ? { reviewComments } : {}), ...(visualIntegrity.count ? { visuals: visualIntegrity } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
5941
6690
  };
5942
6691
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
5943
6692
  const integrityAs = (expected: unknown) => {
@@ -6022,6 +6771,7 @@ export async function startDaemon(opts: DaemonOptions) {
6022
6771
  const deliveryFailures = bus.failedDeliveryCounts();
6023
6772
  const view = {
6024
6773
  ...(deliveryFailures ? { deliveryFailures } : {}),
6774
+ ...runtimeModelProjection(),
6025
6775
  ...(queueBackpressure ? { queueBackpressure } : {}),
6026
6776
  ...(crashLines.length ? { crash: crashLines } : {}),
6027
6777
  projectId,
@@ -6068,7 +6818,7 @@ export async function startDaemon(opts: DaemonOptions) {
6068
6818
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
6069
6819
  codexProxyPort: opts.codexProxyPort,
6070
6820
  peers: Object.fromEntries(
6071
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const pressure = queueBackpressure?.peers[id]; const oldestQueuedAt = pressure ? pressure.oldestQueuedAt : summary.oldestQueuedAt; const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; const reviewObserverHoldNote = reviewObserverSessionIdentityHoldNote(id); return [id, { state: bus.stateOf(id), ...(includeAttentionItems && isExternalInstance(id) && !externalRefusedRows.has(id) && retainedExternalBinding(id) ? { externalInstance: retainedExternalBinding(id) } : {}), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { statusNotice } : {}), ...(reviewObserverHoldNote ? { reviewObserverHoldNote } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(includeAttentionItems && p?.standbySessions?.length ? { standbySessions: p.standbySessions } : {}), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: ownerIsolation[id] === true, ...(ownerIsolation[id] !== true ? { 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), ...(typeof oldestQueuedAt === "number" ? { oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(id) } : {}), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) ? { ...(p.claiming ? { claiming: true } : {}), ...(definitionOf(id)?.transport === "claude-channel" && p.attached ? { attachMode: p.attachMode, attachEvidence: p.attachEvidence } : {}) } : {}), ...(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 ?? [] } : {}) }]; }),
6821
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const pressure = queueBackpressure?.peers[id]; const oldestQueuedAt = pressure ? pressure.oldestQueuedAt : summary.oldestQueuedAt; const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); const statusNotice = isFixedBuiltinDefinition(definitionOf(id)) ? p?.statusNotice : undefined; const reviewObserverHoldNote = reviewObserverSessionIdentityHoldNote(id); return [id, { state: bus.stateOf(id), ...(includeAttentionItems && isExternalInstance(id) && !externalRefusedRows.has(id) && retainedExternalBinding(id) ? { externalInstance: retainedExternalBinding(id) } : {}), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(statusNotice ? { statusNotice } : {}), ...(reviewObserverHoldNote ? { reviewObserverHoldNote } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(includeAttentionItems && p?.standbySessions?.length ? { standbySessions: p.standbySessions } : {}), ...(includeAttentionItems && p?.ownerAttachment ? { ownerAttachment: p.ownerAttachment } : {}), ...(declarations[id] && config.roles[id]?.includes("implementer") && definitionOf(id)?.transport === "claude-channel" ? { ownerIsolation: ownerIsolation[id] === true, ...(ownerIsolation[id] !== true ? { 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), ...(typeof oldestQueuedAt === "number" ? { oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(reviewOnlyPeer(id) ? { reviewScope: reviewScopeOf(id) } : {}), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) ? { ...(p.claiming ? { claiming: true } : {}), ...(definitionOf(id)?.transport === "claude-channel" && p.attached ? { attachMode: p.attachMode, attachEvidence: p.attachEvidence } : {}) } : {}), ...(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 ?? [] } : {}) }]; }),
6072
6822
  ),
6073
6823
  taskWip: tasks.wipBreakdown(peerIds),
6074
6824
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
@@ -6460,6 +7210,7 @@ export async function startDaemon(opts: DaemonOptions) {
6460
7210
  uiEvents.push({ seq: ++uiSequence, event: e });
6461
7211
  if (uiEvents.length > 200) uiEvents.shift();
6462
7212
  if (e.t === "state") {
7213
+ if (e.state === "offline") { modelBoundaries.delete(e.peer); modelLatches.forget(e.peer); }
6463
7214
  roomCommands.changed();
6464
7215
  log(`state ${e.peer} -> ${e.state}`);
6465
7216
  event({ type: "state", peer: e.peer, state: e.state });
@@ -6483,7 +7234,7 @@ export async function startDaemon(opts: DaemonOptions) {
6483
7234
  const delivery = pendingDeliveryTask.get(e.peer);
6484
7235
  pendingDeliveryTask.delete(e.peer);
6485
7236
  const attribution = taskAttribution(e.peer, delivery);
6486
- const id = `${e.peer}#${runId}.${++turnSeq}`;
7237
+ const id = `${e.peer}#${runId}.${++turnSeq}`, startedAt = Date.now();
6487
7238
  // A turn of a peer with a PII task open is not snapshotted: what it writes would stay in git's object store until
6488
7239
  // gc. It is still recorded, without trees, so an overlapping turn's undo knows its changes are unknown.
6489
7240
  const pii = holdsPii(e.peer);
@@ -6499,7 +7250,11 @@ export async function startDaemon(opts: DaemonOptions) {
6499
7250
  writeScope = ownerWriteRoot(e.peer) === opts.cwd ? canonicalWriteScope(start.repo, start.budget) : true;
6500
7251
  } catch (error) { snapshotFailure(`the write scope of ${id}`, error); }
6501
7252
  const preparedRoles = preparedSessionRoleIdentities.get(e.peer)?.map(role => ({ ...role })) ?? null;
6502
- turns.set(e.peer, { id, capture, start: Date.now(), tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
7253
+ turns.set(e.peer, { id, capture, start: startedAt, tokens: 0, writeTasks, branches, writeScope, deliveryTask: delivery, attribution, preparedRoles, ...(start ? { tree: start.tree, head: start.head, commit: start.commit, reflog: start.reflog, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
7254
+ const modelBoundary = modelBoundaries.get(e.peer);
7255
+ if (modelBoundary && modelBoundary.owner === bus.peers.get(e.peer)) modelBoundary.turn = turns.get(e.peer);
7256
+ const supervised = supervisionTurns.get(e.peer);
7257
+ if (supervised?.id.startsWith("supervision-")) supervised.nativeTurn = id;
6503
7258
  const pendingReviewInput = pendingReviewObserverInputs.get(e.peer);
6504
7259
  if (pendingReviewInput) {
6505
7260
  pendingReviewInput.turnId = id; delete pendingReviewInput.awaitingTurn;
@@ -6520,16 +7275,23 @@ export async function startDaemon(opts: DaemonOptions) {
6520
7275
  try {
6521
7276
  deliveredAwaitingTurn.delete(e.peer);
6522
7277
  if (settles(e.peer)) {
6523
- const person = pendingPersonDelivery.get(e.peer);
7278
+ const person = pendingPersonDelivery.get(e.peer), nativePerson = pendingNativePerson.delete(e.peer);
6524
7279
  pendingPersonDelivery.delete(e.peer);
6525
- const run = runs.turnStarted({ peer: e.peer, turn: id, at: Date.now(), personOriginated: person !== undefined, ...(person ? { promptEnvelope: person.envelope } : {}), private: person?.private === true || pii || holdsPii(e.peer) });
6526
- if (run) { if (run.runTurn === id) completionStore.openRun(run); else completionStore.touchRun(run); }
7280
+ const run = runs.turnStarted({ peer: e.peer, turn: id, at: Date.now(), personOriginated: person !== undefined || nativePerson, ...(person ? { promptEnvelope: person.envelope } : {}), private: nativePerson || person?.private === true || pii || holdsPii(e.peer) });
7281
+ if (run) {
7282
+ if (run.runTurn === id) { completionStore.openRun(run); runAttributions.set(id, attribution); } else completionStore.touchRun(run);
7283
+ // A run that never settles in this process would keep its entry; the table only ever holds the open runs, one per settling peer.
7284
+ if (runAttributions.size > 256) runAttributions.delete(runAttributions.keys().next().value!);
7285
+ }
6527
7286
  }
6528
7287
  } catch { log(`completion run start for ${e.peer} failed; no completion for this turn`); }
6529
- if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer); } catch { /* counting never costs a turn */ }
7288
+ if (softTokenPeer(e.peer)) try { kimiUsage.turnStarted(e.peer, { id, startedAt, attribution }); } catch { /* counting never costs a turn */ }
6530
7289
  } else if (!busy && open) {
6531
7290
  collectKimiUsage(e.peer); // before the turn closes: its rows count toward the turn's tokens
7291
+ if (softTokenPeer(e.peer)) try { kimiUsage.turnEnded(e.peer, open.id, Date.now()); } catch { /* counting never costs a turn */ }
7292
+ closeRecordFrame(e.peer); recordInputs.delete(e.peer);
6532
7293
  turns.delete(e.peer);
7294
+ modelBoundaries.delete(e.peer); modelLatches.end(e.peer);
6533
7295
  let afterTurn: (() => void) | undefined;
6534
7296
  let files: number | undefined;
6535
7297
  let writeProgress: boolean | undefined;
@@ -6605,9 +7367,10 @@ export async function startDaemon(opts: DaemonOptions) {
6605
7367
  // After the turn_end event: a notice delivered at once starts the peer's next turn, which must come after it.
6606
7368
  if (changed.length) afterTurn = () => detectConflicts(e.peer, open.id, open.start, changed);
6607
7369
  }
6608
- // A lost Claude channel/watchdog is not native completion; only its real Stop closes a counted turn.
6609
- if (definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...open.attribution, preparedRoles: open.preparedRoles, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
6610
- if (definitionOf(e.peer)?.turnEnd !== "native-hook") { // Claude's native turn end is its Stop hook
7370
+ // A lost Claude channel/watchdog is not native completion; only its real Stop closes a counted turn. A launcher-bound pull-only native turn follows the same rule.
7371
+ const nativeFactsTurn = nativeHookLaunches.current(e.peer) !== undefined && bus.peers.get(e.peer)?.pullOnly === true;
7372
+ if (nativeFactsTurn ? (nativeCompletedAt.get(e.peer) ?? -1) >= open.start : definitionOf(e.peer)?.turnEnd !== "native-hook" || (nativeCompletedAt.get(e.peer) ?? -1) >= open.start) event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...open.attribution, preparedRoles: open.preparedRoles, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
7373
+ if (!nativeFactsTurn && definitionOf(e.peer)?.turnEnd !== "native-hook") { // Claude's native turn end is its Stop hook
6611
7374
  if ((bus.peers.get(e.peer)?.state ?? e.state) === "idle") queueMicrotask(() => finishSupervisionTurn(e.peer));
6612
7375
  turnEnded.set(e.peer, Date.now());
6613
7376
  tasks.cohorts.turnEnded(e.peer);
@@ -6622,7 +7385,7 @@ export async function startDaemon(opts: DaemonOptions) {
6622
7385
  try { tasks.recordOwnerTurn(e.peer, open.writeTasks, changed, !!open.taskEffect || !!open.effectsPending); } catch { /* advisory cannot fail native completion */ }
6623
7386
  }
6624
7387
  }
6625
- if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer); kimiUsage.drop(e.peer); }
7388
+ if (e.state === "offline" && softTokenPeer(e.peer)) { collectKimiUsage(e.peer, true); kimiUsage.drop(e.peer); }
6626
7389
  // #636: a class-budget allowance belongs to the owner that earned it. A stopped, crashed or removed owner that later resumes the same native
6627
7390
  // session id starts from zero (a replaced adapter's own `offline` is muted, so a new owner also revokes where it is created).
6628
7391
  if (e.state === "offline") actionBudgets.revoke(e.peer);
@@ -6753,6 +7516,9 @@ export async function startDaemon(opts: DaemonOptions) {
6753
7516
  const timeoutMs = opts.permissionTimeoutMs ?? approvalTimeoutS * 1000;
6754
7517
  const createdAt = Date.now();
6755
7518
  const expiresAt = createdAt + timeoutMs;
7519
+ // #1278: the task this request belongs to, resolved once when it opens by the rule `turn_end` and `tokens` use. The settle event reuses it, because the turn can have ended by then and the
7520
+ // peer's open tasks changed. Ids and a closed rule name only; unattributed stays unattributed.
7521
+ const requestAttribution = taskAttribution(req.peer);
6756
7522
  const publicTool = typeof req.tool === "string" && /^[A-Za-z][A-Za-z0-9_.:/-]{0,127}$/.test(req.tool) && !/\s/.test(req.tool) ? req.tool : undefined;
6757
7523
  log(`permission ${id} requested by ${req.peer} (${title.length} chars, shown on the console; cancelled after ${timeoutMs / 1000}s)`);
6758
7524
  // The tool name is for the desktop notice only: the console push keeps its shape.
@@ -6781,7 +7547,7 @@ export async function startDaemon(opts: DaemonOptions) {
6781
7547
  if (outcome === "expired") { try { notify(`permission ${id} from ${req.peer} was not answered within ${Math.round(timeoutMs / 1000)}s and was cancelled`); } catch { /* registry withdrawal already completed */ } }
6782
7548
  const latencyMs = Math.max(0, Date.now() - createdAt);
6783
7549
  const optionKind = option?.kind === "allow_once" || option?.kind === "allow_always" || option?.kind === "reject_once" || option?.kind === "reject_always" ? option.kind : undefined;
6784
- try { event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}) }); } catch { try { log(`permission event recording failed at settlement for ${req.peer}`); } catch { /* settlement remains authoritative */ } }
7550
+ try { event({ type: "permission", id, peer: req.peer, event: outcome, latencyMs, ...(surface ? { surface } : {}), ...(optionKind ? { option: optionKind } : {}), ...requestAttribution }); } catch { try { log(`permission event recording failed at settlement for ${req.peer}`); } catch { /* settlement remains authoritative */ } }
6785
7551
  try { notify(`permission ${id} from ${req.peer} ${outcome} option ${optionKind ?? "none"} by ${surface ?? "hub"} (${latencyMs}ms)`); } catch { /* no reporting failure strands the caller */ }
6786
7552
  for (const c of consoles) if (c.data.tail) { try { c.send(JSON.stringify({ t: "permission_closed", id, peer: req.peer, outcome: outcome === "expired" ? "cancelled" : outcome, ...(outcome === "expired" ? { reason: "expired" } : {}), latencyMs })); } catch { /* another surface can still observe the closure */ } }
6787
7553
  resolve({ outcome, ...(option ? { optionId: option.optionId, ...(surface ? { approvalId: id } : {}) } : {}) });
@@ -6793,7 +7559,7 @@ export async function startDaemon(opts: DaemonOptions) {
6793
7559
  timer = setTimeout(() => done(undefined, undefined, "expired"), timeoutMs);
6794
7560
  signal?.addEventListener("abort", aborted, { once: true });
6795
7561
  if (signal?.aborted) { aborted(); return; }
6796
- try { event({ type: "permission", id, peer: req.peer, event: "requested" }); } catch { /* optional observer */ }
7562
+ try { event({ type: "permission", id, peer: req.peer, event: "requested", ...requestAttribution }); } catch { /* optional observer */ }
6797
7563
  for (const c of consoles) if (c.data.tail) { try { c.send(push); } catch { /* next tail gets the registered request */ } }
6798
7564
  if (config.approvals.notify) { try { desktop("agent-hub", `${req.peer} asks for approval${req.tool ? `: ${req.tool}` : ""} (ahub tail)`); } catch { /* optional desktop */ } }
6799
7565
  });
@@ -7205,6 +7971,8 @@ export async function startDaemon(opts: DaemonOptions) {
7205
7971
  if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
7206
7972
  startCurrent?.();
7207
7973
  const jcode: JcodePeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["owned-acp"]({ peer, command: config.jcode_cmd, home: instance?.jcode_home ?? config.jcode_home,
7974
+ getModelOverride: () => acpModelSelection(peer),
7975
+ ...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride?.acp ? { modelPolicy: adapter.modelOverride.acp } : {}),
7208
7976
  providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
7209
7977
  ...(instance?.provider ? { provider: instance.provider } : {}),
7210
7978
  ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
@@ -7225,9 +7993,10 @@ export async function startDaemon(opts: DaemonOptions) {
7225
7993
  event({ type: "usage", peer, source: "jcode_acp", id: `jcode:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens,
7226
7994
  ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), ...taskAttribution(peer) });
7227
7995
  } }) }) as JcodePeer;
7996
+ if (isFixedBuiltinDefinition(adapter)) modelAcpOwners.set(jcode, jcode);
7228
7997
  jcodeLaunches.set(peer, jcode);
7229
7998
  recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
7230
- try { startCurrent?.(); await jcode.start(); startCurrent?.(); await reconcileNativeStart(peer, jcode, startCurrent); }
7999
+ try { startCurrent?.(); await jcode.start(); startCurrent?.(); registerModelOwner(jcode); bindRecordOwner(jcode, executionCwd, () => jcode.nativeSession); await reconcileNativeStart(peer, jcode, startCurrent); }
7231
8000
  catch { await jcode.stop().catch(() => {}); if (jcodeLaunches.get(peer) === jcode) jcodeLaunches.delete(peer); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
7232
8001
  return { ok: true, policy: "unmanaged" };
7233
8002
  }
@@ -7240,6 +8009,8 @@ export async function startDaemon(opts: DaemonOptions) {
7240
8009
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
7241
8010
  actionGate: acpActionGate(peer),
7242
8011
  cmd, kind: adapter.kind,
8012
+ getModelOverride: () => acpModelSelection(peer),
8013
+ ...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride?.acp ? { modelPolicy: adapter.modelOverride.acp } : {}),
7243
8014
  toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
7244
8015
  permissionMode: permissionMode(peer),
7245
8016
  ...(args.model ? { launchModel: args.model } : {}),
@@ -7275,10 +8046,12 @@ export async function startDaemon(opts: DaemonOptions) {
7275
8046
  mcpServers: [{ name: "agent-hub", command: "bun", args: ["run", SERVER_JS], env: Object.entries(toolEnv(peer, true)).map(([name, value]) => ({ name, value })) }],
7276
8047
  preamble: sessionRoleContract(peer),
7277
8048
  }) }) as AcpPeer;
8049
+ if (isFixedBuiltinDefinition(adapter)) modelAcpOwners.set(kimi, kimi);
7278
8050
  recoveryTaskPreface(peer);
7279
8051
  await ensurePreface(peer); startCurrent?.();
7280
8052
  try {
7281
- startCurrent?.(); await kimi.start(); startCurrent?.();
8053
+ startCurrent?.(); await kimi.start(); startCurrent?.(); registerModelOwner(kimi);
8054
+ bindRecordOwner(kimi, executionCwd, () => kimi.nativeSession);
7282
8055
  await reconcileNativeStart(peer, kimi, startCurrent);
7283
8056
  } catch (error) { await kimi.stop().catch(stop => log(`kimi stop after a failed start: ${(stop as Error).message}`)); throw error; }
7284
8057
  return { ok: true };
@@ -7296,6 +8069,8 @@ export async function startDaemon(opts: DaemonOptions) {
7296
8069
  const externalLaunchId = externalBinding ? randomUUID() : undefined;
7297
8070
  if (externalLaunchId) for (const server of mcpServers) server.env.push({ name: "AGENTHUB_LAUNCH_ID", value: externalLaunchId });
7298
8071
  const acpOptions: AcpOptions = {
8072
+ getModelOverride: () => acpModelSelection(peer),
8073
+ ...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride?.acp ? { modelPolicy: adapter.modelOverride.acp } : {}),
7299
8074
  toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(),
7300
8075
  onActivity: item => activityRings[peer]?.append(item),
7301
8076
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
@@ -7328,6 +8103,7 @@ export async function startDaemon(opts: DaemonOptions) {
7328
8103
  if (externalBinding && externalLaunchId) externalLaunches.set(peer, { owner, options: acpOptions, launchId: externalLaunchId, binding: externalBinding, workspace: externalWorkspace! });
7329
8104
  assertNativeProofPorts(adapter, owner);
7330
8105
  if (!isAcpPeer(owner) || owner.id !== peer || owner.deliveryOnly !== true || owner.hubOwned !== true || owner.form !== "headless") throw new Error("ACP factory did not return its bound native transport");
8106
+ if (isFixedBuiltinDefinition(adapter)) modelAcpOwners.set(owner, owner);
7331
8107
  recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
7332
8108
  try {
7333
8109
  if (externalBinding && (!membershipAllowed(peer) || externalWorkspaceIdentity(executionCwd) !== externalWorkspace || !sameExternalInstanceBinding(externalBinding, captureExternalBinding(peer, args.sessionId)))) throw new Error("external instance binding changed before native spawn");
@@ -7342,6 +8118,8 @@ export async function startDaemon(opts: DaemonOptions) {
7342
8118
  externalSessions.set(peer, receipt.sessionId);
7343
8119
  writeExternalInstanceRecord(opts.stateDir, externalBinding, receipt.sessionId);
7344
8120
  }
8121
+ registerModelOwner(owner);
8122
+ bindRecordOwner(owner, executionCwd, () => owner.nativeSession);
7345
8123
  await reconcileNativeStart(peer, owner, startCurrent);
7346
8124
  }
7347
8125
  catch (error) {
@@ -7359,6 +8137,11 @@ export async function startDaemon(opts: DaemonOptions) {
7359
8137
  actionBudgets.revoke(peer);
7360
8138
  startCurrent?.();
7361
8139
  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"]({
8140
+ getModelOverride: () => modelSelection(peer),
8141
+ onNativeModelApplied: model => { if (!stopping && bus.peers.get(peer) === codex && isNativeModelToken(model)) {
8142
+ try { modelLatches.observeNative(peer, codex, model); } catch { modelLatches.forget(peer); }
8143
+ writeStatus();
8144
+ } },
7362
8145
  admitAttach: () => membershipAllowed(peer),
7363
8146
  permissionMode: permissionMode(peer),
7364
8147
  ...(declarations[peer] ? { threadSandbox: () => isPlannerOnly(config.roles[peer] ?? []) ? declarations[peer]?.worktree ? "unavailable" as const : "read-only" as const : undefined } : {}),
@@ -7375,6 +8158,8 @@ export async function startDaemon(opts: DaemonOptions) {
7375
8158
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
7376
8159
  onThread: (thread, fresh) => { lastCodexThreads.set(peer, thread); event({ type: "native_thread", peer, thread, fresh }); },
7377
8160
  onTurn: (native) => {
8161
+ const modelBoundary = modelBoundaries.get(peer);
8162
+ if (modelBoundary && modelBoundary.owner === codex) modelBoundary.nativeStarted = true;
7378
8163
  const open = turns.get(peer);
7379
8164
  if (open) turnLog?.native(open.id, native);
7380
8165
  if (factsOn()) factSession(peer, codex.thread); // a new thread is unverified before its first item
@@ -7426,10 +8211,12 @@ export async function startDaemon(opts: DaemonOptions) {
7426
8211
  watchdogMs: config.watchdog_ms,
7427
8212
  log,
7428
8213
  }) }) as CodexPeer;
8214
+ registerModelOwner(codex);
7429
8215
  recoveryTaskPreface(peer);
7430
8216
  await ensurePreface(peer); startCurrent?.();
7431
8217
  try {
7432
8218
  startCurrent?.(); await codex.start(); startCurrent?.();
8219
+ bindRecordOwner(codex, executionCwd, () => codex.nativeSession);
7433
8220
  await reconcileNativeStart(peer, codex, startCurrent);
7434
8221
  } catch (error) { await codex.stop().catch(stop => log(`codex stop after a failed start: ${(stop as Error).message}`)); throw error; }
7435
8222
  const proxy = frontendProxyState(peer, codex);
@@ -7522,6 +8309,7 @@ export async function startDaemon(opts: DaemonOptions) {
7522
8309
  const routing = activeRouting();
7523
8310
  startCurrent?.();
7524
8311
  const pi: PiPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["pi-rpc"]({
8312
+ getModelOverride: () => modelSelection(peer),
7525
8313
  cwd: executionCwd, stateDir: nativeRoot, ...(declarations[peer] ? { hubStateDir: opts.stateDir } : {}), cmd: config.pi.cmd, mode, backend,
7526
8314
  model: args.model,
7527
8315
  sessionId: args.sessionId, sessionFile: args.sessionFile,
@@ -7633,9 +8421,11 @@ export async function startDaemon(opts: DaemonOptions) {
7633
8421
  },
7634
8422
  watchdogMs: config.watchdog_ms, maxSteps: config.pi.max_steps, log,
7635
8423
  }) }) as PiPeer;
8424
+ registerModelOwner(pi);
7636
8425
  recoveryTaskPreface(peer);
7637
8426
  await ensurePreface(peer); startCurrent?.();
7638
8427
  executionOwners.set(peer, { owner: pi, cwd: executionCwd });
8428
+ if (pi.mode === "headless") bindRecordOwner(pi, executionCwd, () => pi.nativeSession);
7639
8429
  bus.add(pi);
7640
8430
  // The start's own error is the one reported; a stop that fails too is logged beside it (#115).
7641
8431
  try { startCurrent?.(); await pi.start(); } catch (error) {
@@ -7702,12 +8492,15 @@ export async function startDaemon(opts: DaemonOptions) {
7702
8492
  };
7703
8493
  startCurrent?.();
7704
8494
  const local: LocalPeer = adapter!.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["local-worker"]({
8495
+ getModelOverride: () => modelSelection(peer),
7705
8496
  cwd: executionCwd,
7706
8497
  omni,
7707
8498
  onUsage: record => event({ type: "usage", peer, source: "omniroute", id: record.id, ...record.usage, ...taskAttribution(peer, record.task), requestedModel: record.requestedModel, ...(record.servedModel ? { servedModel: record.servedModel } : {}), ...(record.provider ? { provider: record.provider } : {}), measuredAt: record.at }),
7708
8499
  admitBudget: async (envs, unit) => tasks.admitExecutionEnvelopes(envs, peer, unit),
7709
8500
  ...(route ? { route } : {}),
7710
8501
  hubRoutes: () => activeRouting().hub_routes ?? {},
8502
+ routeEvidencePublic: boundedSafe,
8503
+ routeDecisionContext: () => ({ pii: contextPii(peer), private: turns.get(peer)?.private === true, excluded: reviewObserverHeld(peer) }),
7711
8504
  staySwitch: () => activeRouting(),
7712
8505
  onRoute: record => event({ type: "route", peer, ...record }),
7713
8506
  onAdvisor: record => event({ type: "advisor", peer, ...record }),
@@ -7741,11 +8534,13 @@ export async function startDaemon(opts: DaemonOptions) {
7741
8534
  maxSteps: config.local.max_steps,
7742
8535
  log,
7743
8536
  }) }) as LocalPeer;
8537
+ registerModelOwner(local);
7744
8538
  localObserverSessionIds.set(local, () => local.observerSessionId);
7745
8539
  ownedReviewInputReleasePeers.add(local);
7746
8540
  recoveryTaskPreface(peer);
7747
8541
  await ensurePreface(peer); startCurrent?.();
7748
8542
  executionOwners.set(peer, { owner: local, cwd: executionCwd });
8543
+ bindRecordOwner(local, executionCwd, () => local.observerSessionId);
7749
8544
  bus.add(local);
7750
8545
  startCurrent?.(); await local.start();
7751
8546
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
@@ -7764,6 +8559,7 @@ export async function startDaemon(opts: DaemonOptions) {
7764
8559
  if (selected !== "ask" && selected !== "ask-when-needed") return { ok: false, error: "Claude implementer refuses never-ask" };
7765
8560
  startCurrent?.();
7766
8561
  const implementer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: selected, mcpServers: [], daemon: DAEMON_PORTS["claude-implementer"]({
8562
+ getModelOverride: () => modelSelection(peer),
7767
8563
  cwd: executionCwd, stateDir: opts.stateDir, projectRoot: opts.cwd, instanceId, server: SERVER_JS,
7768
8564
  factsHook: join(import.meta.dir, "..", "cli", "facts-hook.ts"), systemPrompt: sessionRoleContract(peer), mode: selected, watchdogMs: config.watchdog_ms, ...(declarations[peer]?.model ? { model: declarations[peer].model } : {}),
7769
8565
  beforeInput: (context, envs) => beforeOwnedObserverInput(peer, implementer, context, envs),
@@ -7818,7 +8614,7 @@ export async function startDaemon(opts: DaemonOptions) {
7818
8614
  if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
7819
8615
  recoveryTaskPreface(peer); await ensurePreface(peer); startCurrent?.();
7820
8616
  if (!currentRoot()) throw new Error("Claude implementer startup admission changed");
7821
- executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
8617
+ registerModelOwner(implementer); executionOwners.set(peer, { owner: implementer, cwd: executionCwd }); bus.add(implementer);
7822
8618
  return { ok: true };
7823
8619
  } catch (error) { await implementer.stop().catch(() => {}); throw error; }
7824
8620
  }
@@ -7831,6 +8627,7 @@ export async function startDaemon(opts: DaemonOptions) {
7831
8627
  const reviewPolicies = new Map<string, string>();
7832
8628
  startCurrent?.();
7833
8629
  const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
8630
+ ...(isFixedBuiltinDefinition(adapter) && adapter.modelOverride && !adapter.modelOverride.acp ? { getModelOverride: () => modelSelection(peer) } : {}),
7834
8631
  backend, cwd: executionCwd,
7835
8632
  mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
7836
8633
  systemPrompt: sessionRoleContract(peer),
@@ -7897,7 +8694,7 @@ export async function startDaemon(opts: DaemonOptions) {
7897
8694
  return { ok: false, error: reason };
7898
8695
  }
7899
8696
  reviewerUnavailable.delete(peer);
7900
- executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
8697
+ registerModelOwner(reviewer); executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
7901
8698
  reviewerToolObservers.set(peer, (task, tool, refusal) => {
7902
8699
  try { if (bus.peers.get(peer) === reviewer) reviewer.recordToolTrace(task, { tool, ...(refusal === undefined ? {} : { refusal }) }); }
7903
8700
  catch { /* Diagnostics never change a host tool's result or authority. */ }
@@ -8260,6 +9057,20 @@ export async function startDaemon(opts: DaemonOptions) {
8260
9057
  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;
8261
9058
  const code = start ? await memoryCode([start]).catch(() => unavailableMemoryCodeOverlay()) : undefined;
8262
9059
  const snapshot = uiSnapshot(after, input, session);
9060
+ if (input?.visual !== undefined) {
9061
+ const record = visualRecord(input.visual);
9062
+ return { ...snapshot, visual: record ? visualView(record) : null };
9063
+ }
9064
+ if (input?.visualTask !== undefined) {
9065
+ const task = input.visualTask;
9066
+ return { ...snapshot, visuals: Number.isSafeInteger(task) && Number(task) > 0 ? visualList(Number(task)) : [] };
9067
+ }
9068
+ if (input?.visualAsset !== undefined) {
9069
+ const match = typeof input.visualAsset === "string" ? /^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}):(before|after)$/.exec(input.visualAsset) : null;
9070
+ const record = match ? visualRecord(match[1]) : undefined;
9071
+ const file = record?.files.find(file => file.asset === input.visualAsset);
9072
+ return { ...snapshot, visualAsset: record && file ? { id: record.id, ...(record.task === undefined ? {} : { task: record.task }), from: record.from, created: record.created, file } : null };
9073
+ }
8263
9074
  if (code && "memory" in snapshot && snapshot.memory && typeof snapshot.memory === "object") {
8264
9075
  const published = currentMemoryCode(code), tier = query?.tier === "goals" ? "goals" : query?.tier === "short" ? "short" : "long";
8265
9076
  const search = typeof query?.q === "string" ? query.q.toLowerCase() : undefined;
@@ -8343,11 +9154,13 @@ export async function startDaemon(opts: DaemonOptions) {
8343
9154
  projectId,
8344
9155
  instanceId,
8345
9156
  attention: attentionStatus.attentionInput,
9157
+ ...runtimeModelProjection(),
8346
9158
  rooms: attentionStatus.rooms,
8347
9159
  pet,
8348
9160
  status: { peers: peerView, pendingRestart: allPendingRestart(), retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo, ownerIsolation: attentionStatus.ownerIsolation, ownerIsolationNotes: attentionStatus.ownerIsolationNotes },
8349
9161
  boardCapabilities: { backlog: true },
8350
9162
  planningCapabilities: { enabled: true, wipPull: true, split: true },
9163
+ visuals: visualList(),
8351
9164
  // #1144: source metadata only, including private records; never expose origins or stored text.
8352
9165
  sources: sourceRegistry.list().map(({ id, kind, bytes, sha256, added_at }) => ({ id, kind, bytes, hashPrefix: sha256.slice(0, 12), added_at })),
8353
9166
  releases: tasks.releaseList(USER),
@@ -9222,6 +10035,14 @@ export async function startDaemon(opts: DaemonOptions) {
9222
10035
  }
9223
10036
  case "setting":
9224
10037
  return settingsSet(a.key, a.value, authority, a.confirm, a.now, a.reason);
10038
+ case "model_preview":
10039
+ case "model_confirm": {
10040
+ const current = () => session?.settings === true && session.liveTest !== true && typeof session.authorityId === "string"
10041
+ && typeof session.settingsUntil === "number" && session.settingsUntil > Date.now();
10042
+ if (!current()) return modelFailure("model changes require a current person settings session; reopen ahub ui --settings and preview again");
10043
+ return modelCommandSafe(a.action === "model_preview" ? { op: "preview", action: a.modelAction, peer: a.peer, model: a.model } : { op: "confirm", token: a.token },
10044
+ { source: "dashboard", scope: session!.authorityId!, expiresAt: session!.settingsUntil, current });
10045
+ }
9225
10046
  case "setting_undo":
9226
10047
  return settingsUndo(authority, a.confirm, a.now, a.reason, a.key, a.undoDigest);
9227
10048
  case "setting_preview":
@@ -9306,6 +10127,13 @@ export async function startDaemon(opts: DaemonOptions) {
9306
10127
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
9307
10128
  };
9308
10129
  const incompatibleRecoveryTarget = () => {
10130
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("model_overrides")) && modelOverrides.hasRecords()) return "target lacks model_overrides; retained runtime model choices require a supporting daemon; clear them with ahub model clear or choose a supporting target";
10131
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("visuals")) {
10132
+ try {
10133
+ if (visuals.hasRecords()) return "target lacks visuals; retained visual records require a supporting daemon; choose a target with visuals";
10134
+ if (rawVisualsPresent(opts.cwd)) return "target lacks visuals; explicit visuals configuration requires a supporting daemon; choose a target with visuals or remove the explicit configuration";
10135
+ } catch { return "visuals compatibility is unknown; the store or configuration could not be read; inspect them or choose a target with visuals"; }
10136
+ }
9309
10137
  if (config.specs.enabled && config.specs.review === "require" && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("design_specs"))) return "target lacks design_specs; specs.enabled is on and specs.review is require; use a supporting release or explicitly change specs.enabled/specs.review";
9310
10138
  if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
9311
10139
  if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
@@ -9915,9 +10743,9 @@ export async function startDaemon(opts: DaemonOptions) {
9915
10743
  }
9916
10744
  const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
9917
10745
  && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
9918
- if (stopping && !committedSourceProof && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
10746
+ if (stopping && !committedSourceProof && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: msg.t === "task" && (msg.op === "hub_visual" || msg.op === "hub_visual_show") ? visualOperational("stopping").message : "hub is stopping" });
9919
10747
  if (c.role === "peer" && c.detachedByPerson) return void reply({ t: msg.t, ok: false, error: "detached by the person; stop using this peer connection and tell the person; inspect ahub status before opening a fresh session" });
9920
- if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
10748
+ if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: msg.t === "task" && (msg.op === "hub_visual" || msg.op === "hub_visual_show") ? visualOperational("membership").message : `${c.peer} is off roster` });
9921
10749
  if (c.role === "peer" && c.peer) {
9922
10750
  const currentPeer = bus.peers.get(c.peer);
9923
10751
  if (c.standbyId && currentPeer?.ownsSocket?.(sock) !== true) return void reply({ t: msg.t, ok: false, error: "peer is standing by; a person must request takeover" });
@@ -10288,7 +11116,7 @@ export async function startDaemon(opts: DaemonOptions) {
10288
11116
  }
10289
11117
  if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
10290
11118
  current();
10291
- if (!event({ type: "agent_cli", peer: c.peer!, command: raw.kind, refused: false })) throw new RecoveryAuthorityError();
11119
+ if (!event({ type: "agent_cli", peer: c.peer!, command: raw.kind, refused: false, ...taskAttribution(c.peer!) })) throw new RecoveryAuthorityError();
10292
11120
  current();
10293
11121
  issueConductorRecoveryAuthority(opts.stateDir, expected, fresh);
10294
11122
  } else {
@@ -10376,13 +11204,24 @@ export async function startDaemon(opts: DaemonOptions) {
10376
11204
  }
10377
11205
  case "native_hook_launch": {
10378
11206
  if (c.role !== "console" || typeof msg.peer !== "string" || !definitionOf(msg.peer)?.hooks?.factsHook) return void reply({ ok: false, error: "managed native launch requires the person and a supported hook adapter" });
10379
- if (msg.op === "end") return void reply({ ok: nativeHookLaunches.end(msg.peer, msg.launchId) });
11207
+ if (msg.op === "end") {
11208
+ const launch = nativeHookLaunches.get(msg.peer, msg.launchId);
11209
+ if (!launch) return void reply({ ok: false });
11210
+ const run = runs.get(msg.peer);
11211
+ if (run) {
11212
+ if (budget.record(msg.peer)) completionStore.dropRun(run.runTurn);
11213
+ else completionDone(msg.peer, run, "interrupted");
11214
+ runs.close(msg.peer);
11215
+ }
11216
+ return void reply({ ok: nativeHookLaunches.end(msg.peer, msg.launchId) });
11217
+ }
10380
11218
  if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding native launches" });
10381
11219
  if (!membershipAllowed(msg.peer)) return void reply({ ok: false, error: "peer is off roster" });
10382
11220
  if (msg.op !== "begin") return void reply({ ok: false, error: "unknown native launch operation" });
11221
+ if (msg.nativeHome !== undefined && (typeof msg.nativeHome !== "string" || !isAbsolute(msg.nativeHome) || msg.nativeHome.length > 4096 || /[\u0000-\u001f\u007f]/.test(msg.nativeHome))) return void reply({ ok: false, error: "native home must be an absolute control-free path" });
10383
11222
  const owner = bus.peers.get(msg.peer);
10384
11223
  if (owner && owner.state !== "offline") return void reply({ ok: false, error: "native peer is already attached; end it in its terminal first" });
10385
- try { return void reply({ ok: true, launchId: nativeHookLaunches.begin(msg.peer, sock).id, instanceId }); }
11224
+ try { return void reply({ ok: true, launchId: nativeHookLaunches.begin(msg.peer, sock, typeof msg.nativeHome === "string" ? msg.nativeHome : undefined).id, instanceId }); }
10386
11225
  catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
10387
11226
  }
10388
11227
  case "native_facts": {
@@ -10391,49 +11230,151 @@ export async function startDaemon(opts: DaemonOptions) {
10391
11230
  const launch = nativeHookLaunches.get(peer, msg.nativeLaunchId);
10392
11231
  const native = typeof msg.event === "string" ? port.decode(msg.event, msg.payload) : undefined;
10393
11232
  if (!launch || !native || !native.workspacePaths.every(root => { try { return realPath(root) === realPath(opts.cwd); } catch { return false; } })) return void reply({ ok: false });
11233
+ if (launch.home && native.transcriptPath !== join(launch.home, "session_index.jsonl")) return void reply({ ok: false });
10394
11234
  if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
10395
11235
  launch.session = native.sessionId;
10396
11236
  const owner = bus.peers.get(peer);
10397
11237
  if (!isWsPeer(owner) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
10398
11238
  if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
10399
11239
  launch.generation = owner.sessionGeneration;
11240
+ launch.transcript = native.transcriptPath;
11241
+ const callStarted = performance.now();
11242
+ const tally = () => tallyHook(peer, callStarted, msg.startedMs);
10400
11243
  const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
10401
11244
  // Preserve the start baseline at observation, even if native output is flushed before recall allows attach.
10402
- const readback = port.read(native.sessionId, native.transcriptPath);
11245
+ // Skip transcript I/O on PostToolUse, PostInvocation, Stop, and active-turn PreToolUse with no pending fact offers (#1280).
11246
+ const needsBaseline = (native.event === "PreInvocation" || native.event === "PreToolUse") && (!launch.active || native.event === "PreInvocation" && native.invocation === 0)
11247
+ || native.event === "TurnStarted" || native.event === "Stop" || native.event === "StopFailure" || native.event === "Interrupt";
11248
+ const needsAck = factsOn() && (native.event === "SessionStart" || native.event === "PreInvocation" || native.event === "PreToolUse" && factsFor(peer).pending(peer).length > 0);
11249
+ const since = launch.transcriptPath === native.transcriptPath && launch.transcriptOffset !== undefined ? { sinceOffset: launch.transcriptOffset } : {};
11250
+ const readback: ReturnType<typeof port.read> = needsBaseline
11251
+ ? port.read(native.sessionId, native.transcriptPath, since)
11252
+ : needsAck
11253
+ ? port.read(native.sessionId, native.transcriptPath, { completion: false, ...since })
11254
+ : { factIds: [] };
11255
+ // ponytail: an unacknowledged fact row followed by >4 MiB of output before the next readback hits the 4 MiB lookback cap in readAgyTranscript.
11256
+ const acknowledge = (read: ReturnType<typeof port.read>, turnCtx?: { hubTurnId?: string; baseline: number; attribution: TaskAttribution }) => {
11257
+ for (const u of read.usage ?? []) {
11258
+ if (agyNativeUsageSeen.has(u.id)) continue;
11259
+ agyNativeUsageSeen.add(u.id);
11260
+ const producedByTurn = !!turnCtx && u.index > turnCtx.baseline;
11261
+ const attr: TaskAttribution = producedByTurn ? turnCtx.attribution : { attribution: "unattributed" };
11262
+ if (u.totalTokens > 0) {
11263
+ if (noQuotaPeer(peer)) hubTokenWindow(peer).add(Date.now(), Math.round(u.totalTokens));
11264
+ const supervisionTurn = supervisionTurns.get(peer);
11265
+ if (producedByTurn && supervisionTurn && turnCtx.hubTurnId && supervisionTurn.id === turnCtx.hubTurnId) {
11266
+ supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + u.totalTokens;
11267
+ }
11268
+ event({ type: "tokens", peer, n: u.totalTokens, ...attr });
11269
+ const openTurn = turns.get(peer);
11270
+ if (producedByTurn && openTurn && turnCtx.hubTurnId && openTurn.id === turnCtx.hubTurnId) {
11271
+ openTurn.tokens += u.totalTokens;
11272
+ }
11273
+ }
11274
+ event({ type: "usage", peer, source: "agy_transcript", id: u.id, inputTokens: u.inputTokens, outputTokens: u.outputTokens, cacheReadTokens: u.cacheReadTokens, totalTokens: u.totalTokens, ...attr, ...(u.at ? { measuredAt: u.at } : {}) });
11275
+ }
11276
+ if (factsOn()) {
11277
+ factSession(peer, native.sessionId);
11278
+ for (const id of read.factIds) acked(peer, id, "native-hook");
11279
+ }
11280
+ if (read.size !== undefined && read.scannedFrom !== undefined) {
11281
+ const prev = launch.transcriptPath === native.transcriptPath ? (launch.transcriptOffset ?? 0) : 0;
11282
+ if (read.scannedFrom <= prev) {
11283
+ launch.transcriptPath = native.transcriptPath;
11284
+ launch.transcriptOffset = read.size;
11285
+ }
11286
+ }
11287
+ };
10403
11288
  const apply = () => {
10404
11289
  if (!current()) return void reply({ ok: false });
10405
- if (factsOn()) { factSession(peer, native.sessionId); for (const id of readback.factIds) acked(peer, id, "native-hook"); }
10406
- if (native.event === "PreInvocation" || native.event === "PreToolUse") {
10407
- if (!launch.active || native.event === "PreInvocation" && native.invocation === 0) { launch.active = true; launch.turn = crypto.randomUUID(); launch.baseline = readback.completion?.index ?? -1; }
11290
+ if (native.event === "TurnStarted") {
11291
+ const started = readback.started;
11292
+ if (!started) return void reply({ ok: false });
11293
+ if (launch.active && launch.turn === started.id) return void reply({ ok: true });
11294
+ if (readback.completion && readback.completion.index >= started.index) return void reply({ ok: true });
11295
+ launch.active = true; launch.turn = started.id; launch.baseline = started.index - 1;
11296
+ pendingNativePerson.delete(peer);
11297
+ if (started.origin === "user") pendingNativePerson.add(peer);
10408
11298
  activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
10409
11299
  }
10410
- if (native.event === "Stop") {
10411
- if (!launch.active || !native.fullyIdle || native.terminationReason !== "NO_TOOL_CALL") return void reply({ ok: false });
10412
- // Native output can be flushed after its Stop command returns. Acknowledge observation, then wait for
10413
- // a fresh completion row while the same launcher/session/socket generation still owns the turn.
10414
- const turn = launch.turn, baseline = launch.baseline ?? -1;
11300
+ owner.bindNativeSession(native.sessionId);
11301
+ const startsTurn = (native.event === "PreInvocation" || native.event === "PreToolUse") && (!launch.active || native.event === "PreInvocation" && native.invocation === 0);
11302
+ if (native.event === "PreInvocation" || native.event === "PreToolUse") {
11303
+ if (startsTurn) {
11304
+ // Credit any leftover rows read at turn start to the preceding turn that produced them, never the new turn.
11305
+ acknowledge(readback, agyTurnContext.get(launch));
11306
+ launch.active = true; launch.turn = crypto.randomUUID(); launch.baseline = readback.completion?.index ?? -1;
11307
+ activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
11308
+ agyTurnContext.set(launch, { hubTurnId: turns.get(peer)?.id, baseline: launch.baseline, attribution: taskAttribution(peer) });
11309
+ } else {
11310
+ activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
11311
+ const activeCtx = { hubTurnId: turns.get(peer)?.id ?? agyTurnContext.get(launch)?.hubTurnId, baseline: launch.baseline ?? -1, attribution: taskAttribution(peer) };
11312
+ agyTurnContext.set(launch, activeCtx);
11313
+ acknowledge(readback, activeCtx);
11314
+ }
11315
+ } else if (native.event !== "Stop") {
11316
+ const activeCtx = launch.active ? { hubTurnId: turns.get(peer)?.id ?? agyTurnContext.get(launch)?.hubTurnId, baseline: launch.baseline ?? -1, attribution: taskAttribution(peer) } : agyTurnContext.get(launch);
11317
+ if (launch.active && activeCtx) agyTurnContext.set(launch, activeCtx);
11318
+ acknowledge(readback, activeCtx);
11319
+ }
11320
+ const stopCtx = { hubTurnId: turns.get(peer)?.id ?? agyTurnContext.get(launch)?.hubTurnId, baseline: launch.baseline ?? -1, attribution: taskAttribution(peer) };
11321
+ agyTurnContext.set(launch, stopCtx);
11322
+ if (native.event === "Stop" && native.terminationReason !== undefined) {
11323
+ if (!launch.active || native.fullyIdle !== true) return void reply({ ok: false });
11324
+ const terminalReason = ({ USER_CANCELED: "user_canceled", ERROR: "error", MAX_INVOCATIONS: "max_invocations", MAX_FORCED_INVOCATIONS: "max_forced_invocations", MAX_TOKEN_BUDGET_EXCEEDED: "max_token_budget_exceeded", TERMINAL_STEP_TYPE: "terminal_step_type", TERMINAL_CUSTOM_HOOK: "terminal_custom_hook" } as const)[native.terminationReason];
11325
+ if (terminalReason) {
11326
+ acknowledge(port.read(native.sessionId, native.transcriptPath, { completion: false, ...since }), stopCtx);
11327
+ launch.active = false; turnEnded.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
11328
+ tally(); emitHookStats(peer);
11329
+ event({ type: "native_turn_end", peer, reason: terminalReason }); owner.nativeStopped(native.sessionId);
11330
+ // #1054: pull-only agy opens no run (`settles(peer)` is false); if a future native-hook peer does, settle an open run by its closed reason.
11331
+ try {
11332
+ const run = runs.get(peer);
11333
+ if (run) {
11334
+ if (terminalReason === "user_canceled") { completionDone(peer, run, "interrupted"); runs.close(peer); }
11335
+ else { runs.note(peer, terminalReason === "error" || terminalReason === "terminal_step_type" || terminalReason === "terminal_custom_hook" ? { failed: true } : { limit: true }); runs.signalled(peer); settleCheck(peer); }
11336
+ }
11337
+ } catch { log(`completion settle for ${peer} failed`); }
11338
+ return void reply({ ok: true });
11339
+ }
11340
+ if (native.terminationReason !== "NO_TOOL_CALL") return void reply({ ok: false });
11341
+ tally();
11342
+ }
11343
+ if (native.event === "Stop" || native.event === "StopFailure" || native.event === "Interrupt") {
11344
+ if (!launch.active || native.event === "Stop" && native.terminationReason !== undefined && native.fullyIdle !== true) return void reply({ ok: false });
11345
+ // A hook is only the candidate. The final native turn and prompt rows, correlated by id and reason, are the completion evidence.
11346
+ const turn = launch.turn, baseline = launch.baseline ?? -1, expected = native.event === "StopFailure" ? "failed" : native.event === "Interrupt" ? "cancelled" : "completed";
10415
11347
  reply({ ok: true, pending: true });
10416
11348
  void (async () => {
10417
11349
  const deadline = Date.now() + 2000;
10418
11350
  let delay = 5;
10419
- // ponytail: an unverified Stop may re-read up to 4 MiB per poll within this 2 s wait.
10420
- // Upgrade: cache unchanged transcript evidence within the wait.
11351
+ let lastStat: { size: number; mtimeMs: number } | undefined;
10421
11352
  while (current() && launch.active && launch.turn === turn) {
10422
- const completion = port.read(native.sessionId, native.transcriptPath).completion;
10423
- if (completion && completion.index > baseline && completion.id !== launch.completed) {
10424
- launch.completed = completion.id; launch.active = false;
10425
- turnEnded.set(peer, Date.now()); nativeCompletedAt.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
10426
- event({ type: "native_turn_end", peer, id: completion.id }); owner.nativeStopped(native.sessionId);
10427
- try { if (runs.get(peer)) { runs.signalled(peer); settleCheck(peer); } } catch { log(`completion settle for ${peer} failed`); } // #1054: the verified native Stop
11353
+ const pollSince = launch.transcriptPath === native.transcriptPath && launch.transcriptOffset !== undefined ? { sinceOffset: launch.transcriptOffset } : {};
11354
+ const polled = port.read(native.sessionId, native.transcriptPath, { ...(lastStat ? { unchanged: lastStat } : {}), ...pollSince });
11355
+ if (polled.size !== undefined && polled.mtimeMs !== undefined) lastStat = { size: polled.size, mtimeMs: polled.mtimeMs };
11356
+ acknowledge(polled, stopCtx);
11357
+ const completion = polled.completion;
11358
+ if (completion && completion.index > baseline && completion.id !== launch.completed && (completion.reason ?? "completed") === expected) {
11359
+ completeNativeTurn(peer, launch, completion);
11360
+ emitHookStats(peer);
10428
11361
  return;
10429
11362
  }
10430
- if (Date.now() >= deadline) { notify(`${peer}: native completion could not be verified; it stays busy until a verified turn end or detach`); return; }
11363
+ if (Date.now() >= deadline) { settleCheck(peer); emitHookStats(peer); notify(`${peer}: native completion could not be verified; it stays busy until a verified turn end or detach`); return; }
10431
11364
  await Bun.sleep(delay); delay = Math.min(50, delay * 2);
10432
11365
  }
10433
11366
  })().catch(() => log("managed native completion unavailable"));
10434
11367
  return;
10435
11368
  }
10436
- const offered = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
11369
+ if (native.event === "TaskStarted") activeAt.set(peer, Date.now());
11370
+ if (native.event === "TaskStarted" || native.event === "Notification") { nativeBackgroundBackoff.delete(peer); settleCheck(peer); }
11371
+ queueMicrotask(tally);
11372
+ const offeredFacts = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
11373
+ // #1280 P2: agy is pull-only, so a model boundary is the one place the hub can tell it mail waits. Counts only, never a body or sender.
11374
+ const modelBoundary = native.event === "PreInvocation" || native.event === "SessionStart";
11375
+ const waiting = bus.queued(peer), important = bus.queuedImportant(peer);
11376
+ const inboxHint = modelBoundary && waiting ? `[agent-hub: ${waiting} message(s) waiting${important ? ` (${important} important)` : ""}; call hub_inbox]` : undefined;
11377
+ const offered = offeredFacts || inboxHint ? { ...(offeredFacts ?? {}), text: [offeredFacts?.text, inboxHint].filter(Boolean).join("\n") } : undefined;
10437
11378
  // The decoded native read (agy view_file or read_file, as `Read`) is a peer fact: its target is observed before it runs and the
10438
11379
  // view moves after it. Nothing is offered here: agy's hook encodes an offer only on SessionStart/PreInvocation.
10439
11380
  if (factsOn() && native.event === "PreToolUse") factsFor(peer).preTool(peer, native.toolUseId, native.tool, native.input);
@@ -10460,11 +11401,7 @@ export async function startDaemon(opts: DaemonOptions) {
10460
11401
  const toolUseId = typeof msg.toolUseId === "string" && msg.toolUseId ? msg.toolUseId.slice(0, 128) : undefined;
10461
11402
  const sessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId.slice(0, 128) : undefined;
10462
11403
  const callStarted = performance.now();
10463
- const tally = (peer: PeerId) => {
10464
- const st = hookStats.get(peer) ?? { n: 0, startupMs: 0, hubMs: 0, maxStartupMs: 0 };
10465
- const startup = typeof msg.startedMs === "number" && Number.isFinite(msg.startedMs) ? Math.max(0, msg.startedMs) : 0;
10466
- hookStats.set(peer, { n: st.n + 1, startupMs: st.startupMs + startup, hubMs: st.hubMs + (performance.now() - callStarted), maxStartupMs: Math.max(st.maxStartupMs, startup) });
10467
- };
11404
+ const tally = (peer: PeerId) => tallyHook(peer, callStarted, msg.startedMs);
10468
11405
  try {
10469
11406
  const peer = c.peer;
10470
11407
  if (definitionOf(peer)?.start.daemonPort === "claude-implementer") {
@@ -10539,10 +11476,7 @@ export async function startDaemon(opts: DaemonOptions) {
10539
11476
  event({ type: "native_turn_end", peer, id });
10540
11477
  try { if (runs.get(peer)) { runs.signalled(peer); settleCheck(peer); } } catch { log(`completion settle for ${peer} failed`); } // #1054: the verified native Stop
10541
11478
  }
10542
- turnEnded.set(peer, Date.now()); tasks.cohorts.turnEnded(peer); tally(peer);
10543
- const st = hookStats.get(peer)!;
10544
- event({ type: "hook_stats", peer, n: st.n, startupMs: Math.round(st.startupMs), hubMs: Math.round(st.hubMs), maxStartupMs: Math.round(st.maxStartupMs) });
10545
- hookStats.delete(peer);
11479
+ turnEnded.set(peer, Date.now()); tasks.cohorts.turnEnded(peer); tally(peer); emitHookStats(peer);
10546
11480
  if (id && nativePeer) nativePeer.nativeStopped(sessionId!);
10547
11481
  if (factsOn()) { factSession(peer, sessionId); readbacks(peer, claudeTranscript(sessionId, msg.transcriptPath, peer)); }
10548
11482
  };
@@ -10937,7 +11871,7 @@ export async function startDaemon(opts: DaemonOptions) {
10937
11871
  case "ui_snapshot":
10938
11872
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
10939
11873
  if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
10940
- void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || msg.reviewRefresh === true || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(msg.reviewRefresh === true && msg.reviewTask !== undefined ? { reviewTask: msg.reviewTask, reviewRefresh: true } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
11874
+ void uiMemorySnapshot(msg.after ?? 0, msg.memory !== undefined || msg.reviewRefresh === true || dashboardView(msg.view) || msg.visual !== undefined || msg.visualTask !== undefined || msg.visualAsset !== undefined ? { ...(msg.visual !== undefined ? { visual: msg.visual } : {}), ...(msg.visualTask !== undefined ? { visualTask: msg.visualTask } : {}), ...(msg.visualAsset !== undefined ? { visualAsset: msg.visualAsset } : {}), ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(msg.reviewRefresh === true && msg.reviewTask !== undefined ? { reviewTask: msg.reviewTask, reviewRefresh: true } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined).then(value => reply(value), () => reply({ ok: false, error: "memory view unavailable" }));
10941
11875
  return;
10942
11876
  case "ui_action":
10943
11877
  if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
@@ -10956,6 +11890,9 @@ export async function startDaemon(opts: DaemonOptions) {
10956
11890
  if (c.role !== "console") return void reply({ ok: false, error: "autonomy is a person-only command; run ahub autonomy in a person's terminal" });
10957
11891
  void autonomyCommand(msg.action, msg.environment, msg.confirmed, msg.confirmation).then(reply, () => reply({ ok: false, error: "project autonomy could not be changed" }));
10958
11892
  return;
11893
+ case "model":
11894
+ if (c.role !== "console") return void reply(modelFailure("model overrides are person-only; run ahub model in a person's terminal"));
11895
+ return void reply(modelCommandSafe(msg, { source: "cli", scope: c, current: () => c.authed === true && !c.closed && c.role === "console" && sock.readyState === WebSocket.OPEN }));
10959
11896
  case "settings_get":
10960
11897
  if (c.role !== "console") {
10961
11898
  try { const current = publicReadCurrent(sock); current(); const view = publicSettingsView(); current(); return void reply({ ok: true, ...view }); }
@@ -11058,7 +11995,7 @@ export async function startDaemon(opts: DaemonOptions) {
11058
11995
  // #601: ahub study is the person's. A tools connection (any agent) may use the hub_study_* tools, never these op names, whatever Tasks would answer.
11059
11996
  if (String(msg.op).startsWith("study_") && c.role !== "console") return void reply({ t: "task", ok: false, error: "ahub study draft, revise, approve and close are console commands; agents use the hub_study_* tools" });
11060
11997
  if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
11061
- if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
11998
+ if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: msg.op === "hub_visual" || msg.op === "hub_visual_show" ? visualOperational("recovery").message : "recovery is holding mutations" });
11062
11999
  if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
11063
12000
  // #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
11064
12001
  let current: () => void;
@@ -11322,6 +12259,7 @@ export async function startDaemon(opts: DaemonOptions) {
11322
12259
  }
11323
12260
  async function stopOnce(): Promise<void> {
11324
12261
  stopping = true;
12262
+ kimiUsage.stop();
11325
12263
  controlWriteTelemetry?.stop();
11326
12264
  const roomsStopped = roomCommands.stop().catch(() => { log("room shutdown failed; inspect ahub room status after restart"); });
11327
12265
  tasks.stopQueuePressureRechecks();
@@ -11385,9 +12323,12 @@ export async function startDaemon(opts: DaemonOptions) {
11385
12323
  executionBudget.close();
11386
12324
  syncStop.abort(); // a sync in flight kills its git step and ends before the store closes
11387
12325
  await syncRun?.catch(() => undefined);
12326
+ for (const peer of recordFrames.keys()) closeRecordFrame(peer); recordInputs.clear(); generatedRecordNotes.clear();
11388
12327
  memory.close();
11389
12328
  candidates?.close();
11390
12329
  memoryStore?.close();
12330
+ modelPreviews.clear(); modelOverrides.close();
12331
+ visuals.close();
11391
12332
  board.close();
11392
12333
  turnLog?.close();
11393
12334
  server.stop(true);
@@ -11429,6 +12370,7 @@ export async function startDaemon(opts: DaemonOptions) {
11429
12370
  // #1054: a run a restart cut short settles `interrupted`, once. Every process of the earlier run is gone by now, and the unique run turn makes repeated restarts yield one completion.
11430
12371
  reconstructRuns();
11431
12372
  ready = true;
12373
+ startupTimings.mark("ready");
11432
12374
  controlWriteTelemetry?.start();
11433
12375
  void handoffCatchUp();
11434
12376
  // #733: the periodic catch-up and the descriptor watch. Unref'd, cleared with the other intervals at stop; each pass is guarded by `stopping` and never overlaps another.
@@ -11479,6 +12421,8 @@ export async function startDaemon(opts: DaemonOptions) {
11479
12421
  // #843 AC3: at hub start, after the roster and the control socket exist; never awaited, so a slow probe delays no start.
11480
12422
  void sweepAgentDrift();
11481
12423
  // #515b: the loopback ports of the two egress proxies, for a host-only test that connects to the proxy this daemon actually started. Ports only: no list, no authority.
12424
+ startupTimings.mark("post_ready");
12425
+ log(startupTimings.finish());
11482
12426
  return { bus, token, port: server.port as number, egressPorts: () => ({ bash: egress?.port, web: webEgress?.port }), bandEvaluations: () => bandEvaluations, sdlcFileReads: () => sdlcFileReads, stop, stopped: new Promise<void>((r) => (onStop = r)) };
11483
12427
  } finally {
11484
12428
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }