@staix/agent-hub 0.12.45 → 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 (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +83 -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 +6 -1
  6. package/docs/agent-notes/bus.md +6 -4
  7. package/docs/agent-notes/daemon.md +6 -1
  8. package/docs/agent-notes/local-worker.md +2 -1
  9. package/docs/agent-notes/tasks.md +10 -0
  10. package/docs/agent-notes/tests.md +4 -2
  11. package/docs/dogfood.md +92 -0
  12. package/docs/events.md +14 -1
  13. package/docs/operations.md +250 -19
  14. package/docs/profiles.md +2 -2
  15. package/docs/quickstart.md +8 -2
  16. package/docs/sdlc.md +36 -6
  17. package/docs/security.md +20 -11
  18. package/docs/smoke.md +9 -4
  19. package/docs/specs/2026-09-19-agent-hub-design.md +759 -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 +47 -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/dogfood/2026-10-22.md +3109 -0
  26. package/docs/verified.json +14 -14
  27. package/package.json +1 -1
  28. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  29. package/plugins/agent-hub/server.js +613 -329
  30. package/src/adapters/agy/entry.ts +5 -4
  31. package/src/adapters/claude-channel.ts +20 -9
  32. package/src/adapters/codex-appserver.ts +18 -2
  33. package/src/adapters/codex-review-isolation.ts +12 -5
  34. package/src/adapters/compat.ts +140 -0
  35. package/src/adapters/definitions/builtin-metadata.ts +2 -1
  36. package/src/adapters/definitions/native-cli-doctor.ts +12 -5
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
  38. package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
  39. package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
  41. package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
  42. package/src/adapters/definitions/qwen-native.ts +7 -3
  43. package/src/adapters/definitions/qwen.ts +2 -1
  44. package/src/adapters/jcode.ts +72 -31
  45. package/src/adapters/kimi-review.ts +6 -3
  46. package/src/adapters/registry-types.ts +3 -1
  47. package/src/adapters/registry.ts +1 -0
  48. package/src/cli/agents.ts +40 -0
  49. package/src/cli/console-review.ts +54 -0
  50. package/src/cli/console-state.ts +124 -29
  51. package/src/cli/console-symbols.ts +74 -0
  52. package/src/cli/console-usage.ts +89 -0
  53. package/src/cli/console.ts +26 -1
  54. package/src/cli/help.ts +16 -6
  55. package/src/cli/identity.ts +29 -6
  56. package/src/cli/init.ts +109 -11
  57. package/src/cli/main.ts +122 -11
  58. package/src/cli/output.ts +44 -1
  59. package/src/cli/preview.ts +1 -1
  60. package/src/cli/sdlc-release-guard.ts +82 -0
  61. package/src/hub/action-class.ts +362 -0
  62. package/src/hub/action-policy.ts +106 -0
  63. package/src/hub/board.ts +43 -8
  64. package/src/hub/budget.ts +71 -2
  65. package/src/hub/bus.ts +43 -3
  66. package/src/hub/checks.ts +80 -31
  67. package/src/hub/code-facts.ts +223 -0
  68. package/src/hub/config-apply.ts +10 -1
  69. package/src/hub/config-trust.ts +7 -4
  70. package/src/hub/daemon.ts +237 -22
  71. package/src/hub/dogfood-read.ts +199 -0
  72. package/src/hub/dogfood.ts +163 -0
  73. package/src/hub/envelope.ts +1 -1
  74. package/src/hub/events.ts +11 -2
  75. package/src/hub/hub-tools.ts +24 -2
  76. package/src/hub/inbox-polling.ts +25 -0
  77. package/src/hub/instances.ts +73 -1
  78. package/src/hub/profiles.ts +7 -2
  79. package/src/hub/release-worktree.ts +56 -0
  80. package/src/hub/review-brief.ts +36 -0
  81. package/src/hub/sdlc-queue.ts +31 -9
  82. package/src/hub/sdlc-release-authorization.ts +173 -0
  83. package/src/hub/sdlc.ts +49 -11
  84. package/src/hub/settings.ts +5 -2
  85. package/src/hub/snapshots.ts +93 -8
  86. package/src/hub/symbol-normalizer.ts +26 -0
  87. package/src/hub/task-code-symbols.ts +90 -0
  88. package/src/hub/task-sweep.ts +3 -2
  89. package/src/hub/tasks.ts +292 -54
  90. package/src/hub/test-integrity.ts +162 -0
  91. package/src/hub/usage-rollup.ts +361 -0
  92. package/src/local/sandbox.ts +5 -3
  93. package/src/ui/index.html +1 -0
  94. package/templates/AGENTS.block.md +1 -1
  95. package/templates/profiles/dev.json +6 -1
  96. package/templates/profiles/general.json +6 -1
  97. package/templates/profiles/research.json +6 -1
  98. package/templates/profiles/writing.json +6 -1
  99. 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";
@@ -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";
@@ -145,6 +153,7 @@ export interface HubConfig {
145
153
  jcode_cmd: string[];
146
154
  jcode_home: string;
147
155
  jcode_provider_profile: string;
156
+ action_policy?: ActionPolicyConfig;
148
157
  permission_modes: Record<string, PermissionMode>;
149
158
  protect_instruction_files: boolean;
150
159
  /** Loader-owned provenance of requested defaults, before machine-local filtering. */
@@ -218,6 +227,7 @@ const nativeDefaults = nativeConfigurationDefaults({ home: homedir(), env: proce
218
227
  export const DEFAULT_CONFIG: HubConfig = {
219
228
  watchdog_ms: DEFAULT_WATCHDOG_MS,
220
229
  ...nativeDefaults.commands,
230
+ action_policy: actionPolicyConfig(),
221
231
  permission_modes: {},
222
232
  protect_instruction_files: false,
223
233
  ...nativeDefaults.proxy,
@@ -263,7 +273,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
263
273
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
264
274
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
265
275
  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"];
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"];
267
277
 
268
278
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
269
279
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
@@ -358,6 +368,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
358
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");
359
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`);
360
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);
361
372
  instructionProtection(file.protect_instruction_files);
362
373
  permissionDefaults(file.permission_modes, instances, adapterCatalog); // malformed declarations fail even when trust would drop them
363
374
  peerStartConfig(file.peers, instances, adapterCatalog);
@@ -421,6 +432,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
421
432
  ...(file.text_projection !== undefined ? { text_projection: projectionConfig(file.text_projection) } : {}),
422
433
  ...(file.pii !== undefined ? { pii: { paths: policyPaths(file.pii.paths, "pii.paths") } } : {}),
423
434
  ...(file.data !== undefined ? { data: { readonly: policyPaths(file.data.readonly, "data.readonly") } } : {}),
435
+ action_policy: actionPolicyConfig(file.action_policy),
424
436
  permission_modes: { ...instancePermissions, ...permissionDefaults(file.permission_modes, instances, adapterCatalog) },
425
437
  protect_instruction_files: instructionProtection(file.protect_instruction_files),
426
438
  permission_default_sources: appliedPermissionSources,
@@ -486,6 +498,8 @@ export interface DaemonOptions {
486
498
  checkoutBranch?: typeof mainCheckoutBranch;
487
499
  /** Host-only admission transport; never selected by project config or tools. */
488
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>;
489
503
  projectId?: string;
490
504
  instanceId?: string;
491
505
  cwd: string;
@@ -532,10 +546,29 @@ interface Client {
532
546
  type Sock = ServerWebSocket<Client>;
533
547
 
534
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
+
535
568
  export async function startDaemon(opts: DaemonOptions) {
536
569
  const projectId = opts.projectId ?? randomUUID();
537
570
  const instanceId = opts.instanceId ?? randomUUID();
538
- const startupCleanup: (() => void)[] = [];
571
+ const startupCleanup: (() => void | Promise<void>)[] = [];
539
572
  let ready = false;
540
573
  try {
541
574
  let startupLayers: ReturnType<typeof readInstanceConfigLayers>;
@@ -555,6 +588,7 @@ export async function startDaemon(opts: DaemonOptions) {
555
588
  const adapterCatalog = createProjectAdapterCatalog(loadedConfig.adapters, externalDefinitions);
556
589
  const configuredIds = adapterCatalog.definitions.filter(d => !builtinDefinitionOf(d.kind)).map(d => d.kind);
557
590
  const declarations = validateInstances(loadedConfig.instances, adapterCatalog);
591
+ checkJcodeInstanceHomes(declarations, loadedConfig.jcode_home); // #685: one native home per Jcode, at startup and never partially
558
592
  const extraIds = Object.keys(declarations);
559
593
  // #450 I3: channel extras (Claude) attach from the person's terminal; the hub never starts them and their hello is admitted.
560
594
  const channelExtras = extraIds.filter(id => adapterCatalog.definitionOf(id, declarations)?.attach?.externalHello === true);
@@ -914,6 +948,26 @@ export async function startDaemon(opts: DaemonOptions) {
914
948
  if (refused) log(`limits: ${env.from}: ${refused}`);
915
949
  return refused;
916
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
+ };
917
971
  /** This hub run's start: offline owners count from it (issue #6), split predictions read tasks since (issue #109). */
918
972
  const hubStartedAt = Date.now();
919
973
  // The bus exists before `Tasks`, which knows whether a queued notice still matters (issue #106).
@@ -1119,6 +1173,8 @@ export async function startDaemon(opts: DaemonOptions) {
1119
1173
  let modelRelay: ModelRelay | undefined;
1120
1174
  const piReceiptLedgers = new Map<string, PiToolReceipts>();
1121
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>();
1122
1178
  const globalModelRelay = (relay: ModelRelay) => { modelRelay = relay; };
1123
1179
  const nativeStateDir = (peer: string) => declarations[peer] ? join(opts.stateDir, "instances", peer) : opts.stateDir;
1124
1180
  const instanceAppPorts = new Map<string, number>();
@@ -1158,8 +1214,12 @@ export async function startDaemon(opts: DaemonOptions) {
1158
1214
  event({ type: "conduct", peer: action.actor, action: action.action, ...(action.task === undefined ? {} : { task: action.task }), ...(action.peer ? { target: action.peer } : {}) });
1159
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 */ }
1160
1216
  };
1217
+ const inboxPolling = new InboxPolling();
1161
1218
  const board = new Board(join(opts.stateDir, "hub.db"));
1219
+ const codeFacts = new CodeFacts();
1220
+ const reviewSymbols = new Map<string, CodeFactResult<CodeSymbol[]>>();
1162
1221
  startupCleanup.push(() => board.close());
1222
+ startupCleanup.push(() => codeFacts.close());
1163
1223
  const supervision = new SupervisionFeed({
1164
1224
  conductor: () => conductorPeer(config.roles) ?? undefined,
1165
1225
  scope: () => config.conductor?.feed ?? "own",
@@ -1358,9 +1418,19 @@ export async function startDaemon(opts: DaemonOptions) {
1358
1418
  projectedReview: task => reviewProjection(task),
1359
1419
  ownerWrites: (task, since) => ownerWriteEvidence(task, since),
1360
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
+ },
1361
1430
  reviewBrief: task => {
1362
1431
  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 });
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);
1364
1434
  },
1365
1435
  sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
1366
1436
  board,
@@ -1657,6 +1727,10 @@ export async function startDaemon(opts: DaemonOptions) {
1657
1727
  /** What quiescence was judged on, for the log when an integration waits on it. */
1658
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"}`;
1659
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
+ }
1660
1734
  turnFreeNow = undefined;
1661
1735
  scopes = new Map();
1662
1736
  progress.prune();
@@ -1757,6 +1831,8 @@ export async function startDaemon(opts: DaemonOptions) {
1757
1831
  // Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
1758
1832
  canHandOff: (peer) => [...bus.peers.keys()].some((id) => accountOf(id) !== accountOf(peer) && ["idle", "busy"].includes(bus.stateOf(id))),
1759
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),
1760
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() } : {}) }),
1761
1837
  handoff: (peer, context, urgentOnly) => tasks.reassignForPause(peer, context, urgentOnly),
1762
1838
  resumed: (record) => {
@@ -1783,8 +1859,17 @@ export async function startDaemon(opts: DaemonOptions) {
1783
1859
  notify: (line) => { notify(line); feedBudgetPauses(); },
1784
1860
  }, accountMembers, accountOf));
1785
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 } : {}) }] : []));
1786
1866
  const kimiTokens: { at: number; n: number }[] = [];
1787
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();
1788
1873
  // Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
1789
1874
  const runId = Date.now().toString(36);
1790
1875
  let turnSeq = 0;
@@ -1944,6 +2029,7 @@ export async function startDaemon(opts: DaemonOptions) {
1944
2029
  }
1945
2030
  const delta = tokenDeltas();
1946
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
1947
2033
  const addTokens = (peer: PeerId, n: number): number => {
1948
2034
  const supervisionTurn = supervisionTurns.get(peer);
1949
2035
  if (supervisionTurn && Number.isFinite(n) && n >= 0) supervisionTurn.tokens = (supervisionTurn.tokens ?? 0) + n;
@@ -2022,6 +2108,7 @@ export async function startDaemon(opts: DaemonOptions) {
2022
2108
  } catch { /* absent or unreadable native telemetry is unknown */ }
2023
2109
  }, 1000),
2024
2110
  setInterval(() => budget.tick(), 30_000),
2111
+ setInterval(pollUsageRollup, 10_000),
2025
2112
  setInterval(() => {
2026
2113
  if (!ready || !memoryBinding) return;
2027
2114
  const before = memoryProblem;
@@ -2412,7 +2499,10 @@ export async function startDaemon(opts: DaemonOptions) {
2412
2499
  }
2413
2500
  case "task_show": {
2414
2501
  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);
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);
2416
2506
  }
2417
2507
  case "task_assign":
2418
2508
  case "task_assign_checked": {
@@ -2496,6 +2586,7 @@ export async function startDaemon(opts: DaemonOptions) {
2496
2586
  else delete defaultSources[peer];
2497
2587
  }
2498
2588
  };
2589
+ const actionBudgets = new ActionBudgets(() => config.action_policy, contextSession);
2499
2590
  const permissionMode = (peer: string): PermissionMode => permissionModes.get(peer) ?? "ask";
2500
2591
  const permissionModeSources = new Map(Object.entries(defaultSources).map(([peer, value]) => [peer, value.source]));
2501
2592
  const unattendedPeers = new Set<string>();
@@ -2504,6 +2595,12 @@ export async function startDaemon(opts: DaemonOptions) {
2504
2595
  const claudePermissionGenerations = new Map<string, string>();
2505
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 }>();
2506
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
+ });
2507
2604
  const terminalReads = terminalOwnerReads();
2508
2605
  const terminalLaunches = new TerminalLaunches({
2509
2606
  owner: row => {
@@ -2788,10 +2885,11 @@ export async function startDaemon(opts: DaemonOptions) {
2788
2885
  ...(dashboard ? { uiOrigin: dashboard.origin } : {}),
2789
2886
  codexProxyPort: opts.codexProxyPort,
2790
2887
  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 ?? [] } : {}) }]; }),
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 ?? [] } : {}) }]; }),
2792
2889
  ),
2793
2890
  taskWip: Object.fromEntries(peerIds.map(id => [id, { wip: tasks.wip(id), wipLimit: tasks.wipLimit(id) }])),
2794
2891
  boardReadCapabilities: { releaseFilter: true, columnMetadata: true },
2892
+ actionPolicyInfo: Object.fromEntries(peerIds.map(id => [id, actionBudgets.status(id, false)])),
2795
2893
  roleInfo: Object.fromEntries(peerIds.map(id => [id, { flowRoles: config.roles[id] ?? [], catalogRoles: roleState(id), ...(config.roles[id]?.includes("planner") ? { planner: plannerSource } : {}) }])),
2796
2894
  planner: { peers: plannerPeers(), source: plannerSource },
2797
2895
  ...(pendingPermissionDefaults.size ? { permissionDefaults: [...pendingPermissionDefaults].map(([peer, source]) => ({ peer, mode: "never-ask", source })) } : {}),
@@ -3706,12 +3804,24 @@ export async function startDaemon(opts: DaemonOptions) {
3706
3804
  }
3707
3805
  if (adapter?.start.daemonPort === "owned-acp") {
3708
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" };
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,
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,
3711
3820
  preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld") }) }) as JcodePeer;
3821
+ jcodeLaunches.set(peer, jcode);
3712
3822
  recoveryTaskPreface(peer); await ensurePreface(peer);
3713
3823
  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)"); }
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)"); }
3715
3825
  return { ok: true, policy: "unmanaged" };
3716
3826
  }
3717
3827
  if (adapter?.start.acpCommand) {
@@ -3796,7 +3906,7 @@ export async function startDaemon(opts: DaemonOptions) {
3796
3906
  if (configuredIds.includes(adapter.kind) && owner.pendingOwnedProcess) bus.add(owner); // Retain the failed owner for explicit stop retry; never replace its live child.
3797
3907
  if (configuredIds.includes(adapter.kind)) throw new Error(`${peer}: ACP startup identity, session, permission or owned-group verification failed (details withheld)`);
3798
3908
  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)");
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)`);
3800
3910
  }
3801
3911
  return { ok: true };
3802
3912
  }
@@ -3807,6 +3917,12 @@ export async function startDaemon(opts: DaemonOptions) {
3807
3917
  admitAttach: () => membershipAllowed(peer),
3808
3918
  permissionMode: permissionMode(peer),
3809
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
+ },
3810
3926
  contextReader: adapter.budget?.context,
3811
3927
  onContext: adapter.budget?.context ? (reading) => contexts.report(peer, reading, contextSession(peer)) : undefined,
3812
3928
  // #215: before the thread's first turn_start, so the planner can tell which turns belong to which thread.
@@ -4154,7 +4270,8 @@ export async function startDaemon(opts: DaemonOptions) {
4154
4270
  try { await reviewer.start(); }
4155
4271
  catch (error) {
4156
4272
  await reviewer.stop().catch(() => {});
4157
- 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)
4158
4275
  : "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
4159
4276
  reviewerUnavailable.set(peer, reason);
4160
4277
  return { ok: false, error: reason };
@@ -4372,7 +4489,7 @@ export async function startDaemon(opts: DaemonOptions) {
4372
4489
  tasks: boardTasks.map((task) => {
4373
4490
  const view = tasks.publicView(task);
4374
4491
  // 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 } : {}) };
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 } : {}) };
4376
4493
  }),
4377
4494
  links: links.filter(link => link.tasks.every(id => { const task = board.get(id); return task && !tasks.isPii(task); })),
4378
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))) }])),
@@ -4487,7 +4604,7 @@ export async function startDaemon(opts: DaemonOptions) {
4487
4604
  settledHolderChange = JSON.stringify(oldHolders) !== JSON.stringify(newHolders);
4488
4605
  if (settledHolderChange) effect = `planner ${JSON.stringify(oldHolders.planners)} -> ${JSON.stringify(newHolders.planners)}; conductor ${JSON.stringify(oldHolders.conductors)} -> ${JSON.stringify(newHolders.conductors)}`;
4489
4606
  }
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) };
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) };
4491
4608
  const decision = authorizeConfigLeaf(input);
4492
4609
  if (!decision.allowed) {
4493
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" : ""}`;
@@ -4603,6 +4720,7 @@ export async function startDaemon(opts: DaemonOptions) {
4603
4720
  if (change.applies === "restart" || change.key === "roles" || change.key.startsWith("roles.")) continue;
4604
4721
  putConfig(config, change.key, change.after);
4605
4722
  }
4723
+ if (changes.some(change => change.key === "action_policy" || change.key.startsWith("action_policy."))) actionBudgets.policyChanged();
4606
4724
  if (rolesChanged) { policyRoles = structuredClone(prepared.running.roles); settlePlanner(); }
4607
4725
  for (const change of changes.filter(change => change.key === "permission_modes" || change.key.startsWith("permission_modes."))) {
4608
4726
  for (const peer of change.key === "permission_modes" ? SUPPORTED_PEERS : [change.key.split(".")[1]!]) changedPermissionDefaults.add(peer);
@@ -4936,7 +5054,7 @@ export async function startDaemon(opts: DaemonOptions) {
4936
5054
  if (a.action === "sdlc_queue" || a.action === "sdlc_release") {
4937
5055
  const liveIssues = a.liveIssues ?? [];
4938
5056
  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);
5057
+ const report = a.action === "sdlc_queue" ? await tasks.deliveryQueue(a.repo) : releaseAuthorizations.report(await tasks.releaseReadiness(a.repo, liveIssues));
4940
5058
  return { ok: true, report };
4941
5059
  }
4942
5060
  switch (a.action) {
@@ -5313,6 +5431,12 @@ export async function startDaemon(opts: DaemonOptions) {
5313
5431
  c.peer = String(msg.peer ?? "");
5314
5432
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
5315
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
+ }
5316
5440
  } else {
5317
5441
  c.peer = String(msg.peer ?? defaultChannelPeer(undefined));
5318
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");
@@ -5376,6 +5500,31 @@ export async function startDaemon(opts: DaemonOptions) {
5376
5500
  if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
5377
5501
  return void reply({ ok: true, deliveries: bus.queueList(msg.peer).map((row) => queueView(row)) });
5378
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
+ }
5379
5528
  if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
5380
5529
  if (msg.op === "show") {
5381
5530
  const row = bus.queueShow(msg.id);
@@ -5387,7 +5536,7 @@ export async function startDaemon(opts: DaemonOptions) {
5387
5536
  bus.resolveDelivery(msg.id, msg.revision, msg.action, msg.reason.trim());
5388
5537
  writeStatus();
5389
5538
  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" }); }
5539
+ } catch (error) { return void reply({ ok: false, error: resolutionRefusal(error) }); }
5391
5540
  }
5392
5541
  case "delivery_holds": {
5393
5542
  if (!c.peer || (c.role !== "peer" && c.role !== "tools")) return void reply({ ok: false, error: "held metadata requires a peer identity" });
@@ -5413,9 +5562,15 @@ export async function startDaemon(opts: DaemonOptions) {
5413
5562
  const envs = bus.pull(peer.id);
5414
5563
  if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
5415
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.
5416
5571
  writeStatus();
5417
5572
  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) });
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 } } : {}) });
5419
5574
  } catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
5420
5575
  }
5421
5576
  case "delivery_complete": {
@@ -5749,11 +5904,31 @@ export async function startDaemon(opts: DaemonOptions) {
5749
5904
  return void reply({ t: "ui", ok: false, error: "could not start the dashboard" });
5750
5905
  }
5751
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
+ }
5752
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
+ }
5753
5928
  if (msg.op === "queue" || msg.op === "release") {
5754
5929
  if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
5755
5930
  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" }));
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" }));
5757
5932
  return;
5758
5933
  }
5759
5934
  if (msg.op === "export") {
@@ -5788,7 +5963,11 @@ export async function startDaemon(opts: DaemonOptions) {
5788
5963
  if (msg.id !== undefined && (!Number.isSafeInteger(msg.id) || msg.id <= 0 || !selected.length)) return void reply({ ok: false, error: "invalid lifecycle task id" });
5789
5964
  void (async () => {
5790
5965
  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) });
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
+ }
5792
5971
  reply({ ok: true, mode: lifecycleConfig.mode, rows, truncated: selected.length > 100 });
5793
5972
  })().catch(() => reply({ ok: false, error: "lifecycle readback unavailable" }));
5794
5973
  return;
@@ -5964,6 +6143,41 @@ export async function startDaemon(opts: DaemonOptions) {
5964
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" }]);
5965
6144
  }
5966
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
+ }
5967
6181
  case "permit":
5968
6182
  if (c.role !== "console") return void reply({ t: "permit", ok: false, error: "permit is a console command" });
5969
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 });
@@ -5992,6 +6206,7 @@ export async function startDaemon(opts: DaemonOptions) {
5992
6206
  stopping = true;
5993
6207
  terminalLaunches.clear();
5994
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"); });
5995
6210
  const terminalReadsStopped = terminalReads.stop().catch(() => { log("terminal owner query cleanup failed during shutdown"); });
5996
6211
  runnerRefreshAbort.abort();
5997
6212
  // A stop someone asked for is not a crash, even if it then runs past the shutdown deadline: forget the sessions now.
@@ -6007,7 +6222,7 @@ export async function startDaemon(opts: DaemonOptions) {
6007
6222
  for (const pending of checkpointWaits.values()) pending.done(undefined);
6008
6223
  for (const p of permissions.values()) p.done(undefined);
6009
6224
  try {
6010
- await terminalReadsStopped;
6225
+ await Promise.all([terminalReadsStopped, codeFactsStopped]);
6011
6226
  await terminalLaunches.settled();
6012
6227
  // A peer can still be inside memory recall or its native handshake when kill arrives.
6013
6228
  // Drain those starts before taking the final owned-process snapshot.
@@ -6110,6 +6325,6 @@ export async function startDaemon(opts: DaemonOptions) {
6110
6325
  if (piAutoStart() && !recoveryActive() && !piRecovered) autoStartPi();
6111
6326
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
6112
6327
  } finally {
6113
- 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 */ } }
6114
6329
  }
6115
6330
  }