@staix/agent-hub 0.12.45 → 0.14.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 (108) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +85 -116
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -7
  5. package/docs/agent-notes/budget.md +7 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +14 -2
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +16 -3
  10. package/docs/agent-notes/tests.md +5 -3
  11. package/docs/dogfood.md +95 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +290 -27
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +13 -3
  16. package/docs/sdlc.md +59 -8
  17. package/docs/security.md +26 -13
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +797 -8
  20. package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
  21. package/docs/verification/2026-10-22-code-facts.md +78 -0
  22. package/docs/verification/2026-10-22-codex-reviewer-isolation.md +61 -0
  23. package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
  24. package/docs/verification/2026-10-23-0.13.0.md +89 -0
  25. package/docs/verification/2026-10-23-0.14.0.md +69 -0
  26. package/docs/verification/dogfood/2026-10-22.md +3109 -0
  27. package/docs/verified.json +22 -17
  28. package/package.json +1 -1
  29. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  30. package/plugins/agent-hub/server.js +702 -347
  31. package/src/adapters/agy/entry.ts +5 -4
  32. package/src/adapters/claude-channel.ts +20 -9
  33. package/src/adapters/codex-appserver.ts +18 -2
  34. package/src/adapters/codex-review-isolation.ts +29 -11
  35. package/src/adapters/compat.ts +140 -0
  36. package/src/adapters/definitions/builtin-metadata.ts +3 -2
  37. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  38. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -0
  39. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  40. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  41. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  43. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  44. package/src/adapters/definitions/qwen-native.ts +7 -3
  45. package/src/adapters/definitions/qwen.ts +2 -1
  46. package/src/adapters/jcode.ts +72 -31
  47. package/src/adapters/kimi-review.ts +6 -3
  48. package/src/adapters/registry-types.ts +3 -1
  49. package/src/adapters/registry.ts +1 -0
  50. package/src/adapters/reviewer-print.ts +34 -7
  51. package/src/cli/agents.ts +40 -0
  52. package/src/cli/console-review.ts +54 -0
  53. package/src/cli/console-state.ts +124 -29
  54. package/src/cli/console-symbols.ts +74 -0
  55. package/src/cli/console-usage.ts +89 -0
  56. package/src/cli/console.ts +26 -1
  57. package/src/cli/help.ts +17 -6
  58. package/src/cli/identity.ts +29 -6
  59. package/src/cli/init.ts +109 -11
  60. package/src/cli/main.ts +127 -11
  61. package/src/cli/output.ts +91 -3
  62. package/src/cli/preview.ts +1 -1
  63. package/src/cli/sdlc-release-guard.ts +82 -0
  64. package/src/cli/status-lines.ts +1 -1
  65. package/src/hub/action-class.ts +362 -0
  66. package/src/hub/action-policy.ts +106 -0
  67. package/src/hub/board.ts +65 -9
  68. package/src/hub/budget.ts +71 -2
  69. package/src/hub/bus.ts +43 -3
  70. package/src/hub/checks.ts +80 -31
  71. package/src/hub/code-facts.ts +223 -0
  72. package/src/hub/config-apply.ts +10 -1
  73. package/src/hub/config-trust.ts +7 -4
  74. package/src/hub/daemon.ts +319 -34
  75. package/src/hub/dogfood-read.ts +199 -0
  76. package/src/hub/dogfood.ts +163 -0
  77. package/src/hub/envelope.ts +1 -1
  78. package/src/hub/events.ts +11 -2
  79. package/src/hub/hub-tools.ts +26 -3
  80. package/src/hub/inbox-polling.ts +25 -0
  81. package/src/hub/instances.ts +87 -5
  82. package/src/hub/manager.ts +2 -2
  83. package/src/hub/profiles.ts +7 -2
  84. package/src/hub/release-worktree.ts +56 -0
  85. package/src/hub/review-brief.ts +36 -0
  86. package/src/hub/sdlc-metrics.ts +260 -0
  87. package/src/hub/sdlc-queue.ts +31 -9
  88. package/src/hub/sdlc-release-authorization.ts +173 -0
  89. package/src/hub/sdlc.ts +54 -14
  90. package/src/hub/settings.ts +5 -2
  91. package/src/hub/snapshots.ts +93 -8
  92. package/src/hub/spec-read.ts +31 -0
  93. package/src/hub/symbol-normalizer.ts +26 -0
  94. package/src/hub/task-code-group.ts +52 -0
  95. package/src/hub/task-code-symbols.ts +90 -0
  96. package/src/hub/task-sweep.ts +3 -2
  97. package/src/hub/tasks.ts +455 -67
  98. package/src/hub/test-integrity.ts +162 -0
  99. package/src/hub/ui.ts +3 -0
  100. package/src/hub/usage-rollup.ts +419 -0
  101. package/src/local/sandbox.ts +5 -3
  102. package/src/ui/index.html +274 -2
  103. package/templates/AGENTS.block.md +1 -1
  104. package/templates/profiles/dev.json +6 -1
  105. package/templates/profiles/general.json +6 -1
  106. package/templates/profiles/research.json +6 -1
  107. package/templates/profiles/writing.json +6 -1
  108. package/templates/release-guard.block.md +6 -0
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,8 @@
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";
1
6
  import { loadExternalAdapterDefinitions, readAdapterModuleDeclarations, type AdapterModuleDeclaration } from "../adapters/external-modules.ts";
2
7
  import { WsPeer, isWsPeer } from "../adapters/channel-peer.ts";
3
8
  import { ownerToolEffect, canonicalWriteUnknown } from "./no-writes.ts";
@@ -19,11 +24,13 @@ import { needs, type NeedsInput } from "../ui/needs.ts";
19
24
  import { loadPetConfig } from "../pet/config.ts";
20
25
  import { projectionConfig, TextProjections, type ProjectionConfig } from "./text-projection.ts";
21
26
  import { policyPaths, matchesPolicy } from "./path-policy.ts";
22
- 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";
23
29
  import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
24
30
  import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
25
31
  import { ActivityRing } from "./activity.ts";
26
- 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";
27
34
  import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
28
35
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
29
36
  import { claudePeer as defaultChannelPeer, claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
@@ -33,7 +40,7 @@ import { RecoveryDeferredHubError } from "./deferred-hub.ts";
33
40
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
34
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";
35
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";
36
- import { instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
43
+ import { checkJcodeInstanceHomes, instanceDefinitionOf, effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
37
44
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
38
45
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
39
46
  import { resolveInstanceExecutionCwd } from "./instance-execution.ts";
@@ -68,7 +75,7 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
68
75
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
69
76
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
70
77
  import { isLocalPeer, type LocalPeer } from "../adapters/local-worker.ts";
71
- import { isReviewerPrintPeer, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
78
+ import { isReviewerPrintPeer, reviewerUnavailableReason, ReviewerUnavailableError, type ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
72
79
  import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
73
80
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
74
81
  import { reviewDiff } from "../adapters/claude-print.ts";
@@ -76,6 +83,7 @@ import { Capture, skipTools } from "../memory/capture.ts";
76
83
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
77
84
  import { Briefs } from "../memory/brief.ts";
78
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";
79
87
  import { Budget, readNativeQuota, DEFAULT_BUDGET, type BudgetConfig } from "./budget.ts";
80
88
  import { HUB } from "./envelope.ts";
81
89
  import { recallMemoryLines, trimToTokens } from "../memory/recall.ts";
@@ -98,7 +106,8 @@ import { projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUn
98
106
  import { Bus } from "./bus.ts";
99
107
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
100
108
  import { DeliveryJournal } from "./delivery-journal.ts";
101
- import { startDashboard, type DashboardSession } from "./ui.ts";
109
+ import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
110
+ import { codeGroup } from "./task-code-group.ts";
102
111
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
103
112
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
104
113
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
@@ -145,6 +154,7 @@ export interface HubConfig {
145
154
  jcode_cmd: string[];
146
155
  jcode_home: string;
147
156
  jcode_provider_profile: string;
157
+ action_policy?: ActionPolicyConfig;
148
158
  permission_modes: Record<string, PermissionMode>;
149
159
  protect_instruction_files: boolean;
150
160
  /** Loader-owned provenance of requested defaults, before machine-local filtering. */
@@ -218,6 +228,7 @@ const nativeDefaults = nativeConfigurationDefaults({ home: homedir(), env: proce
218
228
  export const DEFAULT_CONFIG: HubConfig = {
219
229
  watchdog_ms: DEFAULT_WATCHDOG_MS,
220
230
  ...nativeDefaults.commands,
231
+ action_policy: actionPolicyConfig(),
221
232
  permission_modes: {},
222
233
  protect_instruction_files: false,
223
234
  ...nativeDefaults.proxy,
@@ -263,7 +274,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
263
274
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
264
275
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
265
276
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
266
- 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"];
277
+ 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"];
267
278
 
268
279
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
269
280
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
@@ -280,9 +291,10 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
280
291
  if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
281
292
  for (const [peer, assigned] of Object.entries(file.roles)) {
282
293
  conductorPeer({ [peer]: assigned });
283
- const ceiling = definitionOf(peer)?.routing?.allowedRoles;
294
+ const definition = definitionOf(peer), ceiling = definition?.routing?.allowedRoles;
284
295
  const declared = assigned as string[];
285
- roles[peer] = ceiling ? declared.filter(role => ceiling.includes(role)) : declared;
296
+ // #525 R1: planner needs a kind that can plan (projection.plannerEligible); such an entry is dropped with the ceiling notice, never thrown.
297
+ roles[peer] = (ceiling ? declared.filter(role => ceiling.includes(role)) : declared).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
286
298
  if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
287
299
  }
288
300
  }
@@ -358,6 +370,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
358
370
  if (file.jcode_home !== undefined && (typeof file.jcode_home !== "string" || !isAbsolute(file.jcode_home))) throw new Error("jcode_home must be an absolute path");
359
371
  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`);
360
372
  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");
373
+ actionPolicyConfig(file.action_policy);
361
374
  instructionProtection(file.protect_instruction_files);
362
375
  permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
363
376
  peerStartConfig(file.peers, instances, adapterCatalog);
@@ -421,6 +434,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
421
434
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
422
435
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
423
436
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
437
+ action_policy: actionPolicyConfig(file.action_policy),
424
438
  permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
425
439
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
426
440
  permission_default_sources: appliedPermissionSources,
@@ -486,6 +500,8 @@ export interface DaemonOptions {
486
500
  checkoutBranch?: typeof mainCheckoutBranch;
487
501
  /** Host-only admission transport; never selected by project config or tools. */
488
502
  releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
503
+ /** Host-only evidence seam; never supplied by project config or control messages. */
504
+ releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
489
505
  projectId?: string;
490
506
  instanceId?: string;
491
507
  cwd: string;
@@ -507,6 +523,10 @@ export interface DaemonOptions {
507
523
  orphanWatchMs?: number;
508
524
  /** Deterministic task-sweep time; the production default is Date.now. */
509
525
  taskSweepNow?: () => number;
526
+ /** Test seam: the period of the SDLC band evaluation, default five minutes. */
527
+ bandsEveryMs?: number;
528
+ /** Test seam: an extra hold on the SDLC band evaluation, like a storage error. */
529
+ bandsHeld?: () => boolean;
510
530
  /** How long a research record waits after an approval for the task's open turns to end (#247). Tests shrink this. */
511
531
  researchGraceMs?: number;
512
532
  /** Tests observe timer-driven sync without opening a remote; production uses the package sync. */
@@ -532,10 +552,29 @@ interface Client {
532
552
  type Sock = ServerWebSocket<Client>;
533
553
 
534
554
 
555
+ /**
556
+ * #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
557
+ * free text from an error (an id or a body would leak). An error that is none of them keeps the old generic sentence.
558
+ */
559
+ const RESOLUTION_CAUSES: [RegExp, string][] = [
560
+ [/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"],
561
+ [/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
562
+ [/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
563
+ [/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
564
+ [/already terminal|already resolved/i, "already resolved: the delivery is settled"],
565
+ [/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
566
+ [/queue is full/i, "recipient queue is full: resolve or discard some of its queued work first"],
567
+ [/journal unavailable/i, "delivery journal unavailable: the hub cannot record a resolution right now"],
568
+ ];
569
+ export function resolutionRefusal(error: unknown): string {
570
+ const text = error instanceof Error ? error.message : String(error);
571
+ return RESOLUTION_CAUSES.find(([pattern]) => pattern.test(text))?.[1] ?? "resolution refused: inspect ahub queue show for the delivery's state";
572
+ }
573
+
535
574
  export async function startDaemon(opts: DaemonOptions) {
536
575
  const projectId = opts.projectId ?? randomUUID();
537
576
  const instanceId = opts.instanceId ?? randomUUID();
538
- const startupCleanup: (() => void)[] = [];
577
+ const startupCleanup: (() => void | Promise<void>)[] = [];
539
578
  let ready = false;
540
579
  try {
541
580
  let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
@@ -555,6 +594,7 @@ export async function startDaemon(opts: DaemonOptions) {
555
594
  const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
556
595
  const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
557
596
  const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
597
+ checkJcodeInstanceHomes(declarations, loadedConfig.jcode_home); // #685: one native home per Jcode, at startup and never partially
558
598
  const extraIds = Object.keys(declarations);
559
599
  // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
560
600
  const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
@@ -914,6 +954,26 @@ export async function startDaemon(opts: DaemonOptions) {
914
954
  if (refused) log(`limits: ${env.from}: ${refused}`);
915
955
  return refused;
916
956
  };
957
+ /**
958
+ * #685: the provider, model and profile names an extra Jcode instance was declared with, for status, doctor and the dashboard,
959
+ * exactly as the person wrote them (they authenticate inside Jcode). Never a URL, a token or the native home path.
960
+ */
961
+ const jcodeModelSource = (id: string): { modelSource?: string } => {
962
+ const instance = declarations[id];
963
+ if (!instance || definitionOf(id)?.instances?.nativeHome !== true) return {};
964
+ return { modelSource: [instance.provider, instance.model, instance.provider_profile && `profile ${instance.provider_profile}`].filter(Boolean).join(" ") || "native default" };
965
+ };
966
+ /**
967
+ * #685 scope update 2: the read-only start preflight for an extra Jcode instance. The declared home must exist as a directory the
968
+ * person owns (the hub never creates it, and reads or writes nothing inside it). The reason names the instance, never the path.
969
+ */
970
+ const jcodeHomeRefusal = (id: string, home: string): string | undefined => {
971
+ let info: ReturnType<typeof statSync> | undefined;
972
+ try { info = statSync(home); } catch { /* reported below */ }
973
+ 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`;
974
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) return `${id}: its jcode_home is not owned by the person running the hub`;
975
+ return undefined;
976
+ };
917
977
  /** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
918
978
  const hubStartedAt = Date.now();
919
979
  // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
@@ -1119,6 +1179,8 @@ export async function startDaemon(opts: DaemonOptions) {
1119
1179
  let modelRelay: ModelRelay | undefined;
1120
1180
  const piReceiptLedgers = new Map<string, PiToolReceipts>();
1121
1181
  const modelRelays = new Map<string, ModelRelay>();
1182
+ /** #368: the owned Jcode launch of each Jcode peer, from creation (its MCP child connects while `start()` runs, before the bus has the peer). */
1183
+ const jcodeLaunches = new Map<string, JcodePeer>();
1122
1184
  const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
1123
1185
  const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
1124
1186
  const instanceAppPorts = new Map<string, number>();
@@ -1158,8 +1220,12 @@ export async function startDaemon(opts: DaemonOptions) {
1158
1220
  event({ type: "conduct", peer: action.actor, action: action.action, ...(action.task === undefined ? {} : { task: action.task }), ...(action.peer ? { target: action.peer } : {}) });
1159
1221
  try { notify(`conductor ${action.actor}: ${action.action}${action.task === undefined ? "" : ` #${action.task}`}${action.peer ? ` ${action.peer}` : ""}`); } catch { /* never throw after a task/hold write */ }
1160
1222
  };
1223
+ const inboxPolling = new InboxPolling();
1161
1224
  const board = new Board(join(opts.stateDir, "hub.db"));
1225
+ const codeFacts = new CodeFacts();
1226
+ const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1162
1227
  startupCleanup.push(() => board.close());
1228
+ startupCleanup.push(() => codeFacts.close());
1163
1229
  const supervision = new SupervisionFeed({
1164
1230
  conductor: () => conductorPeer(config.roles) ?? undefined,
1165
1231
  scope: () => config.conductor?.feed ?? "own",
@@ -1358,9 +1424,19 @@ export async function startDaemon(opts: DaemonOptions) {
1358
1424
  projectedReview: task => reviewProjection(task),
1359
1425
  ownerWrites: (task, since) => ownerWriteEvidence(task, since),
1360
1426
  ownerAccepted: task => captureOwnerWriteStart(task),
1427
+ prepareReviewBrief: async task => {
1428
+ if (tasks.isPii(task) || !task.refs.commit) return;
1429
+ const root = executionRoot(task.owner ?? "");
1430
+ const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1431
+ if ("unavailable" in brief) return;
1432
+ const key = JSON.stringify([root, brief.head, brief.mergeBase]);
1433
+ reviewSymbols.set(key, await codeFacts.changedSymbols(root, brief.mergeBase, brief.head));
1434
+ if (reviewSymbols.size > 64) reviewSymbols.delete(reviewSymbols.keys().next().value!);
1435
+ },
1361
1436
  reviewBrief: task => {
1362
1437
  const root = executionRoot(task.owner ?? "");
1363
- return buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1438
+ const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
1439
+ return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
1364
1440
  },
1365
1441
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1366
1442
  board,
@@ -1657,6 +1733,10 @@ export async function startDaemon(opts: DaemonOptions) {
1657
1733
  /** What quiescence was judged on, for the log when an integration waits on it. */
1658
1734
  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"}`;
1659
1735
  board.onChange = (t, h) => {
1736
+ if (!isClosedTaskState(t.state) && t.state !== "backlog") {
1737
+ if (t.owner) inboxPolling.reset(t.owner);
1738
+ if (t.reviewer) inboxPolling.reset(t.reviewer);
1739
+ }
1660
1740
  turnFreeNow = undefined;
1661
1741
  scopes = new Map();
1662
1742
  progress.prune();
@@ -1757,6 +1837,8 @@ export async function startDaemon(opts: DaemonOptions) {
1757
1837
  // Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
1758
1838
  canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
1759
1839
  // A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
1840
+ // #683 U1: pauses, resumes and derived resets go to the event log, ids and numbers only.
1841
+ ledger: entry => event(entry),
1760
1842
  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() } : {}) }),
1761
1843
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
1762
1844
  resumed: (record) => {
@@ -1783,8 +1865,17 @@ export async function startDaemon(opts: DaemonOptions) {
1783
1865
  notify: (line) => { notify(line); feedBudgetPauses(); },
1784
1866
  }, accountMembers, accountOf));
1785
1867
  budget.setRecoveryHold(recoveryActive());
1868
+ // ponytail: a third full read of events.jsonl at startup (after nativeStopsSeen and claudeNativeUsageSeen). Measured at about 70 ms on the real
1869
+ // 46 MB, 238k-line log, so it is not folded into one pass across startup. Ceiling: the log is never rotated, so the cost grows with it; when a
1870
+ // read passes a few hundred ms, make one `readEvents` pass feed all three sets (or keep the resets in hub.db, the #683 U2 roll-up).
1871
+ budget.seedResets(readEvents(join(opts.stateDir, "events.jsonl")).flatMap(e => e.type === "quota_reset" ? [{ peer: e.peer, window: e.window, ...(e.resetsAt !== undefined ? { resetsAt: e.resetsAt } : {}) }] : []));
1786
1872
  const kimiTokens: { at: number; n: number }[] = [];
1787
1873
  startupCleanup.push(() => budget.close());
1874
+ // #683 U2: events.jsonl folded into hub.db, a bounded pass at a time, so `usage` never rescans the log.
1875
+ const usageRollup = new UsageRollup(join(opts.stateDir, "hub.db"), join(opts.stateDir, "events.jsonl"));
1876
+ startupCleanup.push(() => usageRollup.close());
1877
+ const pollUsageRollup = () => { try { usageRollup.poll(); } catch (error) { log(`usage roll-up pass failed: ${(error as Error).message.slice(0, 120)}`); } };
1878
+ pollUsageRollup();
1788
1879
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
1789
1880
  const runId = Date.now().toString(36);
1790
1881
  let turnSeq = 0;
@@ -1944,6 +2035,7 @@ export async function startDaemon(opts: DaemonOptions) {
1944
2035
  }
1945
2036
  const delta = tokenDeltas();
1946
2037
  /** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
2038
+ let codexUsageSeq = 0; // per hub run: with the run id and peer it names one usage record
1947
2039
  const addTokens = (peer: PeerId, n: number): number => {
1948
2040
  const supervisionTurn = supervisionTurns.get(peer);
1949
2041
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
@@ -1965,6 +2057,8 @@ export async function startDaemon(opts: DaemonOptions) {
1965
2057
  };
1966
2058
  // Claude's numbers arrive through the status line tee `ahub claude` installs (src/cli/statusline-tee.ts).
1967
2059
  let claudeUsageSeen = 0;
2060
+ let bandsRevision = ""; // #481 S7: the board revision and band configuration the last band evaluation that ran saw
2061
+ let bandEvaluations = 0; // evaluations that ran (not skipped for an unchanged board); read by tests through the daemon
1968
2062
  const claudeContextSeen = new Map<string, string>();
1969
2063
  const contextTailSeen = new Map<PeerId, string>();
1970
2064
  const retainedCliAudits = new Set<string>();
@@ -2022,6 +2116,22 @@ export async function startDaemon(opts: DaemonOptions) {
2022
2116
  } catch { /* absent or unreadable native telemetry is unknown */ }
2023
2117
  }, 1000),
2024
2118
  setInterval(() => budget.tick(), 30_000),
2119
+ setInterval(pollUsageRollup, 10_000),
2120
+ // #481 S7: band tiers are evaluated every five minutes, and only when the board changed since the last evaluation (the status
2121
+ // revision moves on every task write), so an idle board costs one integer read. Held while stopping or recovering, like the task sweep.
2122
+ // ponytail: one evaluation reads every task with its history (a few ms per hundred tasks); bound it by a history window if a board passes several thousand tasks.
2123
+ setInterval(() => {
2124
+ if (stopping || recoveryActive()) return;
2125
+ try {
2126
+ const state = tasks.bandsState();
2127
+ if (!state.ready || opts.bandsHeld?.()) return; // held, mode off or no bands: nothing is consumed, so the breach is still there when it clears
2128
+ const revision = `${board.statusRevision()}:${state.signature}`;
2129
+ if (revision === bandsRevision) return;
2130
+ bandEvaluations++;
2131
+ tasks.proposeFromBands();
2132
+ bandsRevision = `${board.statusRevision()}:${state.signature}`; // after the write: the cards this pass made are not a reason to run again
2133
+ } catch (error) { log(`sdlc band evaluation failed: ${sanitize((error as Error).message).slice(0, 160)}`); }
2134
+ }, opts.bandsEveryMs ?? 300_000),
2025
2135
  setInterval(() => {
2026
2136
  if (!ready || !memoryBinding) return;
2027
2137
  const before = memoryProblem;
@@ -2147,7 +2257,7 @@ export async function startDaemon(opts: DaemonOptions) {
2147
2257
  }
2148
2258
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
2149
2259
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
2150
- if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
2260
+ if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
2151
2261
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
2152
2262
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2153
2263
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
@@ -2277,6 +2387,8 @@ export async function startDaemon(opts: DaemonOptions) {
2277
2387
  return JSON.stringify(await tasks.groomingDigest(by));
2278
2388
  case "hub_sdlc_costs":
2279
2389
  return JSON.stringify(await tasks.costReport(by, a.repo));
2390
+ case "hub_sdlc_metrics":
2391
+ return JSON.stringify(tasks.sdlcMetrics(by, a.since));
2280
2392
  case "hub_plan_review": {
2281
2393
  const { id, ...review } = a;
2282
2394
  return JSON.stringify(tasks.publicView(await tasks.recordPlanReview(by, id, review)));
@@ -2297,10 +2409,11 @@ export async function startDaemon(opts: DaemonOptions) {
2297
2409
  return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
2298
2410
  case "hub_task_propose": {
2299
2411
  const t = await tasks.propose(by, a);
2300
- if (t.state === "backlog") return line(t);
2412
+ const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
2413
+ if (t.state === "backlog") return [line(t), repoHint].filter(Boolean).join("\n");
2301
2414
  const overlap = tasks.overlaps(t, t.owner === by);
2302
2415
  if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
2303
- return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : ""].filter(Boolean).join("\n");
2416
+ return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint].filter(Boolean).join("\n");
2304
2417
  }
2305
2418
  case "hub_task_accept": {
2306
2419
  // S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
@@ -2412,7 +2525,10 @@ export async function startDaemon(opts: DaemonOptions) {
2412
2525
  }
2413
2526
  case "task_show": {
2414
2527
  const task = board.get(Number(a.id));
2415
- return JSON.stringify(task ? { ...task, flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) } : `no task #${a.id}`, null, 2);
2528
+ if (!task) return JSON.stringify(`no task #${a.id}`);
2529
+ const { codeSymbols: _storedCodeSymbols, ...detail } = task;
2530
+ const codeSymbols = tasks.publicView(task).codeSymbols;
2531
+ return JSON.stringify({ ...detail, ...(codeSymbols ? { codeSymbols } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
2416
2532
  }
2417
2533
  case "task_assign":
2418
2534
  case "task_assign_checked": {
@@ -2496,6 +2612,7 @@ export async function startDaemon(opts: DaemonOptions) {
2496
2612
  else delete defaultSources[peer];
2497
2613
  }
2498
2614
  };
2615
+ const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
2499
2616
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2500
2617
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2501
2618
  const unattendedPeers = new Set<string>();
@@ -2504,6 +2621,12 @@ export async function startDaemon(opts: DaemonOptions) {
2504
2621
  const claudePermissionGenerations = new Map<string, string>();
2505
2622
  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 }>();
2506
2623
  let stopping = false;
2624
+ const releaseAuthorizations = new ReleaseAuthorizations(opts.cwd, {
2625
+ docs: opts.releaseDocEvidence,
2626
+ gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
2627
+ current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
2628
+ 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 }),
2629
+ });
2507
2630
  const terminalReads = terminalOwnerReads();
2508
2631
  const terminalLaunches = new TerminalLaunches({
2509
2632
  owner: row => {
@@ -2788,10 +2911,11 @@ export async function startDaemon(opts: DaemonOptions) {
2788
2911
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2789
2912
  codexProxyPort: opts.codexProxyPort,
2790
2913
  peers: Object.fromEntries(
2791
- 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 ?? [] } : {}) }]; }),
2914
+ peerIds.map((id) => { const p = bus.peers.get(id); const summary = bus.queueSummary(id); const context = contexts.view(id, contextSession(id), !!p && ["idle", "busy", "paused"].includes(bus.stateOf(id))); return [id, { state: bus.stateOf(id), ...(!membershipAllowed(id) ? { rostered: false, rosterNote: "off roster" } : {}), ...permissionStatus(id, p), ...(isCodexPeer(p) && p.statusNotice ? { statusNotice: p.statusNotice } : {}), ...(context.source !== null || context.measuredAt !== null ? { context } : {}), queued: bus.queued(id), ...(p ? {} : { attached: false }), ...(summary.needsReview ? { needsReview: summary.needsReview } : {}), ...(summary.liveAccepted ? { liveAccepted: summary.liveAccepted, settlementNote: "awaiting adapter completion (Claude: correlated reply or hub_delivery_done); task state is independent" } : {}), ...queueHoldStatus(id, summary.heldBy), ...(summary.oldestQueuedAt !== undefined ? { oldestQueuedAt: summary.oldestQueuedAt } : {}), ...(bus.queuedImportant(id) ? { queuedImportant: bus.queuedImportant(id) } : {}), ...importantWaitingStatus(id, p), ...pausedNote(id), ...(reviewerUnavailable.has(id) ? { reviewerUnavailable: reviewerUnavailable.get(id) } : {}), ...jcodeModelSource(id), ...(isLocalPeer(p) && p.lastServedBy ? { servedBy: p.lastServedBy } : {}), ...(isWsPeer(p) && p.claiming ? { claiming: true } : {}), ...(isWsPeer(p) && p.pullOnly ? { pullOnly: true, toolsOnly: definitionOf(id)?.attach?.pullOnlyNotice ?? "tools-only: messages wait for hub_inbox; for pushes restart Claude with ahub claude", lastInboxAt: p.lastInboxAt ?? null } : {}), ...(isPiPeer(p) ? { requestedModel: p.getRequestedModel(), backends: modelRelays.get(id)?.status().backends ?? [] } : {}) }]; }),
2792
2915
  ),
2793
2916
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2794
2917
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2918
+ actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
2795
2919
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2796
2920
  planner: { peers: plannerPeers(), source: plannerSource },
2797
2921
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
@@ -3706,12 +3830,24 @@ export async function startDaemon(opts: DaemonOptions) {
3706
3830
  }
3707
3831
  if (adapter?.start.daemonPort === "owned-acp") {
3708
3832
  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" };
3709
- 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,
3710
- ...(args.model ? { launchModel: args.model } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
3833
+ // #691: name a version refusal before the owned daemon starts; the ACP identity check stays the binding one.
3834
+ const jcodeVerdict = await jcodeVersionAsync(config.jcode_cmd, executionCwd);
3835
+ if (!jcodeVerdict.ok) return { ok: false, error: jcodeVerdict.reason };
3836
+ if (jcodeVerdict.note) log(jcodeVerdict.note);
3837
+ // #685: an extra instance runs its own native home and model fields; the base peer keeps jcode_home and the global provider profile.
3838
+ const instance = declarations[peer];
3839
+ // #685 scope update 2: the person authenticates inside Jcode against that home. The hub passes -p, -m and --provider-profile as
3840
+ // written and reads or writes no credential; the only preflight is that the declared home is a directory the person owns.
3841
+ if (instance?.jcode_home) { const refusal = jcodeHomeRefusal(peer, instance.jcode_home); if (refusal) return { ok: false, error: refusal }; }
3842
+ 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,
3843
+ providerProfile: (args.providerProfile ?? instance?.provider_profile ?? config.jcode_provider_profile) || undefined,
3844
+ ...(instance?.provider ? { provider: instance.provider } : {}),
3845
+ ...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
3711
3846
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
3847
+ jcodeLaunches.set(peer, jcode);
3712
3848
  recoveryTaskPreface(peer); await ensurePreface(peer);
3713
3849
  try { await jcode.start(); await reconcileNativeStart(peer, jcode); }
3714
- catch { await jcode.stop().catch(() => {}); throw new Error("Jcode startup not verified; check native version, provider profile, MCP store and owned daemon (details withheld)"); }
3850
+ 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)"); }
3715
3851
  return { ok: true, policy: "unmanaged" };
3716
3852
  }
3717
3853
  if (adapter?.start.acpCommand) {
@@ -3796,7 +3932,7 @@ export async function startDaemon(opts: DaemonOptions) {
3796
3932
  if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
3797
3933
  if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
3798
3934
  if (error instanceof QwenIdentityError) throw error;
3799
- 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)");
3935
+ 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)`);
3800
3936
  }
3801
3937
  return { ok: true };
3802
3938
  }
@@ -3807,6 +3943,12 @@ export async function startDaemon(opts: DaemonOptions) {
3807
3943
  admitAttach: () => membershipAllowed(peer),
3808
3944
  permissionMode: permissionMode(peer),
3809
3945
  onTokens: (added) => void addTokens(peer, added),
3946
+ // #683 U1: the same update split into Codex's own kinds, next to the unchanged `tokens` event. One record per update:
3947
+ // 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).
3948
+ onTokenBreakdown: delta => {
3949
+ event({ type: "usage", peer, source: "codex_appserver", id: `codex:${runId}:${peer}:${++codexUsageSeq}`, inputTokens: delta.input, cacheReadTokens: delta.cachedInput,
3950
+ outputTokens: delta.output, reasoningTokens: delta.reasoning, totalTokens: delta.total, ...taskAttribution(peer) });
3951
+ },
3810
3952
  contextReader: adapter.budget?.context,
3811
3953
  onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
3812
3954
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
@@ -4146,7 +4288,12 @@ export async function startDaemon(opts: DaemonOptions) {
4146
4288
  onUnavailable: (failure, current) => {
4147
4289
  const attached = () => current() && bus.peers.get(peer) === reviewer;
4148
4290
  if (!attached()) return;
4149
- if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
4291
+ if (failure.cause === "start_failed") {
4292
+ // #722: the class (a closed list) is the only detail; never a path, native text or token.
4293
+ const reason = reviewerUnavailableReason(failure.causeClass ?? "unknown", failure.causeDetail);
4294
+ log(`reviewer ${peer} unavailable for task #${failure.task}: ${sanitize(reason).slice(0, 300)}`);
4295
+ reviewerUnavailable.set(peer, sanitize(reason).slice(0, 1000));
4296
+ }
4150
4297
  return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
4151
4298
  },
4152
4299
  log,
@@ -4154,8 +4301,10 @@ export async function startDaemon(opts: DaemonOptions) {
4154
4301
  try { await reviewer.start(); }
4155
4302
  catch (error) {
4156
4303
  await reviewer.stop().catch(() => {});
4157
- const reason = backend.nativePolicy && error instanceof Error ? sanitize(error.message).slice(0, 1000)
4158
- : "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
4304
+ // #691: a version refusal names only versions and the trust command, so it is shown for every backend.
4305
+ const reason = error instanceof Error && (backend.nativePolicy || error.message.startsWith(VERSION_REFUSAL)) ? sanitize(error.message).slice(0, 1000)
4306
+ : reviewerUnavailableReason(error instanceof ReviewerUnavailableError ? error.causeClass : "unknown", error instanceof ReviewerUnavailableError ? error.detail : undefined);
4307
+ log(`start ${peer} failed [cause: ${error instanceof ReviewerUnavailableError ? error.causeClass : "unknown"}]`);
4159
4308
  reviewerUnavailable.set(peer, reason);
4160
4309
  return { ok: false, error: reason };
4161
4310
  }
@@ -4315,7 +4464,7 @@ export async function startDaemon(opts: DaemonOptions) {
4315
4464
  const text = (v: unknown, max = 200) => typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined;
4316
4465
  const tier = q.tier === "short" || q.tier === "long" || q.tier === "goals" ? q.tier : "long";
4317
4466
  const kind = typeof q.kind === "string" && (["decision", "finding", "contract", "fail"] as string[]).includes(q.kind) ? q.kind as MemoryView["kind"] : undefined;
4318
- const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
4467
+ const peer = text(q.peer, 32), machine = text(q.machine, 32), search = text(q.q), node = text(q.node, typeof q.node === "string" && q.node.startsWith("symbol:") ? 512 : 200), since = [text(q.since, 40)].find((v) => v !== undefined && !Number.isNaN(Date.parse(v))); // an unreadable since filters nothing
4319
4468
  const hops = q.hops === 2 ? 2 : 1;
4320
4469
  const all = memoryStore.list({ project: memoryProject, limit: Number.MAX_SAFE_INTEGER });
4321
4470
  const goals = memoryStore.goals(memoryProject);
@@ -4353,6 +4502,43 @@ export async function startDaemon(opts: DaemonOptions) {
4353
4502
  const attentionStatus = status(false, true);
4354
4503
  let pet; try { const config = loadPetConfig(opts.cwd); pet = { enabled: config.enabled, character: config.character ?? "stai", blink: config.blink }; } catch { pet = { enabled: false }; }
4355
4504
  const peerView = Object.fromEntries(Object.entries(attentionStatus.peers).map(([id, peer]) => [id, { ...peer, ...turnView(id as PeerId) }])), boardTasks = board.list();
4505
+ const usageView = (open: boolean) => {
4506
+ // #683 U4: the roll-up runs only while the Agents view that shows it is open, never on a hidden view's 1 s poll.
4507
+ if (!open) return undefined;
4508
+ try {
4509
+ const caught = usageRollup.catchUp(100);
4510
+ const status = budget.status();
4511
+ const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
4512
+ const limits = usageRollup.limits(undefined, { limit: 100, live });
4513
+ const total = usageRollup.total();
4514
+ const daily = usageRollup.daily(14);
4515
+ const taskUsage = usageRollup.query({ by: "task" });
4516
+ return {
4517
+ // `behind`: the roll-up has not folded the whole log, so every figure below is a lower bound, never the whole (unknown is not 0).
4518
+ behind: !caught,
4519
+ daily,
4520
+ limits,
4521
+ total,
4522
+ tasks: taskUsage.rows.map(r => ({
4523
+ id: r.key === "unattributed" ? 0 : Number(r.key.replace(/^#/, "")),
4524
+ pii: r.pii === true,
4525
+ records: r.records,
4526
+ input: r.input,
4527
+ output: r.output,
4528
+ cacheRead: r.cacheRead,
4529
+ cacheWrite: r.cacheWrite,
4530
+ reasoning: r.reasoning,
4531
+ total: r.total,
4532
+ counted: r.counted,
4533
+ totalWithoutCache: r.totalWithoutCache,
4534
+ })),
4535
+ };
4536
+ } catch (e) {
4537
+ log(`dashboard usage view failed: ${(e as Error).message}`);
4538
+ // The page keeps the last figures for `undefined` (a hidden view), so a failed read says so explicitly instead.
4539
+ return { unavailable: true };
4540
+ }
4541
+ };
4356
4542
  return {
4357
4543
  ok: true,
4358
4544
  projectId,
@@ -4372,7 +4558,7 @@ export async function startDaemon(opts: DaemonOptions) {
4372
4558
  tasks: boardTasks.map((task) => {
4373
4559
  const view = tasks.publicView(task);
4374
4560
  // Refs and history can themselves quote private text. The dashboard needs neither.
4375
- 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 } : {}) };
4561
+ return { id: task.id, title: view.title, detail: view.detail, class: task.class, ...(task.profile ? { profile: task.profile, profileClass: task.profileClass } : {}), ...(task.role ? { role: task.role } : {}), state: task.state, owner: task.owner, reviewer: task.reviewer, deps: view.deps ?? [], ...(task.reserved ? { reserved: task.reserved } : {}), planningRevision: task.planningRevision ?? 0, rank: task.rank ?? null, priority: task.priority ?? null, release: task.release ?? null, plannedStart: task.plannedStart ?? null, due: task.due ?? null, pinned: task.pinned === true, actualDates: view.actualDates, ...(view.codeSymbols ? { codeSymbols: view.codeSymbols } : {}), ...(() => { const group = codeGroup(task, view.codeSymbols, task.plan?.symbols, tasks.publicMetadataText, tasks.isPii(task)); return group ? { codeGroup: group } : {}; })(), ...(view.cancelled ? { cancelled: view.cancelled } : {}), ...(view.external ? { external: view.external } : {}), started: taskHasStarted(task), urgent: task.signals.includes("urgent"), updated: task.updated, reworkRound: task.history.filter(entry => entry.event === "changes_requested").length, checking: tasks.isChecking(task.id), ...(view.lifecycle ? { lifecycle: view.lifecycle } : {}) };
4376
4562
  }),
4377
4563
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
4378
4564
  budget: Object.fromEntries(peers.map((id) => [id, { ...(quota[accountOf(id)] ?? { windows: [] }), context: contexts.view(id, contextSession(id), bus.peers.has(id) && ["idle", "busy", "paused"].includes(bus.stateOf(id))) }])),
@@ -4391,6 +4577,7 @@ export async function startDaemon(opts: DaemonOptions) {
4391
4577
  roster: rosterView(),
4392
4578
  ...benchView(),
4393
4579
  ...memoryView(_input?.memory),
4580
+ usage: usageView(_input?.view === "agents"),
4394
4581
  };
4395
4582
  }
4396
4583
 
@@ -4487,7 +4674,7 @@ export async function startDaemon(opts: DaemonOptions) {
4487
4674
  settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
4488
4675
  if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
4489
4676
  }
4490
- 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) };
4677
+ const input = { key, before, acceptedFallback, settledHolderChange, after: machine && rawChanged ? rawAfter : after, source: route ? "routing" as const : "config" as const, sourceFile: origin?.name, tracked, authority, guard, confirmed: !!scope || confirmed.includes(key) };
4491
4678
  const decision = authorizeConfigLeaf(input);
4492
4679
  if (!decision.allowed) {
4493
4680
  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" : ""}`;
@@ -4603,6 +4790,7 @@ export async function startDaemon(opts: DaemonOptions) {
4603
4790
  if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
4604
4791
  putConfig(config, change.key, change.after);
4605
4792
  }
4793
+ if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
4606
4794
  if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
4607
4795
  for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
4608
4796
  for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
@@ -4936,7 +5124,7 @@ export async function startDaemon(opts: DaemonOptions) {
4936
5124
  if (a.action === "sdlc_queue" || a.action === "sdlc_release") {
4937
5125
  const liveIssues = a.liveIssues ?? [];
4938
5126
  if (typeof a.repo !== "string" || !Array.isArray(liveIssues) || liveIssues.length > 32) return bad;
4939
- const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : await tasks.releaseReadiness(a.repo, liveIssues);
5127
+ const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : releaseAuthorizations.report(await tasks.releaseReadiness(a.repo, liveIssues));
4940
5128
  return { ok: true, report };
4941
5129
  }
4942
5130
  switch (a.action) {
@@ -5313,6 +5501,12 @@ export async function startDaemon(opts: DaemonOptions) {
5313
5501
  c.peer = String(msg.peer ?? "");
5314
5502
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5315
5503
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5504
+ // #368: a hub-tools socket for an owned Jcode (a project-file MCP entry, which names no peer) is admitted only with the launch id of that
5505
+ // peer's current owned launch. A forged AGENTHUB_PEER_ID in some other process has no such id, so it never acts as that peer.
5506
+ if (definitionOf(c.peer)?.start.daemonPort === "owned-acp") {
5507
+ const owner = jcodeLaunches.get(c.peer);
5508
+ 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");
5509
+ }
5316
5510
  } else {
5317
5511
  c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
5318
5512
  if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
@@ -5376,6 +5570,31 @@ export async function startDaemon(opts: DaemonOptions) {
5376
5570
  if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
5377
5571
  return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
5378
5572
  }
5573
+ if (msg.op === "discard") {
5574
+ // #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
5575
+ // 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
5576
+ // because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
5577
+ if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
5578
+ const state = msg.state === undefined ? "queued" : msg.state;
5579
+ if (state !== "queued" && state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
5580
+ const rows = bus.queueList(msg.peer).filter((row) => row.state === state);
5581
+ if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded: 0, refused: 0 });
5582
+ if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
5583
+ if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
5584
+ let discarded = 0; const refused: Record<string, number> = {};
5585
+ for (const row of rows) {
5586
+ // 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
5587
+ // 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.
5588
+ const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
5589
+ try {
5590
+ if (!current || current.state !== state) throw new Error("unknown delivery");
5591
+ bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim()); discarded++;
5592
+ }
5593
+ catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
5594
+ }
5595
+ writeStatus();
5596
+ return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
5597
+ }
5379
5598
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
5380
5599
  if (msg.op === "show") {
5381
5600
  const row = bus.queueShow(msg.id);
@@ -5387,7 +5606,7 @@ export async function startDaemon(opts: DaemonOptions) {
5387
5606
  bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
5388
5607
  writeStatus();
5389
5608
  return void reply({ ok: true, delivery: queueView(bus.queueShow(msg.id)) });
5390
- } catch { return void reply({ ok: false, error: "resolution refused: refresh the delivery revision and inspect active or already resolved work" }); }
5609
+ } catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
5391
5610
  }
5392
5611
  case "delivery_holds": {
5393
5612
  if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
@@ -5413,9 +5632,15 @@ export async function startDaemon(opts: DaemonOptions) {
5413
5632
  const envs = bus.pull(peer.id);
5414
5633
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
5415
5634
  peer.lastInboxAt = Date.now();
5635
+ let polling: ReturnType<InboxPolling["read"]>;
5636
+ try {
5637
+ const assigned = peer.pullOnly && board.statusTasks().some(task => !isClosedTaskState(task.state) && task.state !== "backlog" && (task.owner === peer.id || task.reviewer === peer.id));
5638
+ polling = peer.pullOnly ? inboxPolling.read(peer.id, envs.length === 0, assigned, peer.lastInboxAt) : undefined;
5639
+ if (polling?.notice) notify(`${peer.id} is polling an empty inbox with no task (${polling.count} empty reads in ${polling.minutes} min)`);
5640
+ } catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
5416
5641
  writeStatus();
5417
5642
  if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
5418
- return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id) });
5643
+ return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
5419
5644
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
5420
5645
  }
5421
5646
  case "delivery_complete": {
@@ -5749,11 +5974,31 @@ export async function startDaemon(opts: DaemonOptions) {
5749
5974
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
5750
5975
  }
5751
5976
  case "sdlc": {
5977
+ if (msg.op === "release-guard" || msg.op === "release-guard-hook") {
5978
+ 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" });
5979
+ if (msg.op === "release-guard") {
5980
+ const parsed = parseReleaseGuardArgv(msg.argv);
5981
+ if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
5982
+ void releaseAuthorizations.guardRequest(parsed.request).then(reply, () => reply({ ok: false, error: "release guard evidence unavailable" }));
5983
+ } else {
5984
+ const parsed = parseReleasePushUpdates(msg.remoteName, msg.remoteUrl, msg.stdin);
5985
+ if (!parsed.ok) return void reply({ ok: false, error: parsed.reason });
5986
+ reply(releaseAuthorizations.guardPush(parsed.remoteName, parsed.remoteUrl, parsed.updates));
5987
+ }
5988
+ return;
5989
+ }
5752
5990
  if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
5991
+ if (msg.op === "authorize-release") {
5992
+ void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
5993
+ authorization => reply({ ok: true, authorization }),
5994
+ error => reply({ ok: false, error: releaseAuthorizationError(error) }),
5995
+ );
5996
+ return;
5997
+ }
5753
5998
  if (msg.op === "queue" || msg.op === "release") {
5754
5999
  if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
5755
6000
  const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
5756
- void read.then(report => reply({ ok: true, report }), () => reply({ ok: false, error: "delivery readback unavailable" }));
6001
+ 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" }));
5757
6002
  return;
5758
6003
  }
5759
6004
  if (msg.op === "export") {
@@ -5788,7 +6033,11 @@ export async function startDaemon(opts: DaemonOptions) {
5788
6033
  if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
5789
6034
  void (async () => {
5790
6035
  const rows = [];
5791
- 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) });
6036
+ for (const task of selected.slice(0, 100)) {
6037
+ const status = await tasks.lifecycleStatus(task.id, msg.id !== undefined);
6038
+ const current = board.get(task.id) ?? task;
6039
+ rows.push({ id: task.id, title: tasks.publicTitle(current), ...(tasks.isPii(current) ? {} : { receipt: tasks.publicView(current).sdlc }), ...status });
6040
+ }
5792
6041
  reply({ ok: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
5793
6042
  })().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
5794
6043
  return;
@@ -5805,7 +6054,7 @@ export async function startDaemon(opts: DaemonOptions) {
5805
6054
  case "ui_snapshot":
5806
6055
  if (c.role !== "console") return void reply({ ok: false, error: "ui_snapshot is a console command" });
5807
6056
  if (!Number.isSafeInteger(msg.after ?? 0) || (msg.after ?? 0) < 0) return void reply({ ok: false, error: "invalid cursor" });
5808
- return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined ? { memory: msg.memory } : undefined));
6057
+ return void reply(uiSnapshot(msg.after ?? 0, msg.memory !== undefined || dashboardView(msg.view) ? { ...(msg.memory !== undefined ? { memory: msg.memory } : {}), ...(dashboardView(msg.view) ? { view: msg.view } : {}) } : undefined));
5809
6058
  case "ui_action":
5810
6059
  if (c.role !== "console") return void reply({ ok: false, error: "ui_action is a console command" });
5811
6060
  if (recoveryActive()) return void reply({ ok: false, error: "recovery is holding mutations" });
@@ -5964,6 +6213,41 @@ export async function startDaemon(opts: DaemonOptions) {
5964
6213
  budget.report(String(msg.set.peer), [{ id: msg.set.window === "week" ? "week" : "5h", used, ...(msg.set.resetsInMs ? { resetsAt: Date.now() + Number(msg.set.resetsInMs) } : {}), source: "ahub budget set" }]);
5965
6214
  }
5966
6215
  return void reply({ t: "budget", ok: true, budget: budget.status(), gate: config.budget.gate });
6216
+ case "usage": {
6217
+ // #683 U2: the console, the person and a configured planner read usage; every other agent is refused. Always answered (rid).
6218
+ const planner = c.role === "tools" && !!c.peer && !!config.roles[c.peer]?.includes("planner");
6219
+ if (c.role !== "console" && !planner) return void reply({ t: "usage", ok: false, error: "usage is read by the person and a configured planner" });
6220
+ if (planner && c.peer && turns.get(c.peer)?.private) return void reply({ t: "usage", ok: false, error: "usage is unavailable during a private turn" });
6221
+ const op = msg.op === undefined ? "query" : msg.op;
6222
+ const num = (v: unknown): number | undefined | null => v === undefined ? undefined : typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
6223
+ const since = num(msg.since), until = num(msg.until);
6224
+ if (since === null || until === null) return void reply({ t: "usage", ok: false, error: "since and until are epoch milliseconds" });
6225
+ 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" });
6226
+ try {
6227
+ // 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
6228
+ // 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).
6229
+ // 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
6230
+ // 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.
6231
+ const behind = !usageRollup.catchUp(250);
6232
+ const reply_ = (body: Record<string, unknown>) => reply({ ...body, ...(behind ? { behind: true } : {}) });
6233
+ if (op === "limits") {
6234
+ 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" });
6235
+ 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}` });
6236
+ const status = budget.status();
6237
+ // 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.
6238
+ const live = new Set(Object.entries(status).filter(([, v]) => v.paused).map(([peer]) => peer));
6239
+ 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)) });
6240
+ }
6241
+ if (op === "total") return void reply_({ t: "usage", ok: true, op, total: usageRollup.total() });
6242
+ if (op !== "query") return void reply({ t: "usage", ok: false, error: "op is query, limits or total" });
6243
+ const by = (msg.by ?? "peer") as UsageGroup;
6244
+ if (!USAGE_GROUPS.includes(by)) return void reply({ t: "usage", ok: false, error: `by is one of ${USAGE_GROUPS.join(", ")}` });
6245
+ // issue, pr and release are the board's current links of each task (ids only, never a title).
6246
+ const board_ = new Map(board.list().map(t => [t.id, t]));
6247
+ 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; };
6248
+ 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) });
6249
+ } catch (error) { return void reply({ t: "usage", ok: false, error: `usage unavailable: ${(error as Error).message.slice(0, 120)}` }); }
6250
+ }
5967
6251
  case "permit":
5968
6252
  if (c.role !== "console") return void reply({ t: "permit", ok: false, error: "permit is a console command" });
5969
6253
  return void reply({ t: "permit", ok: permissions.get(String(msg.id))?.done(msg.option ? String(msg.option) : undefined, msg.surface === "console" ? "console" : "terminal") === true });
@@ -5992,6 +6276,7 @@ export async function startDaemon(opts: DaemonOptions) {
5992
6276
  stopping = true;
5993
6277
  terminalLaunches.clear();
5994
6278
  // Start cancellation immediately; a failed read cleanup must not strand the other owned peers or finalization.
6279
+ const codeFactsStopped = codeFacts.close().catch(() => { log("code facts cleanup failed during shutdown"); });
5995
6280
  const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
5996
6281
  runnerRefreshAbort.abort();
5997
6282
  // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
@@ -6007,7 +6292,7 @@ export async function startDaemon(opts: DaemonOptions) {
6007
6292
  for (const pending of checkpointWaits.values()) pending.done(undefined);
6008
6293
  for (const p of permissions.values()) p.done(undefined);
6009
6294
  try {
6010
- await terminalReadsStopped;
6295
+ await Promise.all([terminalReadsStopped, codeFactsStopped]);
6011
6296
  await terminalLaunches.settled();
6012
6297
  // A peer can still be inside memory recall or its native handshake when kill arrives.
6013
6298
  // Drain those starts before taking the final owned-process snapshot.
@@ -6108,8 +6393,8 @@ export async function startDaemon(opts: DaemonOptions) {
6108
6393
  // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
6109
6394
  const piRecovered = !archivedPiContinuity && !!crashed?.peers.some((p) => definitionOf(p.peer)?.session.restoreOnStart);
6110
6395
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
6111
- return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6396
+ return { bus, token, port: server.port as number, bandEvaluations: () => bandEvaluations, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6112
6397
  } finally {
6113
- if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
6398
+ if (!ready) for (const cleanup of startupCleanup.reverse()) { try { await cleanup(); } catch { /* preserve startup error */ } }
6114
6399
  }
6115
6400
  }