@staix/agent-hub 0.12.44 → 0.13.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 (197) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +83 -116
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +15 -0
  6. package/docs/agent-notes/adapters.md +63 -46
  7. package/docs/agent-notes/budget.md +7 -2
  8. package/docs/agent-notes/bus.md +6 -4
  9. package/docs/agent-notes/daemon.md +15 -8
  10. package/docs/agent-notes/local-worker.md +6 -4
  11. package/docs/agent-notes/tasks.md +18 -5
  12. package/docs/agent-notes/tests.md +11 -9
  13. package/docs/dogfood.md +92 -0
  14. package/docs/events.md +14 -1
  15. package/docs/operations.md +349 -26
  16. package/docs/profiles.md +2 -2
  17. package/docs/quickstart.md +12 -2
  18. package/docs/sdlc.md +61 -13
  19. package/docs/security.md +24 -12
  20. package/docs/smoke.md +9 -4
  21. package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
  22. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  23. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  24. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  25. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  26. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  27. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  28. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  29. package/docs/verification/2026-10-22-code-facts.md +78 -0
  30. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
  31. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  32. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  33. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  34. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  35. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  36. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  37. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  38. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  39. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  40. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  41. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  43. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  44. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  45. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  46. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  47. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  48. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  49. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  51. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  53. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  54. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  56. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  57. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  58. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  59. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  60. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  65. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  66. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  68. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  70. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  71. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  72. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  73. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  74. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  75. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  76. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  77. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  78. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  79. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  80. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  81. package/docs/verified.json +19 -16
  82. package/package.json +5 -1
  83. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  84. package/plugins/agent-hub/server.js +10120 -9623
  85. package/src/adapters/adapter-api.ts +2 -0
  86. package/src/adapters/agy/definition.ts +3 -1
  87. package/src/adapters/agy/entry.ts +5 -4
  88. package/src/adapters/claude-channel.ts +32 -12
  89. package/src/adapters/codex-appserver.ts +19 -2
  90. package/src/adapters/codex-review-isolation.ts +12 -5
  91. package/src/adapters/compat.ts +140 -0
  92. package/src/adapters/conformance/acp.ts +114 -0
  93. package/src/adapters/conformance/codex.ts +191 -0
  94. package/src/adapters/conformance/enrollment.ts +43 -0
  95. package/src/adapters/conformance/factories.ts +17 -0
  96. package/src/adapters/conformance/fake-paths.ts +12 -0
  97. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  98. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  99. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  100. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  101. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  102. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  103. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  104. package/src/adapters/conformance/index.ts +11 -0
  105. package/src/adapters/conformance/kit.ts +50 -0
  106. package/src/adapters/conformance/local-pi.ts +100 -0
  107. package/src/adapters/conformance/owned-acp.ts +44 -0
  108. package/src/adapters/definitions/builtin-metadata.ts +25 -7
  109. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  110. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  111. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  112. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  113. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  114. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  115. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  116. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  117. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  118. package/src/adapters/definitions/qwen-native.ts +7 -3
  119. package/src/adapters/definitions/qwen.ts +5 -1
  120. package/src/adapters/external-metadata.ts +48 -0
  121. package/src/adapters/external-module-scan.ts +126 -0
  122. package/src/adapters/external-modules.ts +240 -0
  123. package/src/adapters/jcode.ts +72 -31
  124. package/src/adapters/kimi-review.ts +6 -3
  125. package/src/adapters/pi.ts +2 -0
  126. package/src/adapters/public.ts +7 -0
  127. package/src/adapters/registry-types.ts +13 -1
  128. package/src/adapters/registry.ts +52 -15
  129. package/src/cli/agents.ts +40 -0
  130. package/src/cli/console-review.ts +54 -0
  131. package/src/cli/console-state.ts +124 -29
  132. package/src/cli/console-symbols.ts +74 -0
  133. package/src/cli/console-usage.ts +89 -0
  134. package/src/cli/console.ts +26 -1
  135. package/src/cli/help.ts +36 -21
  136. package/src/cli/identity.ts +30 -7
  137. package/src/cli/init.ts +109 -11
  138. package/src/cli/main.ts +145 -16
  139. package/src/cli/output.ts +44 -1
  140. package/src/cli/preview.ts +1 -1
  141. package/src/cli/sdlc-release-guard.ts +82 -0
  142. package/src/cli/upgrade-labels.ts +1 -1
  143. package/src/cli/upgrade-presentation.ts +1 -1
  144. package/src/cli/upgrade-restore-state.ts +3 -2
  145. package/src/cli/upgrade-runtime.ts +4 -1
  146. package/src/hub/action-class.ts +362 -0
  147. package/src/hub/action-policy.ts +106 -0
  148. package/src/hub/board.ts +72 -11
  149. package/src/hub/budget.ts +71 -2
  150. package/src/hub/bus.ts +43 -3
  151. package/src/hub/checks.ts +80 -31
  152. package/src/hub/code-facts.ts +223 -0
  153. package/src/hub/config-apply.ts +28 -11
  154. package/src/hub/config-reload.ts +8 -5
  155. package/src/hub/config-trust.ts +7 -4
  156. package/src/hub/control-client.ts +4 -1
  157. package/src/hub/daemon.ts +332 -61
  158. package/src/hub/dogfood-read.ts +199 -0
  159. package/src/hub/dogfood.ts +163 -0
  160. package/src/hub/envelope.ts +1 -1
  161. package/src/hub/events.ts +11 -2
  162. package/src/hub/facts.ts +76 -13
  163. package/src/hub/hub-tools.ts +45 -7
  164. package/src/hub/inbox-polling.ts +25 -0
  165. package/src/hub/instance-config.ts +17 -5
  166. package/src/hub/instance-execution.ts +20 -8
  167. package/src/hub/instances.ts +75 -2
  168. package/src/hub/peers.ts +9 -0
  169. package/src/hub/plan-conformance.ts +83 -0
  170. package/src/hub/profiles.ts +7 -2
  171. package/src/hub/release-worktree.ts +56 -0
  172. package/src/hub/review-brief.ts +36 -0
  173. package/src/hub/review-findings.ts +104 -0
  174. package/src/hub/roster.ts +31 -5
  175. package/src/hub/sdlc-queue.ts +48 -11
  176. package/src/hub/sdlc-release-authorization.ts +173 -0
  177. package/src/hub/sdlc-release.ts +16 -1
  178. package/src/hub/sdlc.ts +71 -18
  179. package/src/hub/settings.ts +10 -3
  180. package/src/hub/snapshots.ts +101 -14
  181. package/src/hub/symbol-normalizer.ts +26 -0
  182. package/src/hub/task-code-symbols.ts +90 -0
  183. package/src/hub/task-sweep.ts +3 -2
  184. package/src/hub/tasks.ts +556 -88
  185. package/src/hub/test-integrity.ts +162 -0
  186. package/src/hub/ui.ts +2 -2
  187. package/src/hub/usage-rollup.ts +361 -0
  188. package/src/local/sandbox.ts +5 -3
  189. package/src/ui/index.html +560 -78
  190. package/src/ui/office-model.ts +3 -1
  191. package/src/ui/peer-palette.ts +44 -0
  192. package/templates/AGENTS.block.md +1 -1
  193. package/templates/profiles/dev.json +6 -1
  194. package/templates/profiles/general.json +6 -1
  195. package/templates/profiles/research.json +6 -1
  196. package/templates/profiles/writing.json +6 -1
  197. package/templates/release-guard.block.md +6 -0
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { InboxPolling } from "./inbox-polling.ts";
2
+ import { ActionBudgets, actionPolicyConfig, type ActionPolicyConfig } from "./action-policy.ts";
3
+ import { ReleaseAuthorizations, releaseAuthorizationError } from "./sdlc-release-authorization.ts";
4
+ import { parseReleaseGuardArgv, parseReleasePushUpdates } from "../cli/sdlc-release-guard.ts";
5
+ import type { DocEvidence } from "./sdlc-release.ts";
6
+ import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
1
7
  import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
2
8
  import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
3
9
  import { NativeHookLaunches } from "./native-hook-launch.ts";
@@ -18,21 +24,23 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
18
24
  import { loadPetConfig } from "../pet/config.ts";
19
25
  import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
20
26
  import { policyPaths, matchesPolicy } from "./path-policy.ts";
21
- import { buildReviewBrief } from "./review-brief.ts";
27
+ import { CodeFacts, type CodeFactResult, type CodeSymbol } from "./code-facts.ts";
28
+ import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
22
29
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
23
30
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
24
31
  import { ActivityRing } from "./activity.ts";
25
- import { JcodeOptionMismatch, isJcodePeer, type JcodePeer } from "../adapters/jcode.ts";
32
+ import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
33
+ import { VERSION_REFUSAL, describe, isCompatId } from "../adapters/compat.ts";
26
34
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
27
35
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
28
36
  import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
29
- import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
37
+ import { peerRosterConfig, rosterAllows, staleInstanceKey, type PeerRoster } from "./roster.ts";
30
38
  import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
31
39
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
32
40
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
33
41
  import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf, createProjectAdapterCatalog } from "../adapters/registry.ts";
34
42
  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";
35
- import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
43
+ import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
36
44
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
37
45
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
38
46
  import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
@@ -75,6 +83,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
75
83
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
76
84
  import { Briefs } from "../memory/brief.ts";
77
85
  import { Board, isClosedTaskState, taskProposalAt, taskHasStarted, CLASSES, type Task, type TaskClass } from "./board.ts";
86
+ import { UsageRollup, USAGE_GROUPS, LIMITS_MAX, type UsageGroup } from "./usage-rollup.ts";
78
87
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
79
88
  import { HUB } from "./envelope.ts";
80
89
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
@@ -93,7 +102,7 @@ import { AssignmentUndeliveredError, ESCALATE_AFTER, Tasks } from "./tasks.ts";
93
102
  import { DEFAULT_INFERENCE, DIGEST, Inference, screenPii, type InferenceConfig } from "./inference.ts";
94
103
  import { ask, ASK_NOTE_TITLE, RUN_START } from "./ask.ts";
95
104
  import { currentRouting, loadRouting, detectSignals, OVERLAY_FILE, overlayToml, parseRouting, routingText, type Routing } from "./routing.ts";
96
- import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
105
+ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
97
106
  import { Bus } from "./bus.ts";
98
107
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
99
108
  import { DeliveryJournal } from "./delivery-journal.ts";
@@ -112,7 +121,7 @@ import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
112
121
  import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
113
122
  import type { JournalDelivery } from "./delivery-journal.ts";
114
123
  import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
115
- import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
124
+ import { soleWorktree, branchHeads, changedPaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
116
125
  import { archiveRestartSnapshot, readRecoveryWaivers, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
117
126
  import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContinuity } from "./recovery-continuity.ts";
118
127
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate } from "../cli/terminal-recovery.ts";
@@ -134,6 +143,7 @@ import { nativeRecall } from "../memory/recall.ts";
134
143
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
135
144
 
136
145
  export interface HubConfig {
146
+ adapter_modules?: readonly AdapterModuleDeclaration[];
137
147
  adapters?: Readonly<Record<string, import("../adapters/configured-acp.ts").ConfiguredAcpAdapterConfig>>;
138
148
  /** Person-owned declarations, read only from an untracked machine-local file. Bound for this daemon lifetime. */
139
149
  instances?: InstancesConfig;
@@ -143,6 +153,7 @@ export interface HubConfig {
143
153
  jcode_cmd: string[];
144
154
  jcode_home: string;
145
155
  jcode_provider_profile: string;
156
+ action_policy?: ActionPolicyConfig;
146
157
  permission_modes: Record<string, PermissionMode>;
147
158
  protect_instruction_files: boolean;
148
159
  /** Loader-owned provenance of requested defaults, before machine-local filtering. */
@@ -216,6 +227,7 @@ const nativeDefaults = nativeConfigurationDefaults({ home: homedir(), env: proce
216
227
  export const DEFAULT_CONFIG: HubConfig = {
217
228
  watchdog_ms: DEFAULT_WATCHDOG_MS,
218
229
  ...nativeDefaults.commands,
230
+ action_policy: actionPolicyConfig(),
219
231
  permission_modes: {},
220
232
  protect_instruction_files: false,
221
233
  ...nativeDefaults.proxy,
@@ -261,7 +273,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
261
273
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
262
274
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
263
275
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
264
- const CONFIG_BLOCKS = ["memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
276
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
265
277
 
266
278
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
267
279
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
@@ -322,12 +334,13 @@ function retiredRoutingSettings(cwd: string): readonly string[] {
322
334
  catch { return []; } // Routing admission and reload report parse errors using their own last-good state.
323
335
  }
324
336
 
325
- export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd)): HubConfig {
337
+ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routingRetired: readonly string[] = retiredRoutingSettings(cwd), externalDefinitions?: readonly import("../adapters/registry-types.ts").AdapterDefinition[]): HubConfig {
326
338
  const captured = layers ?? readInstanceConfigLayers(cwd);
327
- const instanceConfig = readInstanceConfig(cwd, captured);
339
+ const instanceConfig = readInstanceConfig(cwd, captured, externalDefinitions);
328
340
  const instances = instanceConfig.instances;
329
341
  const adapterCatalog = instanceConfig.catalog;
330
342
  const ignored: string[] = [...instanceConfig.ignored];
343
+ const inertModuleRoster = !!instanceConfig.adapter_modules?.length;
331
344
  const appliedPermissionSources: NonNullable<HubConfig["permission_default_sources"]> = {};
332
345
  const files = CONFIG_FILES.flatMap((name) => {
333
346
  let file: Record<string, any>; // parsed JSON, checked field by field below
@@ -338,9 +351,10 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
338
351
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
339
352
  throw error;
340
353
  }
354
+ delete file.adapter_modules; // Code provenance is captured separately and never evaluated by configuration reads.
341
355
  delete file.adapters; // Adapter provenance is compiled by the same project-local loader.
342
356
  delete file.instances; // Declaration authority was checked against its exact captured origin above.
343
- peerRosterConfig(file.roster, instances, adapterCatalog);
357
+ peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster);
344
358
  catalogAssignments(file.catalog_roles);
345
359
  profileName(file.profile);
346
360
  resolveProfile(file.profile, file.profile_overrides);
@@ -354,6 +368,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
354
368
  if (file.jcode_home !== undefined && (typeof file.jcode_home !== "string" || !isAbsolute(file.jcode_home))) throw new Error("jcode_home must be an absolute path");
355
369
  for (const key of ["jcode_home", "jcode_provider_profile"]) if (file[key] !== undefined && typeof file[key] !== "string") throw new Error(`${key} must be a string`);
356
370
  if (file.qwen_cmd !== undefined && (!Array.isArray(file.qwen_cmd) || !file.qwen_cmd.length || file.qwen_cmd.some((word: unknown) => typeof word !== "string" || !word))) throw new Error("qwen_cmd must be a nonempty executable argv");
371
+ actionPolicyConfig(file.action_policy);
357
372
  instructionProtection(file.protect_instruction_files);
358
373
  permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
359
374
  peerStartConfig(file.peers, instances, adapterCatalog);
@@ -412,16 +427,18 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
412
427
  ...DEFAULT_CONFIG,
413
428
  ...file,
414
429
  ...(instanceConfig.adapters ? { adapters: instanceConfig.adapters } : {}),
430
+ ...(instanceConfig.adapter_modules ? { adapter_modules: instanceConfig.adapter_modules } : {}),
415
431
  ...(instances ? { instances } : {}),
416
432
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
417
433
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
418
434
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
435
+ action_policy: actionPolicyConfig(file.action_policy),
419
436
  permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
420
437
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
421
438
  permission_default_sources: appliedPermissionSources,
422
439
  memory: { ...DEFAULT_CONFIG.memory, native: true, ...file.memory, ...(file.memory?.short_ttl_days !== undefined ? { short_ttl_days: ttlDays(file.memory.short_ttl_days) } : {}),
423
440
  ...(file.memory?.sync_interval_min !== undefined ? { sync_interval_min: syncMinutes(file.memory.sync_interval_min) } : {}) }, // the native store is on with any project config (#258)
424
- roster: peerRosterConfig(file.roster, instances, adapterCatalog),
441
+ roster: peerRosterConfig(file.roster, instances, adapterCatalog, inertModuleRoster),
425
442
  roles: { ...configuredRoles, ...conductorPolicy.roles, ...extraRoles },
426
443
  planner: conductorPolicy.planner,
427
444
  conductor: conductorPolicy.conductor,
@@ -476,9 +493,13 @@ export interface DaemonOptions {
476
493
  /** Host-only snapshot/probe seams; never read from project config or tool arguments. */
477
494
  snapshot?: typeof snapshot;
478
495
  snapshotBurstMs?: number;
496
+ /** Host-only (#661): the clock the shared snapshot Git budget reads (default performance.now). A test holds it fixed so a slow runner cannot spend the 250 ms; production never sets it and no config key or tool reaches it. */
497
+ snapshotClock?: () => number;
479
498
  checkoutBranch?: typeof mainCheckoutBranch;
480
499
  /** Host-only admission transport; never selected by project config or tools. */
481
500
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
501
+ /** Host-only evidence seam; never supplied by project config or control messages. */
502
+ releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
482
503
  projectId?: string;
483
504
  instanceId?: string;
484
505
  cwd: string;
@@ -525,20 +546,49 @@ interface Client {
525
546
  type Sock = ServerWebSocket<Client>;
526
547
 
527
548
 
549
+ /**
550
+ * #713: why `ahub queue resolve` was refused, from a closed list: the bus and the journal throw their own messages, and only these causes are named, never
551
+ * free text from an error (an id or a body would leak). An error that is none of them keeps the old generic sentence.
552
+ */
553
+ const RESOLUTION_CAUSES: [RegExp, string][] = [
554
+ [/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
555
+ [/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
556
+ [/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
557
+ [/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
558
+ [/already terminal|already resolved/i, "already resolved: the delivery is settled"],
559
+ [/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
560
+ [/queue is full/i, "recipient queue is full: resolve or discard some of its queued work first"],
561
+ [/journal unavailable/i, "delivery journal unavailable: the hub cannot record a resolution right now"],
562
+ ];
563
+ export function resolutionRefusal(error: unknown): string {
564
+ const text = error instanceof Error ? error.message : String(error);
565
+ return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
566
+ }
567
+
528
568
  export async function startDaemon(opts: DaemonOptions) {
529
569
  const projectId = opts.projectId ?? randomUUID();
530
570
  const instanceId = opts.instanceId ?? randomUUID();
531
- const startupCleanup: (() => void)[] = [];
571
+ const startupCleanup: (() => void | Promise<void>)[] = [];
532
572
  let ready = false;
533
573
  try {
534
- const loadedConfig = opts.config ?? loadConfig(opts.cwd);
574
+ let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
575
+ try { startupLayers = readInstanceConfigLayers(opts.cwd); }
576
+ catch (error) { if (!opts.config) throw error; startupLayers = []; } // Explicit embeddings may ignore unreadable file config; no code is loaded from it.
577
+ const externalAdapterRefusals: import("../adapters/external-modules.ts").ExternalAdapterRefusalRecord[] = [];
578
+ const externalDefinitions = await loadExternalAdapterDefinitions(opts.cwd, startupLayers, refusal => externalAdapterRefusals.push(refusal));
579
+ const externalAdapters = externalDefinitions.map(({ kind, displayName }) => ({ kind, displayName }));
580
+ const externalIds = new Set(externalAdapters.map(item => item.kind));
581
+ const moduleDeclarations = readAdapterModuleDeclarations(opts.cwd, startupLayers).modules;
582
+ const loadedConfig = opts.config ? { ...opts.config, adapter_modules: moduleDeclarations.length ? moduleDeclarations : undefined }
583
+ : loadConfig(opts.cwd, startupLayers, undefined, externalDefinitions);
535
584
  reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
536
585
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
537
586
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
538
587
  const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
539
- const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters);
588
+ const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
540
589
  const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
541
590
  const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
591
+ checkJcodeInstanceHomes(declarations, loadedConfig.jcode_home); // #685: one native home per Jcode, at startup and never partially
542
592
  const extraIds = Object.keys(declarations);
543
593
  // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
544
594
  const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
@@ -562,7 +612,7 @@ export async function startDaemon(opts: DaemonOptions) {
562
612
  }
563
613
  }
564
614
  const config: HubConfig = { ...ownedConfig, ...(extraIds.length ? { instances: declarations } : {}),
565
- roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog),
615
+ roster: peerRosterConfig(loadedConfig.roster, declarations, adapterCatalog, moduleDeclarations.length > 0),
566
616
  permission_modes: { ...permissionModesFromInstances, ...permissionDefaults(loadedConfig.permission_modes, declarations, adapterCatalog) },
567
617
  peers: { ...startModesFromInstances, ...peerStartConfig(loadedConfig.peers, declarations, adapterCatalog) },
568
618
  ...(extraIds.length ? { permission_default_sources: { ...instancePermissionSources, ...loadedConfig.permission_default_sources } } : {}),
@@ -585,7 +635,16 @@ export async function startDaemon(opts: DaemonOptions) {
585
635
  const reviewOnlyItem = (peer: string, env: Envelope): boolean => env.from === USER || env.from === HUB && (env.kind === "review" || (env.kind === "task" && env.to?.includes(peer) === true));
586
636
  const readOnlyPeer = (peer: string): boolean => declarations[peer]?.readOnly === true;
587
637
  const SETTINGS = projectSettings(declarations, adapterCatalog);
588
- const settingDef = (key: unknown) => instanceSettingDef(key, declarations, adapterCatalog);
638
+ // #225: a roster key whose instance was undeclared has no row of its own. The only thing a person can do with it is delete it,
639
+ // so it gets a boolean roster row that checkSettingValue below only accepts as a removal.
640
+ const staleRosterKey = (key: unknown): string | undefined => {
641
+ const peer = typeof key === "string" && key.startsWith("roster.") ? key.slice("roster.".length) : undefined;
642
+ return peer !== undefined && !declarations[peer] && Object.hasOwn(config.roster, peer) && staleInstanceKey(peer, adapterCatalog) ? peer : undefined;
643
+ };
644
+ const settingDef = (key: unknown): SettingDef | string => {
645
+ const found = instanceSettingDef(key, declarations, adapterCatalog), stale = staleRosterKey(key);
646
+ return typeof found === "string" && stale ? { key: `roster.${stale}`, group: "Roster", label: `${stale}: stale roster key (instance no longer declared)`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", stale] } : found;
647
+ };
589
648
  const accountOf = (peer: string) => definitionOf(peer)?.budget?.account ?? peer;
590
649
  const accountMembers = (account: string) => SUPPORTED_PEERS.filter(peer => accountOf(peer) === account);
591
650
  let rosterChanging: string | undefined;
@@ -593,7 +652,8 @@ export async function startDaemon(opts: DaemonOptions) {
593
652
  let kimiAdmissionId: string | undefined;
594
653
  let kimiAdmissionExpiry: ReturnType<typeof setTimeout> | undefined;
595
654
  const clearKimiAdmission = () => { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; kimiSessionMode = undefined; kimiAdmissionId = undefined; };
596
- const onRoster = (peer: string) => rosterAllows(config.roster, peer, config.pi.enabled, declarations, adapterCatalog);
655
+ const projectRosterAllows = (roster: PeerRoster, peer: string, piEnabled: boolean) => rosterAllows(roster, peer, piEnabled, declarations, adapterCatalog, moduleDeclarations.length > 0);
656
+ const onRoster = (peer: string) => projectRosterAllows(config.roster, peer, config.pi.enabled);
597
657
  // pi.enabled historically gates starts, not retained queues or tools sockets. Only an explicit roster.pi changes those.
598
658
  const membershipAllowed = (peer: string) => definitionOf(peer)?.enabledSwitchStartOnly && config.roster[peer] === undefined ? true : onRoster(peer);
599
659
  conductorPeer(config.roles); // reject ambiguous leadership before opening a control listener
@@ -604,7 +664,7 @@ export async function startDaemon(opts: DaemonOptions) {
604
664
  // The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
605
665
  const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
606
666
  let runningRouting = currentRouting(opts.cwd, log);
607
- let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd);
667
+ let acceptedSources = parseConfigSources(configSourceTexts(opts.cwd), runningRouting, opts.cwd, externalAdapters);
608
668
  const startupSources = structuredClone(acceptedSources.trustLayers);
609
669
  const embeddedConfig = structuredClone(config);
610
670
  let desiredConfig = structuredClone(config);
@@ -888,6 +948,26 @@ export async function startDaemon(opts: DaemonOptions) {
888
948
  if (refused) log(`limits: ${env.from}: ${refused}`);
889
949
  return refused;
890
950
  };
951
+ /**
952
+ * #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
953
+ * exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
954
+ */
955
+ const jcodeModelSource = (id: string): { modelSource?: string } => {
956
+ const instance = declarations[id];
957
+ if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
958
+ return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
959
+ };
960
+ /**
961
+ * #685 scope update 2: the read-only start preflight for an extra Jcode instance. The declared home must exist as a directory the
962
+ * person owns (the hub never creates it, and reads or writes nothing inside it). The reason names the instance, never the path.
963
+ */
964
+ const jcodeHomeRefusal = (id: string, home: string): string | undefined => {
965
+ let info: ReturnType<typeof statSync> | undefined;
966
+ try { info = statSync(home); } catch { /* reported below */ }
967
+ if (!info?.isDirectory()) return `${id}: its jcode_home is not an existing directory; the person creates it and signs in to Jcode against it first`;
968
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) return `${id}: its jcode_home is not owned by the person running the hub`;
969
+ return undefined;
970
+ };
891
971
  /** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
892
972
  const hubStartedAt = Date.now();
893
973
  // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
@@ -1093,6 +1173,8 @@ export async function startDaemon(opts: DaemonOptions) {
1093
1173
  let modelRelay: ModelRelay | undefined;
1094
1174
  const piReceiptLedgers = new Map<string, PiToolReceipts>();
1095
1175
  const modelRelays = new Map<string, ModelRelay>();
1176
+ /** #368: the owned Jcode launch of each Jcode peer, from creation (its MCP child connects while `start()` runs, before the bus has the peer). */
1177
+ const jcodeLaunches = new Map<string, JcodePeer>();
1096
1178
  const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
1097
1179
  const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
1098
1180
  const instanceAppPorts = new Map<string, number>();
@@ -1132,8 +1214,12 @@ export async function startDaemon(opts: DaemonOptions) {
1132
1214
  event({ type: "conduct", peer: action.actor, action: action.action, ...(action.task === undefined ? {} : { task: action.task }), ...(action.peer ? { target: action.peer } : {}) });
1133
1215
  try { notify(`conductor ${action.actor}: ${action.action}${action.task === undefined ? "" : ` #${action.task}`}${action.peer ? ` ${action.peer}` : ""}`); } catch { /* never throw after a task/hold write */ }
1134
1216
  };
1217
+ const inboxPolling = new InboxPolling();
1135
1218
  const board = new Board(join(opts.stateDir, "hub.db"));
1219
+ const codeFacts = new CodeFacts();
1220
+ const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1136
1221
  startupCleanup.push(() => board.close());
1222
+ startupCleanup.push(() => codeFacts.close());
1137
1223
  const supervision = new SupervisionFeed({
1138
1224
  conductor: () => conductorPeer(config.roles) ?? undefined,
1139
1225
  scope: () => config.conductor?.feed ?? "own",
@@ -1193,8 +1279,9 @@ export async function startDaemon(opts: DaemonOptions) {
1193
1279
  if (!peer || peer.state === "offline") return undefined;
1194
1280
  let token = checkConnections.get(peer);
1195
1281
  if (!token) { token = randomUUID(); checkConnections.set(peer, token); }
1196
- const native = isWsPeer(peer) || isCodexPeer(peer) || isPiPeer(peer) ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1197
- if (isPiPeer(peer) && native === undefined) return undefined;
1282
+ const hasGeneration = "sessionGeneration" in peer;
1283
+ const native = hasGeneration ? peer.sessionGeneration : peer.recoveryMetadata?.().sessionId;
1284
+ if (hasGeneration && native === undefined) return undefined;
1198
1285
  return `${instanceId}:${token}:${String(native ?? "")}`;
1199
1286
  };
1200
1287
  const currentCheck = (receipt: { task: number; owner: string; updated: number | string; generation: number | string; revision?: number }) => {
@@ -1331,9 +1418,19 @@ export async function startDaemon(opts: DaemonOptions) {
1331
1418
  projectedReview: task => reviewProjection(task),
1332
1419
  ownerWrites: (task, since) => ownerWriteEvidence(task, since),
1333
1420
  ownerAccepted: task => captureOwnerWriteStart(task),
1421
+ prepareReviewBrief: async task => {
1422
+ if (tasks.isPii(task) || !task.refs.commit) return;
1423
+ const root = executionRoot(task.owner ?? "");
1424
+ const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1425
+ if ("unavailable" in brief) return;
1426
+ const key = JSON.stringify([root, brief.head, brief.mergeBase]);
1427
+ reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
1428
+ if (reviewSymbols.size > 64) reviewSymbols.delete(reviewSymbols.keys().next().value!);
1429
+ },
1334
1430
  reviewBrief: task => {
1335
1431
  const root = executionRoot(task.owner ?? "");
1336
- return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1432
+ const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1433
+ return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
1337
1434
  },
1338
1435
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1339
1436
  board,
@@ -1630,6 +1727,10 @@ export async function startDaemon(opts: DaemonOptions) {
1630
1727
  /** What quiescence was judged on, for the log when an integration waits on it. */
1631
1728
  const stopEvidence = (peer: PeerId) => `${peer}: turn end ${turnEnded.has(peer) ? new Date(turnEnded.get(peer)!).toISOString() : "none"}, last tool call ${activeAt.has(peer) ? new Date(activeAt.get(peer)!).toISOString() : "none"}, ${bus.peers.get(peer)?.state ?? "offline"}`;
1632
1729
  board.onChange = (t, h) => {
1730
+ if (!isClosedTaskState(t.state) && t.state !== "backlog") {
1731
+ if (t.owner) inboxPolling.reset(t.owner);
1732
+ if (t.reviewer) inboxPolling.reset(t.reviewer);
1733
+ }
1633
1734
  turnFreeNow = undefined;
1634
1735
  scopes = new Map();
1635
1736
  progress.prune();
@@ -1672,8 +1773,9 @@ export async function startDaemon(opts: DaemonOptions) {
1672
1773
  const owner = bus.peers.get(peer);
1673
1774
  const state = bus.stateOf(peer);
1674
1775
  const session = contextSession(peer);
1675
- const generation = isWsPeer(owner) || isCodexPeer(owner) ? owner.sessionGeneration : undefined;
1676
- if (!owner || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
1776
+ const generationFenced = !!owner && owner.checkpointUsesSessionGeneration !== false && "sessionGeneration" in owner;
1777
+ const generation = generationFenced ? owner.sessionGeneration : undefined;
1778
+ if (!owner || (generationFenced && generation === undefined) || (isWsPeer(owner) && owner.claiming) || (state !== "idle" && state !== "busy") || (reason === "context" && (!session || turns.get(peer)?.private || contextPii(peer)))) return Promise.resolve(undefined);
1677
1779
  const openWork = board.list().some((t) => (["proposed", "in_progress", "changes_requested", "in_review"].includes(t.state) && t.owner === peer) || (t.state === "in_review" && t.reviewer === peer));
1678
1780
  if (!openWork && !bus.queued(peer) && !bus.hasInFlight(peer) && state !== "busy") {
1679
1781
  if (reason === "quota") log(`budget ${peer}: no open work, no checkpoint`);
@@ -1685,7 +1787,7 @@ export async function startDaemon(opts: DaemonOptions) {
1685
1787
  : `Checkpoint request: your native context window is near the configured threshold. Finish the step you are on and call hub_checkpoint {summary, request_id: "${requestId}"} with what you were doing, what is half done and what a fresh session would need. This only saves a checkpoint; keep your tasks and session. The operator decides whether to continue or restart. Do not include private task text.`;
1686
1788
  const ask = newEnvelope(HUB, body, { to: [peer], kind: "budget", priority: "important" });
1687
1789
  return new Promise((resolve) => {
1688
- const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!(isWsPeer(owner) || isCodexPeer(owner)) || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
1790
+ const valid = () => !stopping && !recoveryActive() && bus.peers.get(peer) === owner && (!generationFenced || owner.sessionGeneration === generation) && ["idle", "busy"].includes(bus.stateOf(peer)) && contextSession(peer) === session;
1689
1791
  const done = (summary: string | undefined) => {
1690
1792
  clearTimeout(timer);
1691
1793
  checkpointWaits.delete(peer);
@@ -1729,6 +1831,8 @@ export async function startDaemon(opts: DaemonOptions) {
1729
1831
  // Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
1730
1832
  canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
1731
1833
  // A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
1834
+ // #683 U1: pauses, resumes and derived resets go to the event log, ids and numbers only.
1835
+ ledger: entry => event(entry),
1732
1836
  reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
1733
1837
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
1734
1838
  resumed: (record) => {
@@ -1755,8 +1859,17 @@ export async function startDaemon(opts: DaemonOptions) {
1755
1859
  notify: (line) => { notify(line); feedBudgetPauses(); },
1756
1860
  }, accountMembers, accountOf));
1757
1861
  budget.setRecoveryHold(recoveryActive());
1862
+ // ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
1863
+ // 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
1864
+ // 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).
1865
+ budget.seedResets(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
1758
1866
  const kimiTokens: { at: number; n: number }[] = [];
1759
1867
  startupCleanup.push(() => budget.close());
1868
+ // #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
1869
+ const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
1870
+ startupCleanup.push(() => usageRollup.close());
1871
+ const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
1872
+ pollUsageRollup();
1760
1873
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
1761
1874
  const runId = Date.now().toString(36);
1762
1875
  let turnSeq = 0;
@@ -1815,7 +1928,8 @@ export async function startDaemon(opts: DaemonOptions) {
1815
1928
  if (!snapshotBudget) {
1816
1929
  const requested = opts.snapshotBurstMs ?? 250;
1817
1930
  const ms = Number.isFinite(requested) ? Math.max(1, Math.min(250, requested)) : 250;
1818
- snapshotBudget = { deadline: performance.now() + ms };
1931
+ const now = opts.snapshotClock;
1932
+ snapshotBudget = { deadline: (now ? now() : performance.now()) + ms, ...(now ? { now } : {}) };
1819
1933
  setImmediate(() => { snapshotBudget = undefined; snapshotBudgetNoticed = false; });
1820
1934
  }
1821
1935
  return snapshotBudget;
@@ -1915,6 +2029,7 @@ export async function startDaemon(opts: DaemonOptions) {
1915
2029
  }
1916
2030
  const delta = tokenDeltas();
1917
2031
  /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
2032
+ let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
1918
2033
  const addTokens = (peer: PeerId, n: number): number => {
1919
2034
  const supervisionTurn = supervisionTurns.get(peer);
1920
2035
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
@@ -1993,6 +2108,7 @@ export async function startDaemon(opts: DaemonOptions) {
1993
2108
  } catch { /* absent or unreadable native telemetry is unknown */ }
1994
2109
  }, 1000),
1995
2110
  setInterval(() => budget.tick(), 30_000),
2111
+ setInterval(pollUsageRollup, 10_000),
1996
2112
  setInterval(() => {
1997
2113
  if (!ready || !memoryBinding) return;
1998
2114
  const before = memoryProblem;
@@ -2309,7 +2425,7 @@ export async function startDaemon(opts: DaemonOptions) {
2309
2425
  await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
2310
2426
  return line(tasks.recordLifecycle(by, a.id, a.receipt));
2311
2427
  case "hub_review":
2312
- return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage));
2428
+ return line(await tasks.review(by, a.id, a.verdict, a.note, a.unmet, a.head, a.coverage, a.findings));
2313
2429
  case "hub_remember":
2314
2430
  return tasks.remember(by, a);
2315
2431
  case "hub_checkpoint": {
@@ -2383,7 +2499,10 @@ export async function startDaemon(opts: DaemonOptions) {
2383
2499
  }
2384
2500
  case "task_show": {
2385
2501
  const task = board.get(Number(a.id));
2386
- return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
2502
+ if (!task) return JSON.stringify(`no task #${a.id}`);
2503
+ const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2504
+ const codeSymbols = tasks.publicView(task).codeSymbols;
2505
+ return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2387
2506
  }
2388
2507
  case "task_assign":
2389
2508
  case "task_assign_checked": {
@@ -2467,6 +2586,7 @@ export async function startDaemon(opts: DaemonOptions) {
2467
2586
  else delete defaultSources[peer];
2468
2587
  }
2469
2588
  };
2589
+ const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
2470
2590
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2471
2591
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2472
2592
  const unattendedPeers = new Set<string>();
@@ -2475,6 +2595,12 @@ export async function startDaemon(opts: DaemonOptions) {
2475
2595
  const claudePermissionGenerations = new Map<string, string>();
2476
2596
  const permissions = new Map<string, { push: string; peer: string; tool?: string; createdAt: number; expiresAt: number; done: (optionId: string | undefined, surface?: "console" | "dashboard" | "terminal", reason?: "expired" | "cancelled") => boolean }>();
2477
2597
  let stopping = false;
2598
+ const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
2599
+ docs: opts.releaseDocEvidence,
2600
+ gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
2601
+ current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
2602
+ record: receipt => event({ type: "release_authorization", by: "user", authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
2603
+ });
2478
2604
  const terminalReads = terminalOwnerReads();
2479
2605
  const terminalLaunches = new TerminalLaunches({
2480
2606
  owner: row => {
@@ -2684,13 +2810,14 @@ export async function startDaemon(opts: DaemonOptions) {
2684
2810
  };
2685
2811
  const permissionDisplay = (id: string, peer: PeerAdapter | undefined): string => {
2686
2812
  if (readOnlyPeer(id)) return "read-only";
2687
- if (isAcpPeer(peer) && ["unknown", "unmanaged"].includes(peer.permissionModeState)) return peer.permissionModeState;
2688
- if (starting.has(id) && (isAcpPeer(peer) || isCodexPeer(peer))) return peer.getPermissionMode();
2689
2813
  if (definitionOf(id)?.transport === "claude-channel" && isWsPeer(peer) && peer.state !== "offline") {
2690
2814
  const launch = claudePermissionLaunch(id);
2691
2815
  if (!launch || claudePermissionGenerations.get(id) !== peer.sessionGeneration) return "unverified";
2692
2816
  }
2693
- return permissionMode(id);
2817
+ const native = peer?.permissionModeState;
2818
+ // An offline ask clears the hub choice, not a stopped ACP runtime's retained native field.
2819
+ if (peer?.state === "offline" && !starting.has(id) && isPermissionMode(native)) return permissionMode(id);
2820
+ return native ?? permissionMode(id);
2694
2821
  };
2695
2822
  const permissionStatus = (id: string, peer: PeerAdapter | undefined): Record<string, unknown> => {
2696
2823
  const note = definitionOf(id)?.permission.unmanagedStatusNote;
@@ -2745,6 +2872,8 @@ export async function startDaemon(opts: DaemonOptions) {
2745
2872
  protocol: PROTOCOL,
2746
2873
  features: DAEMON_FEATURES,
2747
2874
  configuredAdapters: configuredIds,
2875
+ ...(externalAdapters.length ? { externalAdapters } : {}),
2876
+ ...(externalAdapterRefusals.length ? { externalAdapterRefusals } : {}),
2748
2877
  startablePeers: SUPPORTED_PEERS.filter(isStartablePeer),
2749
2878
  readOnlyPeers: extraIds.filter(readOnlyPeer),
2750
2879
  // #450 I3: the instance ids this daemon bound at startup; a launcher selects only one of these.
@@ -2756,10 +2885,11 @@ export async function startDaemon(opts: DaemonOptions) {
2756
2885
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2757
2886
  codexProxyPort: opts.codexProxyPort,
2758
2887
  peers: Object.fromEntries(
2759
- peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2888
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2760
2889
  ),
2761
2890
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2762
2891
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2892
+ actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
2763
2893
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2764
2894
  planner: { peers: plannerPeers(), source: plannerSource },
2765
2895
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
@@ -3156,7 +3286,7 @@ export async function startDaemon(opts: DaemonOptions) {
3156
3286
  if (!end.head) throw new Error("end HEAD evidence is unavailable");
3157
3287
  const dirtyPaths = knownChangedPaths(turnRepo, end.head, end.tree, end.budget);
3158
3288
  if (!dirtyPaths) throw new Error("end worktree diff evidence is unavailable");
3159
- const checkBudget = () => { if (end.budget && performance.now() >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3289
+ const checkBudget = () => { if (end.budget && budgetNow(end.budget) >= end.budget.deadline) throw new SnapshotBudgetExceeded(); };
3160
3290
  const dirty = new Set(dirtyPaths), dirtyScope = new Set(dirtyPaths), incoming = new Set(paths);
3161
3291
  for (const path of dirtyPaths) {
3162
3292
  checkBudget();
@@ -3674,12 +3804,24 @@ export async function startDaemon(opts: DaemonOptions) {
3674
3804
  }
3675
3805
  if (adapter?.start.daemonPort === "owned-acp") {
3676
3806
  if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return { ok: false, error: "Jcode project MCP entry missing or stale; the person runs ahub jcode to configure it" };
3677
- 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"]({ command: config.jcode_cmd, home: config.jcode_home, providerProfile: (args.providerProfile ?? config.jcode_provider_profile) || undefined,
3678
- ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
3807
+ // #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
3808
+ const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
3809
+ if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
3810
+ if (jcodeVerdict.note) log(jcodeVerdict.note);
3811
+ // #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
3812
+ const instance = declarations[peer];
3813
+ // #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
3814
+ // written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
3815
+ if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
3816
+ 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,
3817
+ providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
3818
+ ...(instance?.provider ? { provider: instance.provider } : {}),
3819
+ ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
3679
3820
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
3821
+ jcodeLaunches.set(peer, jcode);
3680
3822
  recoveryTaskPreface(peer); await ensurePreface(peer);
3681
3823
  try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
3682
- catch { await jcode.stop().catch(() => {}); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
3824
+ 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)"); }
3683
3825
  return { ok: true, policy: "unmanaged" };
3684
3826
  }
3685
3827
  if (adapter?.start.acpCommand) {
@@ -3740,9 +3882,10 @@ export async function startDaemon(opts: DaemonOptions) {
3740
3882
  onActivity: item => activityRings[peer]?.append(item),
3741
3883
  isPrivateTurn: () => !!turns.get(peer)?.private || contextPii(peer),
3742
3884
  kind: adapter.kind, cmd: [launch.cmd, ...launch.args], permissionMode: permission === "read-only" ? "ask" : permission,
3885
+ ...(externalIds.has(adapter.kind) ? { requireOwnedGroup: true, requireLoadSessionEcho: true, nativePolicy: adapter.permission.genericAcp ?? {} } : {}),
3743
3886
  ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}),
3744
3887
  cwd: executionCwd, watchdogMs: config.watchdog_ms, onPermission,
3745
- autoApprove: title => !stopping && HUB_TOOL_TITLES.has(title),
3888
+ autoApprove: title => !externalIds.has(adapter.kind) && !stopping && HUB_TOOL_TITLES.has(title),
3746
3889
  log: () => log(`${peer}: ACP runtime diagnostic: details withheld`),
3747
3890
  onUsageDiagnostic: (observation, sessionId) => {
3748
3891
  if (observation.source !== "usage_update" || observation.shape !== "context-used") return;
@@ -3755,7 +3898,7 @@ export async function startDaemon(opts: DaemonOptions) {
3755
3898
  mcpServers,
3756
3899
  preamble: sessionRoleContract(peer),
3757
3900
  } } });
3758
- if (!isAcpPeer(owner)) throw new Error("ACP factory did not return its native transport");
3901
+ if (!isAcpPeer(owner) || owner.id !== peer) throw new Error("ACP factory did not return its bound native transport");
3759
3902
  recoveryTaskPreface(peer); await ensurePreface(peer);
3760
3903
  try { await owner.start(); await reconcileNativeStart(peer, owner); }
3761
3904
  catch (error) {
@@ -3763,7 +3906,7 @@ export async function startDaemon(opts: DaemonOptions) {
3763
3906
  if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
3764
3907
  if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
3765
3908
  if (error instanceof QwenIdentityError) throw error;
3766
- throw new Error("Qwen could not reach its provider or complete its startup handshake; verify the provider connection and Qwen Code 0.25.0 (details withheld)");
3909
+ throw new Error(`${adapter.displayName} could not reach its provider or complete its startup handshake; verify the provider connection and native CLI${isCompatId(adapter.kind) ? ` (${describe(adapter.kind)})` : ""}; see ahub agents (details withheld)`);
3767
3910
  }
3768
3911
  return { ok: true };
3769
3912
  }
@@ -3774,6 +3917,12 @@ export async function startDaemon(opts: DaemonOptions) {
3774
3917
  admitAttach: () => membershipAllowed(peer),
3775
3918
  permissionMode: permissionMode(peer),
3776
3919
  onTokens: (added) => void addTokens(peer, added),
3920
+ // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
3921
+ // the id names the hub run, peer and a per-run sequence, so it is unique across restarts. It never carries the native thread: usage telemetry has no session id (docs/events.md).
3922
+ onTokenBreakdown: delta => {
3923
+ event({ type: "usage", peer, source: "codex_appserver", id: `codex:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: delta.input, cacheReadTokens: delta.cachedInput,
3924
+ outputTokens: delta.output, reasoningTokens: delta.reasoning, totalTokens: delta.total, ...taskAttribution(peer) });
3925
+ },
3777
3926
  contextReader: adapter.budget?.context,
3778
3927
  onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
3779
3928
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
@@ -4121,7 +4270,8 @@ export async function startDaemon(opts: DaemonOptions) {
4121
4270
  try { await reviewer.start(); }
4122
4271
  catch (error) {
4123
4272
  await reviewer.stop().catch(() => {});
4124
- const reason = backend.nativePolicy && error instanceof Error ? sanitize(error.message).slice(0, 1000)
4273
+ // #691: a version refusal names only versions and the trust command, so it is shown for every backend.
4274
+ const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
4125
4275
  : "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
4126
4276
  reviewerUnavailable.set(peer, reason);
4127
4277
  return { ok: false, error: reason };
@@ -4339,7 +4489,7 @@ export async function startDaemon(opts: DaemonOptions) {
4339
4489
  tasks: boardTasks.map((task) => {
4340
4490
  const view = tasks.publicView(task);
4341
4491
  // Refs and history can themselves quote private text. The dashboard needs neither.
4342
- return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
4492
+ return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
4343
4493
  }),
4344
4494
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
4345
4495
  budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
@@ -4398,12 +4548,12 @@ export async function startDaemon(opts: DaemonOptions) {
4398
4548
  function roleHolders(candidate: HubConfig): { planners: string[]; conductors: string[] } {
4399
4549
  // A valid transfer may briefly have two conductors while its leaves are planned. Validate the final map later.
4400
4550
  const policy = Object.fromEntries(Object.entries(candidate.roles).map(([peer, roles]) => [peer, roles.filter(role => role !== "conductor")]));
4401
- const settled = resolveSettledRoles(policy, candidate.planner, peer => rosterAllows(candidate.roster, peer, candidate.pi.enabled, declarations, adapterCatalog));
4551
+ const settled = resolveSettledRoles(policy, candidate.planner, peer => projectRosterAllows(candidate.roster, peer, candidate.pi.enabled));
4402
4552
  return { planners: settled.planners, conductors: Object.keys(candidate.roles).filter(peer => candidate.roles[peer]!.includes("conductor")).sort() };
4403
4553
  }
4404
4554
  function prepareConfiguration(sources: ConfigSources, authority: SettingAuthority, scope?: string, confirmed: readonly string[] = []) {
4405
- const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? []);
4406
- validateEffectiveConfig(fileConfig);
4555
+ const fileConfig = loadConfig(opts.cwd, sources.layers, sources.routing.retired ?? [], externalDefinitions);
4556
+ validateEffectiveConfig(fileConfig, adapterCatalog);
4407
4557
  const candidate = structuredClone(desiredConfig), routing = structuredClone(desiredRouting);
4408
4558
  const accepted: string[] = [], rejected: { key: string; reason: string }[] = [], guarded: string[] = [];
4409
4559
  const previews: { key: string; before: string; after: string; effect?: string }[] = [];
@@ -4454,7 +4604,7 @@ export async function startDaemon(opts: DaemonOptions) {
4454
4604
  settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
4455
4605
  if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
4456
4606
  }
4457
- const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
4607
+ const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
4458
4608
  const decision = authorizeConfigLeaf(input);
4459
4609
  if (!decision.allowed) {
4460
4610
  const next = `${decision.reason}; review and run ahub settings reload, or revert this key${machine && tracked !== false ? "; move machine-local declarations to an untracked local file first" : ""}`;
@@ -4500,7 +4650,7 @@ export async function startDaemon(opts: DaemonOptions) {
4500
4650
  const running = structuredClone({ ...config, roles: policyRoles });
4501
4651
  for (const change of changes) if (change.applies !== "restart") putConfig(running, change.key, change.after);
4502
4652
  running.retired = fileConfig.retired;
4503
- validateEffectiveConfig(running);
4653
+ validateEffectiveConfig(running, adapterCatalog);
4504
4654
  conductorPeer(running.roles);
4505
4655
  for (const peer of Object.keys(running.catalog_roles ?? {})) selectedRoles(peer, running.roles, { ...resolvedProfile.catalog_roles, ...catalogAssignments(running.catalog_roles) }, catalog);
4506
4656
  const problem = running.mlx.enabled === false ? mlxPinProblem(routing, sources.texts["routing.toml"] !== null) : undefined;
@@ -4530,7 +4680,7 @@ export async function startDaemon(opts: DaemonOptions) {
4530
4680
  const texts = configSourceTexts(opts.cwd), next = configSourceTexts(scratch);
4531
4681
  texts["config.json"] = next["config.json"];
4532
4682
  texts["config.local.json"] = next["config.local.json"];
4533
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4683
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
4534
4684
  const refusal = prepared.rejected.find(item => item.key === scope);
4535
4685
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4536
4686
  return prepared.fileConfig;
@@ -4538,7 +4688,7 @@ export async function startDaemon(opts: DaemonOptions) {
4538
4688
  function routingWriteCheck(authority: SettingAuthority, overlay: import("./routing.ts").RoutingOverlay | undefined, scope?: string): void {
4539
4689
  const texts = configSourceTexts(opts.cwd);
4540
4690
  texts["routing.local.toml"] = overlay === undefined ? null : overlayToml(overlay);
4541
- const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd), authority, scope);
4691
+ const prepared = prepareConfiguration(parseConfigSources(texts, undefined, opts.cwd, externalAdapters), authority, scope);
4542
4692
  const refusal = prepared.rejected.find(item => item.key === scope);
4543
4693
  if (refusal) throw new Error(`${refusal.key}: ${refusal.reason}`);
4544
4694
  }
@@ -4555,7 +4705,7 @@ export async function startDaemon(opts: DaemonOptions) {
4555
4705
  const texts = configSourceTexts(opts.cwd);
4556
4706
  fingerprint = configFingerprint(texts);
4557
4707
  if (confirm !== undefined && (typeof confirm !== "string" || authority !== "terminal" || scope || !reloadConfirmation || reloadConfirmation.token !== confirm || reloadConfirmation.fingerprint !== fingerprint || reloadConfirmation.revision !== configRevision)) return { ok: false, error: "configuration confirmation is stale or changed; run ahub settings reload again" };
4558
- const sources = parseConfigSources(texts, undefined, opts.cwd);
4708
+ const sources = parseConfigSources(texts, undefined, opts.cwd, externalAdapters);
4559
4709
  const prepared = prepareConfiguration(sources, authority, scope, confirm === undefined ? [] : reloadConfirmation!.keys);
4560
4710
  if (confirm !== undefined && prepared.rejected.some(item => reloadConfirmation!.keys.includes(item.key))) return { ok: false, error: "configuration trust changed; run ahub settings reload again" };
4561
4711
  if (scope && prepared.rejected.some(item => item.key === scope)) return { ok: false, error: prepared.rejected.find(item => item.key === scope)!.reason };
@@ -4570,6 +4720,7 @@ export async function startDaemon(opts: DaemonOptions) {
4570
4720
  if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
4571
4721
  putConfig(config, change.key, change.after);
4572
4722
  }
4723
+ if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
4573
4724
  if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
4574
4725
  for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
4575
4726
  for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
@@ -4622,7 +4773,7 @@ export async function startDaemon(opts: DaemonOptions) {
4622
4773
  const stamp = CONFIG_FILES.map((name) => { try { const s = statSync(join(opts.cwd, ".agenthub", name)); return `${s.mtimeMs}:${s.size}`; } catch { return "-"; } }).join("|");
4623
4774
  if (storedConfigCache?.stamp !== stamp) {
4624
4775
  let loaded: HubConfig | undefined;
4625
- try { loaded = loadConfig(opts.cwd); } catch { /* the rows say that the files do not load */ }
4776
+ try { loaded = loadConfig(opts.cwd, undefined, undefined, externalDefinitions); } catch { /* the rows say that the files do not load */ }
4626
4777
  storedConfigCache = { stamp, config: loaded };
4627
4778
  }
4628
4779
  return storedConfigCache.config;
@@ -4645,7 +4796,7 @@ export async function startDaemon(opts: DaemonOptions) {
4645
4796
  const rows = settingRows({
4646
4797
  instances: declarations, adapterCatalog, cwd: opts.cwd, routing: activeRouting(), ...(routingProblem ? { routingProblem } : {}), ...(stored?.ignored ? { ignored: stored.ignored } : {}),
4647
4798
  running: (path) => path[0] === "roster" ? onRoster(path[1]!) : path[0] === "tasks" && path[1] === "wip_limits" ? tasks.wipLimit(path[2]!) : valueAt(config, path),
4648
- next: stored ? (path) => path[0] === "roster" ? rosterAllows(stored.roster, path[1]!, stored.pi.enabled, declarations, adapterCatalog) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4799
+ next: stored ? (path) => path[0] === "roster" ? projectRosterAllows(stored.roster, path[1]!, stored.pi.enabled) : path[0] === "tasks" && path[1] === "wip_limits" ? (Object.hasOwn(stored.tasks.wip_limits ?? {}, path[2]!) ? stored.tasks.wip_limits![path[2]!] : 1) : valueAt(stored, path) : undefined,
4649
4800
  roles: roleState,
4650
4801
  mode: (peer) => {
4651
4802
  const owner = bus.peers.get(peer), attached = !!owner && owner.state !== "offline";
@@ -4698,10 +4849,12 @@ export async function startDaemon(opts: DaemonOptions) {
4698
4849
  if (rosterChanging) return { ok: false, error: `roster change for ${rosterChanging} is in progress` };
4699
4850
  const peer = def.path[1]!;
4700
4851
  let saved = false;
4852
+ // #225: a stale key has no peer behind it. What changes is that the key is deleted, not whether the peer is on the roster.
4853
+ const stale = staleRosterKey(def.key) !== undefined, previousKey = config.roster[peer], sharedCopy = stale && sharedValue(opts.cwd, def) !== undefined;
4701
4854
  const terminalNotice = retainedTerminalNotice(peer);
4702
4855
  let handoff: Awaited<ReturnType<Tasks["reassignForRoster"]>> = [];
4703
4856
  try {
4704
- const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
4857
+ const next = rosterCandidate(def, value, undo), before = onRoster(peer), after = projectRosterAllows(next, peer, config.pi.enabled);
4705
4858
  if (!after) {
4706
4859
  const refusal = rosterRemovalRefusal(peer, now);
4707
4860
  if (refusal) return { ok: false, error: refusal };
@@ -4715,7 +4868,7 @@ export async function startDaemon(opts: DaemonOptions) {
4715
4868
  config.roster = next;
4716
4869
  settlePlanner(); // #455: an automatic planner follows the roster
4717
4870
  saved = true;
4718
- recordSetting(def.key, before, after, source, undo, reason);
4871
+ recordSetting(def.key, stale ? previousKey ?? null : before, stale ? null : after, source, undo, reason);
4719
4872
  bus.rosterChanged(peer); // fence the queue before stopping or detaching the adapter
4720
4873
  if (!after) {
4721
4874
  for (const pending of permissions.values()) if (pending.peer === peer) pending.done(undefined);
@@ -4740,6 +4893,7 @@ export async function startDaemon(opts: DaemonOptions) {
4740
4893
  const reloaded = await reloadConfig(authority, `roster.${peer}`);
4741
4894
  if (reloaded.ok !== true) return { ...reloaded, saved: true };
4742
4895
  writeStatus();
4896
+ if (stale) return { ok: true, text: [`${peer}: stale roster key removed from .agenthub/config.local.json`, ...(sharedCopy ? [`still set in .agenthub/config.json: that copy decides nothing, and stays until you edit it there`] : [])].join("\n") };
4743
4897
  const stayed = !after ? board.list().filter(task => !isClosedTaskState(task.state) && (task.owner === peer || task.reviewer === peer)) : [];
4744
4898
  return { ok: true, text: [`${peer}: ${after ? "on" : "off"} roster (in force now)`, ...(!after && terminalNotice ? [terminalNotice] : []), ...(after && definitionOf(peer)?.cli.rosterReturnNotice ? [definitionOf(peer)!.cli.rosterReturnNotice!(codexResumeCommand(peer))] : []), ...(!after ? handoff.map(move => sanitize(`task ${move.title}: ${move.role} assignment moved to ${move.to}`)) : []), ...stayed.map(task => sanitize(`task ${tasks.publicTitle(task)} stays with ${peer} as ${[task.owner === peer ? "owner" : "", task.reviewer === peer ? "reviewer" : ""].filter(Boolean).join(" and ")} (${task.state})`)), ...(!after && !handoff.length && !stayed.length ? ["No open tasks to hand over."] : [])].join("\n"), ...(!after ? { handoff } : {}) };
4745
4899
  } catch (error) {
@@ -4752,6 +4906,12 @@ export async function startDaemon(opts: DaemonOptions) {
4752
4906
  const def = settingDef(key);
4753
4907
  if (typeof def === "string") return { error: def };
4754
4908
  const routing = activeRouting();
4909
+ if (staleRosterKey(def.key) && raw !== null) return { error: `${def.key} names an instance that is no longer declared: it can only be removed (ahub roster remove ${def.path[1]})` };
4910
+ // A removal only edits the machine-local file. A key that lives only in the shared one would be "removed" and come back on reload.
4911
+ if (staleRosterKey(def.key) && !storedLocally(opts.cwd, def)) return { error: `${def.key} is not in .agenthub/config.local.json, so removing it here changes nothing: edit .agenthub/config.json` };
4912
+ // Removing the local copy would let a different shared value show through, which the reload refuses as setting a stale key, after the write.
4913
+ const shared = staleRosterKey(def.key) ? sharedValue(opts.cwd, def) : undefined;
4914
+ if (shared !== undefined && shared !== config.roster[def.path[1]!]) return { error: `${def.key} is also set to ${String(shared)} in .agenthub/config.json: edit .agenthub/config.json first; nothing was changed` };
4755
4915
  try { return { def, value: checkSettingValue(def, raw, def.type === "roles" ? compatibleRoles(def.path[1]!) : Object.keys(routing.hub_routes ?? {})) }; }
4756
4916
  catch (error) { return { error: (error as Error).message }; }
4757
4917
  }
@@ -4848,9 +5008,12 @@ export async function startDaemon(opts: DaemonOptions) {
4848
5008
  const input = settingInput(key, raw);
4849
5009
  if ("error" in input) return { ok: false, error: input.error };
4850
5010
  const { def, value } = input;
5011
+ if (def.group === "Roster" && staleRosterKey(def.key)) {
5012
+ return { ok: true, lines: [`${def.path[1]}: no longer declared; its roster key decides nothing today.`, `Removing it deletes roster.${def.path[1]} from .agenthub/config.local.json and nothing else.`, ...(sharedValue(opts.cwd, def) !== undefined ? [`.agenthub/config.json also holds roster.${def.path[1]}: that copy stays until you edit it there.`] : [])] };
5013
+ }
4851
5014
  if (def.group === "Roster") {
4852
5015
  try {
4853
- const peer = def.path[1]!, next = rosterCandidate(def, value), on = rosterAllows(next, peer, config.pi.enabled, declarations, adapterCatalog);
5016
+ const peer = def.path[1]!, next = rosterCandidate(def, value), on = projectRosterAllows(next, peer, config.pi.enabled);
4854
5017
  return { ok: true, lines: [`${peer}: ${onRoster(peer) ? "on" : "off"} -> ${on ? "on" : "off"} roster`, ...(on ? ["Admission, starts and new work become available; any existing pause or held delivery remains."] : [`${bus.queued(peer)} queued messages are retained until the peer returns.`, "Owned runtimes stop; native terminal sessions detach. Open tasks are offered to another eligible peer.", `For a busy peer, use ahub roster remove ${peer} --now explicitly; this stops its owned runtime or detaches its native session during the active turn.`, ...(retainedTerminalNotice(peer) ? [retainedTerminalNotice(peer)!] : []), ...tasks.rosterPreview(peer).map(move => sanitize(`task ${move.title}: ${move.to ? `${move.role} offered to ${move.to} if removed now` : `${move.role} stays with ${peer}; no eligible replacement`}`)), ...(rosterRemovalRefusal(peer, false) ? [rosterRemovalRefusal(peer, false)!] : [])])], ...(on ? {} : { confirm: peer }) };
4855
5018
  } catch (error) { return { ok: false, error: sanitize((error as Error).message) }; }
4856
5019
  }
@@ -4891,7 +5054,7 @@ export async function startDaemon(opts: DaemonOptions) {
4891
5054
  if (a.action === "sdlc_queue" || a.action === "sdlc_release") {
4892
5055
  const liveIssues = a.liveIssues ?? [];
4893
5056
  if (typeof a.repo !== "string" || !Array.isArray(liveIssues) || liveIssues.length > 32) return bad;
4894
- const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : await tasks.releaseReadiness(a.repo, liveIssues);
5057
+ const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : releaseAuthorizations.report(await tasks.releaseReadiness(a.repo, liveIssues));
4895
5058
  return { ok: true, report };
4896
5059
  }
4897
5060
  switch (a.action) {
@@ -5009,6 +5172,7 @@ export async function startDaemon(opts: DaemonOptions) {
5009
5172
  const incompatibleRecoveryTarget = () => {
5010
5173
  const cancelled = incompatibleCancellationTarget();
5011
5174
  if (cancelled) return cancelled;
5175
+ if (externalIds.size && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("external_adapter_modules"))) return "target lacks external_adapter_modules; verified external definitions cannot be preserved by that daemon";
5012
5176
  if (configuredIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("configured_acp"))) return "target lacks configured_acp; project adapter definitions cannot be preserved by that daemon";
5013
5177
  if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
5014
5178
  if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
@@ -5171,8 +5335,11 @@ export async function startDaemon(opts: DaemonOptions) {
5171
5335
  if (nativeUnattended(peer)) return { ok: false, error: "Codex started --unattended already skips prompts; run ahub permission codex ask after it stops to clear the hub choice, then restart without --unattended" };
5172
5336
  }
5173
5337
  const from = permissionMode(peer);
5338
+ const runtime: PeerAdapter = owner;
5339
+ const nativeMode = runtime.permissionModeState;
5340
+ if (nativeMode === "unknown" || nativeMode === "unmanaged") return { ok: false, error: `${peer} permission mode ${nativeMode}; inspect its native policy before changing modes` };
5174
5341
  try {
5175
- if (isAcpPeer(owner) || isCodexPeer(owner)) await owner.setPermissionMode(mode);
5342
+ if (runtime.setPermissionMode) await runtime.setPermissionMode(mode);
5176
5343
  } catch (error) { return { ok: false, error: (error as Error).message }; }
5177
5344
  if (bus.peers.get(peer) !== owner || bus.stateOf(peer) === "offline") return { ok: false, error: `${peer} session changed; inspect status and retry` };
5178
5345
  permissionModes.set(peer, mode);
@@ -5264,6 +5431,12 @@ export async function startDaemon(opts: DaemonOptions) {
5264
5431
  c.peer = String(msg.peer ?? "");
5265
5432
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5266
5433
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5434
+ // #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
5435
+ // peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
5436
+ if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
5437
+ const owner = jcodeLaunches.get(c.peer);
5438
+ if (!owner || !owner.ownsLaunch(msg.launchId)) return sock.close(4403, "this process is not an owned launch of that peer; start it with ahub jcode");
5439
+ }
5267
5440
  } else {
5268
5441
  c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
5269
5442
  if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
@@ -5327,6 +5500,31 @@ export async function startDaemon(opts: DaemonOptions) {
5327
5500
  if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
5328
5501
  return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
5329
5502
  }
5503
+ if (msg.op === "discard") {
5504
+ // #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
5505
+ // through the same `resolveDelivery` as one by one, each with the reason, so the journal records each as a `resolve ... discard`. Push peers' queues are never touched
5506
+ // because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
5507
+ if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
5508
+ const state = msg.state === undefined ? "queued" : msg.state;
5509
+ if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
5510
+ const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
5511
+ if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
5512
+ if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
5513
+ if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
5514
+ let discarded = 0; const refused: Record<string, number> = {};
5515
+ for (const row of rows) {
5516
+ // A queued row with no journal record of its own carries the journal's global revision, which every discard moves: read the row's current revision just before
5517
+ // discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
5518
+ const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
5519
+ try {
5520
+ if (!current || current.state !== state) throw new Error("unknown delivery");
5521
+ bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
5522
+ }
5523
+ catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
5524
+ }
5525
+ writeStatus();
5526
+ return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
5527
+ }
5330
5528
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
5331
5529
  if (msg.op === "show") {
5332
5530
  const row = bus.queueShow(msg.id);
@@ -5338,7 +5536,7 @@ export async function startDaemon(opts: DaemonOptions) {
5338
5536
  bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
5339
5537
  writeStatus();
5340
5538
  return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
5341
- } catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
5539
+ } catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
5342
5540
  }
5343
5541
  case "delivery_holds": {
5344
5542
  if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
@@ -5364,9 +5562,15 @@ export async function startDaemon(opts: DaemonOptions) {
5364
5562
  const envs = bus.pull(peer.id);
5365
5563
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
5366
5564
  peer.lastInboxAt = Date.now();
5565
+ let polling: ReturnType<InboxPolling["read"]>;
5566
+ try {
5567
+ const assigned = peer.pullOnly && board.statusTasks().some(task => !isClosedTaskState(task.state) && task.state !== "backlog" && (task.owner === peer.id || task.reviewer === peer.id));
5568
+ polling = peer.pullOnly ? inboxPolling.read(peer.id, envs.length === 0, assigned, peer.lastInboxAt) : undefined;
5569
+ if (polling?.notice) notify(`${peer.id} is polling an empty inbox with no task (${polling.count} empty reads in ${polling.minutes} min)`);
5570
+ } catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
5367
5571
  writeStatus();
5368
5572
  if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
5369
- return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id) });
5573
+ return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
5370
5574
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
5371
5575
  }
5372
5576
  case "delivery_complete": {
@@ -5700,16 +5904,43 @@ export async function startDaemon(opts: DaemonOptions) {
5700
5904
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
5701
5905
  }
5702
5906
  case "sdlc": {
5907
+ if (msg.op === "release-guard" || msg.op === "release-guard-hook") {
5908
+ if (c.role !== "console" && c.role !== "tools" && c.role !== "peer" || c.peer && (!!turns.get(c.peer)?.private || contextPii(c.peer))) return void reply({ ok: false, error: "release guard unavailable for this caller" });
5909
+ if (msg.op === "release-guard") {
5910
+ const parsed = parseReleaseGuardArgv(msg.argv);
5911
+ if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
5912
+ void releaseAuthorizations.guardRequest(parsed.request).then(reply, () => reply({ ok: false, error: "release guard evidence unavailable" }));
5913
+ } else {
5914
+ const parsed = parseReleasePushUpdates(msg.remoteName, msg.remoteUrl, msg.stdin);
5915
+ if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
5916
+ reply(releaseAuthorizations.guardPush(parsed.remoteName, parsed.remoteUrl, parsed.updates));
5917
+ }
5918
+ return;
5919
+ }
5703
5920
  if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
5921
+ if (msg.op === "authorize-release") {
5922
+ void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
5923
+ authorization => reply({ ok: true, authorization }),
5924
+ error => reply({ ok: false, error: releaseAuthorizationError(error) }),
5925
+ );
5926
+ return;
5927
+ }
5704
5928
  if (msg.op === "queue" || msg.op === "release") {
5705
5929
  if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
5706
5930
  const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
5707
- void read.then(report => reply({ ok: true, report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
5931
+ void read.then(report => reply({ ok: true, report: msg.op === "release" ? releaseAuthorizations.report(report as Awaited<ReturnType<typeof tasks.releaseReadiness>>) : report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
5708
5932
  return;
5709
5933
  }
5710
5934
  if (msg.op === "export") {
5711
5935
  try { return void reply({ ok: true, export: tasks.workflowExport(msg.id) }); } catch { return void reply({ ok: false, error: "workflow export unavailable" }); }
5712
5936
  }
5937
+ if (msg.op === "accept-departure") {
5938
+ void tasks.acceptPlanDeparture(USER, msg.id, msg.head, msg.reason).then(
5939
+ () => reply({ ok: true }),
5940
+ () => reply({ ok: false, error: "plan departure not accepted: task changed, current complete file evidence unavailable, or arguments invalid" }),
5941
+ );
5942
+ return;
5943
+ }
5713
5944
  if (msg.op === "accept") {
5714
5945
  // #481 S1: the person's spec acceptance, bound to the issue's body edit time; no body is read.
5715
5946
  if (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !Number.isSafeInteger(msg.issue) || msg.issue <= 0 || (msg.repo !== undefined && typeof msg.repo !== "string")) return void reply({ ok: false, error: "usage: ahub sdlc accept <task> --issue <n> [--repo owner/repository]" });
@@ -5732,7 +5963,11 @@ export async function startDaemon(opts: DaemonOptions) {
5732
5963
  if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
5733
5964
  void (async () => {
5734
5965
  const rows = [];
5735
- for (const task of selected.slice(0, 100)) rows.push({ id: task.id, title: tasks.publicTitle(task), ...(tasks.isPii(task) ? {} : { receipt: task.sdlc }), ...await tasks.lifecycleStatus(task.id, msg.id !== undefined) });
5966
+ for (const task of selected.slice(0, 100)) {
5967
+ const status = await tasks.lifecycleStatus(task.id, msg.id !== undefined);
5968
+ const current = board.get(task.id) ?? task;
5969
+ rows.push({ id: task.id, title: tasks.publicTitle(current), ...(tasks.isPii(current) ? {} : { receipt: tasks.publicView(current).sdlc }), ...status });
5970
+ }
5736
5971
  reply({ ok: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
5737
5972
  })().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
5738
5973
  return;
@@ -5908,6 +6143,41 @@ export async function startDaemon(opts: DaemonOptions) {
5908
6143
  budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
5909
6144
  }
5910
6145
  return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
6146
+ case "usage": {
6147
+ // #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
6148
+ const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
6149
+ if (c.role !== "console" && !planner) return void reply({ t: "usage", ok: false, error: "usage is read by the person and a configured planner" });
6150
+ if (planner && c.peer && turns.get(c.peer)?.private) return void reply({ t: "usage", ok: false, error: "usage is unavailable during a private turn" });
6151
+ const op = msg.op === undefined ? "query" : msg.op;
6152
+ const num = (v: unknown): number | undefined | null => v === undefined ? undefined : typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
6153
+ const since = num(msg.since), until = num(msg.until);
6154
+ if (since === null || until === null) return void reply({ t: "usage", ok: false, error: "since and until are epoch milliseconds" });
6155
+ if (msg.task !== undefined && !(Number.isSafeInteger(msg.task) && (msg.task as number) > 0)) return void reply({ t: "usage", ok: false, error: "task is a positive task id" });
6156
+ try {
6157
+ // Fold what the log has now, within a time budget: a hub that has just started is still behind a large log, and an answer built from the
6158
+ // part folded so far would read as "no records" for a peer that did use tokens. `behind` says so when the budget ran out (#683 U3).
6159
+ // ponytail: catchUp runs synchronously on the daemon loop, so a request on a log far behind stalls it for up to this budget (about 250 ms
6160
+ // folds the real 47 MB log). Ceiling: a log several times larger answers `behind` and finishes over the 10 s polls; move the fold to a worker if a stall is felt.
6161
+ const behind = !usageRollup.catchUp(250);
6162
+ const reply_ = (body: Record<string, unknown>) => reply({ ...body, ...(behind ? { behind: true } : {}) });
6163
+ if (op === "limits") {
6164
+ if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ t: "usage", ok: false, error: "peer must be a peer id" });
6165
+ if (msg.limit !== undefined && !(Number.isInteger(msg.limit) && (msg.limit as number) >= 1 && (msg.limit as number) <= LIMITS_MAX)) return void reply({ t: "usage", ok: false, error: `limit is an integer from 1 to ${LIMITS_MAX}` });
6166
+ const status = budget.status();
6167
+ // The pauses the hub holds now: an event-open pause it no longer holds (an `ahub reset` cleared it) is reported as ended, not open.
6168
+ const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
6169
+ return void reply_({ t: "usage", ok: true, op, limits: usageRollup.limits(msg.peer, { ...(msg.limit !== undefined ? { limit: msg.limit as number } : {}), live }), current: publicPeerBudget(status, text => tasks.nameable(text)) });
6170
+ }
6171
+ if (op === "total") return void reply_({ t: "usage", ok: true, op, total: usageRollup.total() });
6172
+ if (op !== "query") return void reply({ t: "usage", ok: false, error: "op is query, limits or total" });
6173
+ const by = (msg.by ?? "peer") as UsageGroup;
6174
+ if (!USAGE_GROUPS.includes(by)) return void reply({ t: "usage", ok: false, error: `by is one of ${USAGE_GROUPS.join(", ")}` });
6175
+ // issue, pr and release are the board's current links of each task (ids only, never a title).
6176
+ const board_ = new Map(board.list().map(t => [t.id, t]));
6177
+ const links = (id: number) => { const t = board_.get(id); return t ? { ...(t.sdlc?.issue ? { issue: t.sdlc.issue } : {}), ...(t.sdlc?.pr ? { pr: t.sdlc.pr } : {}), ...(t.release ? { release: t.release } : {}) } : undefined; };
6178
+ return void reply_({ t: "usage", ok: true, op, usage: usageRollup.query({ by, ...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}), ...(msg.task !== undefined ? { task: msg.task as number } : {}) }, links) });
6179
+ } catch (error) { return void reply({ t: "usage", ok: false, error: `usage unavailable: ${(error as Error).message.slice(0, 120)}` }); }
6180
+ }
5911
6181
  case "permit":
5912
6182
  if (c.role !== "console") return void reply({ t: "permit", ok: false, error: "permit is a console command" });
5913
6183
  return void reply({ t: "permit", ok: permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined, msg.surface === "console" ? "console" : "terminal") === true });
@@ -5936,6 +6206,7 @@ export async function startDaemon(opts: DaemonOptions) {
5936
6206
  stopping = true;
5937
6207
  terminalLaunches.clear();
5938
6208
  // Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
6209
+ const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
5939
6210
  const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
5940
6211
  runnerRefreshAbort.abort();
5941
6212
  // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
@@ -5951,7 +6222,7 @@ export async function startDaemon(opts: DaemonOptions) {
5951
6222
  for (const pending of checkpointWaits.values()) pending.done(undefined);
5952
6223
  for (const p of permissions.values()) p.done(undefined);
5953
6224
  try {
5954
- await terminalReadsStopped;
6225
+ await Promise.all([terminalReadsStopped, codeFactsStopped]);
5955
6226
  await terminalLaunches.settled();
5956
6227
  // A peer can still be inside memory recall or its native handshake when kill arrives.
5957
6228
  // Drain those starts before taking the final owned-process snapshot.
@@ -6054,6 +6325,6 @@ export async function startDaemon(opts: DaemonOptions) {
6054
6325
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
6055
6326
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6056
6327
  } finally {
6057
- if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
6328
+ if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
6058
6329
  }
6059
6330
  }