@staix/agent-hub 0.12.36 → 0.12.38

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -11
  3. package/docs/agent-notes/adapters.md +19 -6
  4. package/docs/agent-notes/budget.md +2 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +9 -3
  7. package/docs/agent-notes/tasks.md +8 -4
  8. package/docs/agent-notes/tests.md +2 -2
  9. package/docs/memory.md +3 -1
  10. package/docs/operations.md +57 -10
  11. package/docs/quickstart.md +15 -7
  12. package/docs/sdlc.md +29 -3
  13. package/docs/security.md +10 -2
  14. package/docs/smoke.md +38 -2
  15. package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
  16. package/docs/verification/2026-10-19-0.12.37.md +60 -0
  17. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  18. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  19. package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
  20. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  21. package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
  22. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  23. package/docs/verified.json +27 -15
  24. package/package.json +2 -2
  25. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  26. package/plugins/agent-hub/server.js +5627 -3670
  27. package/src/adapters/acp.ts +3 -2
  28. package/src/adapters/agy/definition.ts +61 -0
  29. package/src/adapters/agy/entry.ts +76 -0
  30. package/src/adapters/agy/hooks.ts +81 -0
  31. package/src/adapters/claude-channel.ts +1 -0
  32. package/src/adapters/claude-print.ts +18 -156
  33. package/src/adapters/codex-review-isolation.ts +290 -0
  34. package/src/adapters/definitions/builtin-metadata.ts +9 -6
  35. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  37. package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
  38. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  39. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  40. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  41. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  42. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  43. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  44. package/src/adapters/definitions/native-factories.ts +17 -0
  45. package/src/adapters/definitions/native-policy-aux.ts +1 -1
  46. package/src/adapters/definitions/qwen.ts +1 -1
  47. package/src/adapters/definitions/reviewer-variants.ts +33 -0
  48. package/src/adapters/jcode.ts +20 -5
  49. package/src/adapters/kimi-review.ts +161 -0
  50. package/src/adapters/registry-types.ts +36 -4
  51. package/src/adapters/registry.ts +28 -7
  52. package/src/adapters/reviewer-mcp.ts +85 -0
  53. package/src/adapters/reviewer-print.ts +382 -0
  54. package/src/cli/agy-mcp.ts +370 -0
  55. package/src/cli/console-state.ts +6 -0
  56. package/src/cli/console.ts +2 -0
  57. package/src/cli/facts-hook.ts +15 -2
  58. package/src/cli/help.ts +2 -0
  59. package/src/cli/identity.ts +1 -1
  60. package/src/cli/init.ts +7 -0
  61. package/src/cli/launch.ts +3 -1
  62. package/src/cli/main.ts +20 -0
  63. package/src/cli/memory.ts +13 -12
  64. package/src/cli/output.ts +5 -0
  65. package/src/hub/board.ts +34 -6
  66. package/src/hub/bus.ts +17 -6
  67. package/src/hub/config-apply.ts +3 -2
  68. package/src/hub/control-client.ts +7 -1
  69. package/src/hub/daemon.ts +266 -46
  70. package/src/hub/envelope.ts +1 -1
  71. package/src/hub/host-executable.ts +1 -1
  72. package/src/hub/hub-tools.ts +5 -0
  73. package/src/hub/instances.ts +13 -3
  74. package/src/hub/memory-profile.ts +67 -11
  75. package/src/hub/native-hook-launch.ts +37 -0
  76. package/src/hub/peer-catalog.ts +2 -3
  77. package/src/hub/planning.ts +2 -0
  78. package/src/hub/release-admission.ts +64 -0
  79. package/src/hub/reviewer-pool-config.ts +11 -0
  80. package/src/hub/reviewer-pool.ts +76 -0
  81. package/src/hub/routing.ts +45 -3
  82. package/src/hub/sdlc-release.ts +113 -0
  83. package/src/hub/tasks.ts +261 -36
  84. package/src/ui/index.html +6 -1
  85. package/templates/AGENTS.block.md +1 -1
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { NativeHookLaunches } from "./native-hook-launch.ts";
2
+ import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
1
3
  import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
2
4
  import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
3
5
  import { isDenied } from "../local/deny.ts";
@@ -23,11 +25,11 @@ import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
23
25
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
24
26
  import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
25
27
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
26
- import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
28
+ import { KNOWN_PEERS, OPTIONAL_PEERS, PLANNER_PEERS, SUPPORTED_PEERS, START_MODE_PEERS, isSupportedPeer, STARTABLE_PEERS, isStartablePeer } from "../adapters/registry.ts";
27
29
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
28
30
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
29
- import { ADAPTERS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
30
- import { definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
31
+ import { ADAPTERS, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS, QUOTA_HANDOFF_PLATFORMS, NATIVE_CONFIG_BLOCKS, nativeConfigurationDefaults, mergeNativeConfiguration, retainNativeRouting, DAEMON_PORTS, definitionOf } from "../adapters/registry.ts";
32
+ import { isSupportedPeer as registrySupportedPeer, definitionOf as instanceDefinitionOf, KNOWN_PEERS as BASE_KNOWN_PEERS, SUPPORTED_PEERS as BASE_SUPPORTED_PEERS, STARTABLE_PEERS as BASE_STARTABLE_PEERS } from "../adapters/registry.ts";
31
33
  import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
32
34
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
33
35
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
@@ -63,7 +65,10 @@ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
63
65
  import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
64
66
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
65
67
  import { LocalPeer } from "../adapters/local-worker.ts";
66
- import { reviewDiff, ClaudePrintPeer } from "../adapters/claude-print.ts";
68
+ import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
69
+ import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
70
+ import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
71
+ import { reviewDiff } from "../adapters/claude-print.ts";
67
72
  import { Capture, skipTools } from "../memory/capture.ts";
68
73
  import { DEFAULT_OMNIROUTE, OmniRoute, type OmniRouteConfig } from "../omniroute/client.ts";
69
74
  import { Sidecar } from "../switchyard/sidecar.ts";
@@ -92,7 +97,7 @@ import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts"
92
97
  import { DeliveryJournal } from "./delivery-journal.ts";
93
98
  import { startDashboard, type DashboardSession } from "./ui.ts";
94
99
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
95
- import { newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
100
+ import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
96
101
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
97
102
  import { MemoryClient, workerUrl } from "../memory/client.ts";
98
103
  import { VERSION } from "../version.ts";
@@ -118,10 +123,10 @@ import { ContextWindows, DEFAULT_CONTEXT, acpContext, claudeContext, type Contex
118
123
  import { projectFileTool, scopedWriteBlocked } from "../cli/facts-hook.ts";
119
124
  import { detectTooling } from "./tooling.ts";
120
125
  import { looksSecret, DEFAULT_LEASE_MS, extractNodes, memoryHome, SUGGESTED, taskKey, type CandidateStore, type MemoryStore, type GoalView, type Suggested, type View as MemoryView } from "@staix/agent-mem";
121
- import { AGENT_MEM_VERSION, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
126
+ import { memoryProjectKey } from "@staix/agent-mem";
127
+ import { AGENT_MEM_VERSION, MEMORY_SPLIT_INCOMPLETE, memoryProjectFor, openProfileMemory, openProfileCandidates, type MemoryProfileBinding, type UnboundMemoryProfileReceipt } from "./memory-profile.ts";
122
128
  import { syncMemory } from "@staix/agent-mem";
123
129
  import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead } from "./read-tools.ts";
124
- import { memoryProjectKey } from "@staix/agent-mem";
125
130
  import { nativeRecall } from "../memory/recall.ts";
126
131
  import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
127
132
 
@@ -184,7 +189,7 @@ export interface HubConfig {
184
189
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
185
190
  limits: LimitsConfig;
186
191
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
187
- review: { adaptive: boolean; min_reviews: number };
192
+ review: { adaptive: boolean; min_reviews: number; pool?: string[]; rerotate?: boolean };
188
193
  /** After an unplanned stop, start Kimi, Pi and the local worker again with their recorded sessions (issue #37). */
189
194
  recovery: { auto_resume_after_crash: boolean };
190
195
  /** Per peer, the hub-tool capabilities it has (issue #39); a peer not listed has all of them. */
@@ -252,16 +257,26 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
252
257
  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"];
253
258
 
254
259
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
255
- export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string } {
260
+ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string; notices?: string[] } {
256
261
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
257
262
  const conductor = { ...DEFAULT_CONFIG.conductor };
263
+ const notices: string[] = [];
258
264
  let planner: string | undefined;
259
265
  for (const name of CONFIG_FILES) {
260
266
  let file: Record<string, unknown>;
261
267
  if (layers && !layers.some(layer => layer.name === name)) continue;
262
268
  try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
263
269
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
264
- if (file.roles !== undefined) { conductorPeer(file.roles); Object.assign(roles, file.roles); }
270
+ if (file.roles !== undefined) {
271
+ if (!file.roles || typeof file.roles !== "object" || Array.isArray(file.roles)) throw new Error("roles must be an object of peer role lists");
272
+ for (const [peer, assigned] of Object.entries(file.roles)) {
273
+ conductorPeer({ [peer]: assigned });
274
+ const ceiling = definitionOf(peer)?.routing?.allowedRoles;
275
+ const declared = assigned as string[];
276
+ roles[peer] = ceiling ? declared.filter(role => ceiling.includes(role)) : declared;
277
+ if (roles[peer]!.length !== declared.length) notices.push(`roles.${peer}: ignored roles outside the adapter ceiling`);
278
+ }
279
+ }
265
280
  if (file.planner !== undefined) {
266
281
  if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
267
282
  planner = file.planner as string;
@@ -280,7 +295,7 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
280
295
  }
281
296
  }
282
297
  conductorPeer(roles);
283
- return { roles, conductor, ...(planner ? { planner } : {}) };
298
+ return { roles, conductor, ...(planner ? { planner } : {}), ...(notices.length ? { notices } : {}) };
284
299
  }
285
300
 
286
301
  /** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
@@ -361,6 +376,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
361
376
  }
362
377
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
363
378
  const conductorPolicy = loadConductorPolicy(cwd, captured);
379
+ retired.push(...conductorPolicy.notices ?? []);
364
380
  const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
365
381
  const instancePermissions: Record<string, PermissionMode> = {};
366
382
  const instanceModes: PeerStartConfig = {};
@@ -438,6 +454,8 @@ function mlxPinProblem(routing: Routing, project: boolean): string | undefined {
438
454
  }
439
455
 
440
456
  export interface DaemonOptions {
457
+ /** Host-only admission transport; never selected by project config or tools. */
458
+ releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
441
459
  projectId?: string;
442
460
  instanceId?: string;
443
461
  cwd: string;
@@ -501,8 +519,18 @@ class WsPeer extends BasePeer {
501
519
  steer: PeerAdapter["steer"] = undefined;
502
520
  lastInboxAt: number | undefined;
503
521
  private claimedPullOnly = false;
522
+ private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
523
+ private settleAttachmentWaiters(attached: boolean): void {
524
+ for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
525
+ }
526
+ /** Bounded native-hook observations wait for this exact claim, never a later socket. */
527
+ waitForAttach(generation: string): Promise<boolean> {
528
+ if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
529
+ return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
530
+ }
504
531
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
505
532
  claim(sock: Sock, pullOnly = false): void {
533
+ this.settleAttachmentWaiters(false);
506
534
  this.claimGeneration = crypto.randomUUID();
507
535
  this.claimed = sock;
508
536
  this.claimedPullOnly = pullOnly;
@@ -534,9 +562,10 @@ class WsPeer extends BasePeer {
534
562
  };
535
563
  this.lastInboxAt = undefined;
536
564
  this.setState("idle");
565
+ this.settleAttachmentWaiters(true);
537
566
  }
538
567
  detach(sock: Sock): void {
539
- if (this.claimed === sock) this.claimed = undefined;
568
+ if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
540
569
  if (this.sock !== sock) return;
541
570
  this.sock = undefined;
542
571
  this.steer = undefined;
@@ -545,6 +574,7 @@ class WsPeer extends BasePeer {
545
574
  this.setState("offline");
546
575
  }
547
576
  detachForRoster(): void {
577
+ this.settleAttachmentWaiters(false);
548
578
  this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
549
579
  this.sock?.close(4000, "peer is off roster; retry when a person adds it");
550
580
  this.claimed = undefined;
@@ -586,6 +616,7 @@ class WsPeer extends BasePeer {
586
616
  ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
587
617
  async start(): Promise<void> {}
588
618
  async stop(): Promise<void> {
619
+ this.settleAttachmentWaiters(false);
589
620
  this.sock?.close();
590
621
  }
591
622
 
@@ -601,6 +632,7 @@ export async function startDaemon(opts: DaemonOptions) {
601
632
  let ready = false;
602
633
  try {
603
634
  const loadedConfig = opts.config ?? loadConfig(opts.cwd);
635
+ reviewerPoolConfig(loadedConfig.review, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS);
604
636
  const lifecycleConfig = loadSdlc(opts.cwd, VERSION);
605
637
  // Preserve embedding-owned policy references, but never publish reloads into shared defaults.
606
638
  const ownedConfig = Object.fromEntries(Object.entries(loadedConfig).map(([key, value]) => [key, !opts.config || value === (DEFAULT_CONFIG as unknown as Record<string, unknown>)[key] ? structuredClone(value) : value])) as unknown as HubConfig;
@@ -612,6 +644,7 @@ export async function startDaemon(opts: DaemonOptions) {
612
644
  const channelPeers = [...ADAPTERS.filter(definition => definition.identity.mcp === "channel-plugin").map(definition => definition.kind), ...channelExtras];
613
645
  const executionRoots = new Map<string, string>();
614
646
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string }>();
647
+ const reviewerUnavailable = new Map<string, string>();
615
648
  const roles = { ...loadedConfig.roles };
616
649
  const permissionModesFromInstances: Record<string, PermissionMode> = {};
617
650
  const startModesFromInstances: PeerStartConfig = {};
@@ -635,7 +668,7 @@ export async function startDaemon(opts: DaemonOptions) {
635
668
  const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
636
669
  const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
637
670
  const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
638
- const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
671
+ const isSupportedPeer = (peer: unknown): peer is string => registrySupportedPeer(peer, declarations);
639
672
  const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
640
673
  const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
641
674
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
@@ -675,6 +708,28 @@ export async function startDaemon(opts: DaemonOptions) {
675
708
  let configRevision = 0;
676
709
  let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
677
710
  let pendingRestart: { key: string; reason: string }[] = [];
711
+ let retainedConfig: { key: string; reason: string }[] = [];
712
+ function retainedConfigAfterReload(sources: ConfigSources, fileConfig: HubConfig, rejected: readonly { key: string; reason: string }[], scope?: string): { key: string; reason: string }[] {
713
+ const rows = new Map<string, string>();
714
+ if (scope) for (const row of retainedConfig) {
715
+ if (row.key === scope) continue;
716
+ const route = row.key.startsWith("routing."), path = (route ? row.key.slice(8) : row.key).split(".");
717
+ const current = valueAt(route ? sources.routing : fileConfig, path), accepted = valueAt(route ? desiredRouting : desiredConfig, path);
718
+ if (JSON.stringify(current) !== JSON.stringify(accepted)) rows.set(row.key, row.reason);
719
+ }
720
+ for (const row of rejected) rows.set(sanitize(row.key).slice(0, 160), sanitize(row.reason).slice(0, 1200));
721
+ return [...rows].map(([key, reason]) => ({ key, reason })).sort((left, right) => left.key.localeCompare(right.key));
722
+ }
723
+ function retainedLaunchConfig(peer: string): Record<string, unknown> | undefined {
724
+ const keys = definitionOf(peer)?.cli.launchConfigKeys ?? [];
725
+ const pending = retainedConfig.filter(row => keys.some(key => row.key === key || row.key.startsWith(`${key}.`)));
726
+ if (!pending.length) return;
727
+ return Object.fromEntries(pending.map(row => [row.key, valueAt(config, row.key.split("."))]));
728
+ }
729
+ const includeRetainedLaunchConfig = (peer: string, result: Record<string, unknown>): Record<string, unknown> => {
730
+ const values = retainedLaunchConfig(peer);
731
+ return values ? { ...result, retainedLaunchConfig: values } : result;
732
+ };
678
733
  let lastReloadFailure: string | undefined;
679
734
  let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
680
735
  let configWatchPending = false;
@@ -780,6 +835,7 @@ export async function startDaemon(opts: DaemonOptions) {
780
835
  // Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
781
836
  // The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
782
837
  const token = randomBytes(24).toString("hex");
838
+ const nativeHookLaunches = new NativeHookLaunches();
783
839
 
784
840
  const server = listenWithoutPortCollision(() => Bun.serve<Client>({
785
841
  hostname: "127.0.0.1",
@@ -807,6 +863,7 @@ export async function startDaemon(opts: DaemonOptions) {
807
863
  }
808
864
  },
809
865
  close(sock) {
866
+ nativeHookLaunches.endOwner(sock);
810
867
  consoles.delete(sock);
811
868
  sock.data.tail?.();
812
869
  sock.data.watch?.();
@@ -977,7 +1034,9 @@ export async function startDaemon(opts: DaemonOptions) {
977
1034
  const recalled = new Set<PeerId>(); // once per peer per hub run, however often the peer's session restarts
978
1035
  // #258: the hub's own store, machine-wide in ~/.agenthub/memory. Entries are filed under the project's memory key (its
979
1036
  // normalized origin remote, the same on every machine; without one, its path). Fail-open: without it the hub works as before.
980
- const memoryProject = memoryProjectKey(opts.cwd);
1037
+ // Daemon start never depends on the memory home: an unresolvable home falls back to the raw key.
1038
+ const memoryProjectRaw = memoryProjectKey(opts.cwd);
1039
+ const memoryProject = (() => { try { return memoryProjectFor(opts.cwd); } catch { return memoryProjectRaw; } })();
981
1040
  let memoryStore: MemoryStore | undefined;
982
1041
  let memoryBinding: MemoryProfileBinding | undefined;
983
1042
  let memoryProblem: string | undefined;
@@ -989,11 +1048,11 @@ export async function startDaemon(opts: DaemonOptions) {
989
1048
  memoryNotice(`hub native memory disabled: ${reason}`);
990
1049
  };
991
1050
  if (config.memory.native) {
992
- try { const opened = openProfileMemory({ ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
1051
+ try { const opened = openProfileMemory({ project: memoryProject, projectKey: memoryProjectRaw, ttlMs: (config.memory.short_ttl_days ?? 14) * 24 * 3600_000, log, screen: promotionScreen(memoryProject, opts.cwd, () => activeRouting(), () => config.pii?.paths ?? [], (key) => {
993
1052
  const m = /#(\d+)@(\d+)$/.exec(key), t = m ? board.get(Number(m[1])) : undefined;
994
1053
  return t && String(t.created) === m![2] ? [...(t.refs.paths ?? []), ...(t.plan?.paths ?? [])] : [];
995
1054
  }) }, memoryUnavailable); memoryStore = opened.store; memoryBinding = opened.binding; startupCleanup.push(() => opened.store.close()); }
996
- catch { if (!memoryProblem) memoryUnavailable("memory profile unavailable; inspect the selector and generation, then restart the hub"); }
1055
+ catch (error) { if (!memoryProblem) memoryUnavailable(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : "memory profile unavailable; inspect the selector and generation, then restart the hub"); }
997
1056
  }
998
1057
  const memoryProfileStatus = (person = false) => {
999
1058
  const value = memoryBinding?.status(memoryStore) ?? ({
@@ -1292,7 +1351,45 @@ export async function startDaemon(opts: DaemonOptions) {
1292
1351
  specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
1293
1352
  }));
1294
1353
  };
1354
+ const releaseQuotaSnapshot = () => {
1355
+ const headroom = budget.headroom(), rows = [...bus.peers].filter(([, peer]) => peer.state !== "offline");
1356
+ const reasons: string[] = [], notes: string[] = [];
1357
+ for (const [id] of rows) {
1358
+ const definition = definitionOf(id), account = definition?.budget?.account ?? id;
1359
+ if (manualPaused.has(id)) reasons.push(`${id}: manual pause; ahub resume ${id}`);
1360
+ if (budget.record(account)) reasons.push(`${id}: paused quota window; inspect ahub budget or ahub budget resume ${id}`);
1361
+ if (definition && (!definition.budget || definition.budget.quotaExempt)) continue;
1362
+ // Source availability belongs to the shared account, including its offline base and reviewer variants.
1363
+ // The soft token window uses the same definition capability that resets it on resume.
1364
+ const hasSource = accountMembers(account).some(member => {
1365
+ const policy = definitionOf(member)?.budget;
1366
+ return !!policy?.reader || !!policy?.resetSoftTokensOnResume && config.budget.kimi_tokens_5h > 0;
1367
+ });
1368
+ if (definition && !hasSource) {
1369
+ notes.push(`${id}: no quota source`);
1370
+ continue;
1371
+ }
1372
+ const reading = headroom[account];
1373
+ if (!reading || !Number.isFinite(reading.headroom)) reasons.push(`${id}: quota window unknown or stale; inspect ahub budget or ahub budget set ${id} <0..1>`);
1374
+ else if (reading.headroom <= 1 - config.budget.gate) reasons.push(`${id}: quota window below release headroom; wait for reset or inspect ahub budget set ${id} <0..1>`);
1375
+ }
1376
+ const idle = rows.every(([, peer]) => peer.state === "idle");
1377
+ return { quota: reasons.length === 0, idle, reasons, notes, signature: JSON.stringify({ gate: config.budget.gate, softTokens: config.budget.kimi_tokens_5h, rows: rows.map(([id, peer]) => [id, peer.state]), headroom, paused: rows.map(([id]) => manualPaused.has(id)), records: Object.entries(budget.status()).map(([peer, row]) => [peer, row.paused?.since, row.paused?.resetsAt]) }) };
1378
+ };
1379
+ const releaseWindow = async () => {
1380
+ const host = await (opts.releaseHostAdmission ?? readReleaseHostAdmission)(opts.cwd), quota = releaseQuotaSnapshot();
1381
+ if (stopping || recoveryActive()) quota.reasons.push("recovery or shutdown is holding release planning");
1382
+ return { at: Date.now(), host: host.admitted && !stopping && !recoveryActive(), quota: quota.quota, idle: quota.idle, reasons: [...(host.reason ? [host.reason] : []), ...quota.reasons, ...quota.notes], close: host.close, current: () => host.current?.() !== false && !stopping && !recoveryActive() && releaseQuotaSnapshot().signature === quota.signature };
1383
+ };
1384
+ // Bind both legacy print transports and task-MCP calls to the child's original review request.
1385
+ const runningReviewAuthority = (by: PeerId, task: number): (() => string | undefined) | undefined => {
1386
+ if (!definitionOf(by)?.reviewer) return undefined;
1387
+ const attached=bus.peers.get(by), peer=attached instanceof ReviewerPrintPeer?attached:undefined, token=peer?.reviewToken(task), binding=peer?.reviewBinding(task);
1388
+ return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
1389
+ ? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
1390
+ };
1295
1391
  const tasks: Tasks = new Tasks({
1392
+ releaseWindow,
1296
1393
  capabilities: peerCapabilities, repository: memoryRepo,
1297
1394
  plannerBlocks: () => Object.fromEntries(bus.knownPeers().map(peer => {
1298
1395
  const codes: ("approvals" | "quota")[] = [];
@@ -1303,13 +1400,9 @@ export async function startDaemon(opts: DaemonOptions) {
1303
1400
  instances: () => declarations,
1304
1401
  reviewOnly: reviewOnlyPeer,
1305
1402
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1306
- reviewAuthority: (by, task) => {
1307
- if (definitionOf(by)?.start.daemonPort !== "claude-print") return undefined;
1308
- const peer = bus.peers.get(by) as ClaudePrintPeer | undefined, token = peer?.reviewToken(task);
1309
- return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
1310
- },
1403
+ reviewAuthority: runningReviewAuthority,
1311
1404
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1312
- reviewContext: (by, task) => definitionOf(by)?.start.daemonPort === "claude-print" && (bus.peers.get(by) as ClaudePrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1405
+ reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1313
1406
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
1314
1407
  // reload cannot turn a canonical reviewer process into an isolated implementation owner.
1315
1408
  ownerIsolation: () => Object.fromEntries(extraIds.map(id => {
@@ -1403,7 +1496,7 @@ export async function startDaemon(opts: DaemonOptions) {
1403
1496
  recordCohort: (c) => event({ type: "cohort", ...c }),
1404
1497
  // The trace holds peer names and numbers only (never task text): it is the inputs a later check of the prediction needs.
1405
1498
  recordSplit: (task, p, where) => event({ type: "split", task, where, verdict: p.verdict, ...(p.single ? { single: p.single, splitS: p.splitS, singleS: p.singleS } : {}), ...(p.verdict === "unknown" ? { reason: p.trace.at(-1)!.replace(/^ {2}unknown: /, "").slice(0, 200) } : {}), trace: p.trace.slice(1).map((l) => l.trim()).slice(0, 10) }),
1406
- failing: () => bus.failingPeers(),
1499
+ failing: () => ({ ...bus.failingPeers(), ...Object.fromEntries(reviewerUnavailable) }),
1407
1500
  held: () => Object.fromEntries(bus.knownPeers().flatMap((peer) => { const hold = queueHold(peer); return hold ? [[peer, hold]] : []; })),
1408
1501
  });
1409
1502
  relevantNotice = (peer, env) => {
@@ -1977,7 +2070,10 @@ export async function startDaemon(opts: DaemonOptions) {
1977
2070
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
1978
2071
  taskOpsInFlight++;
1979
2072
  try {
2073
+ const readAuthority=op==="hub_task_show"?runningReviewAuthority(by,Number(input?.id)):undefined;
2074
+ const before=readAuthority?.();if(before)throw new PreEffectToolRefusal(before);
1980
2075
  const text = await taskOpBody(...args);
2076
+ const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
1981
2077
  if (conducted) {
1982
2078
  const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
1983
2079
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
@@ -2022,7 +2118,7 @@ export async function startDaemon(opts: DaemonOptions) {
2022
2118
  }
2023
2119
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
2024
2120
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
2025
- 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_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`);
2121
+ 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`);
2026
2122
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
2027
2123
  // completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
2028
2124
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
@@ -2136,6 +2232,16 @@ export async function startDaemon(opts: DaemonOptions) {
2136
2232
  }
2137
2233
  case "hub_backlog_list":
2138
2234
  return JSON.stringify(tasks.backlogList(by));
2235
+ case "hub_release_new":
2236
+ return JSON.stringify(tasks.publicRelease(await tasks.newRelease(by, { name: a.name, goal: a.goal, order: a.order })));
2237
+ case "hub_release_report":
2238
+ return JSON.stringify(tasks.releaseReport(by, a.id));
2239
+ case "hub_release_configure":
2240
+ return JSON.stringify(tasks.publicRelease(await tasks.configureRelease(by, { id: a.id, revision: a.revision, hotfix: a.hotfix, state: a.state, cut: a.cut })));
2241
+ case "hub_release_assign":
2242
+ return JSON.stringify(tasks.publicView(await tasks.assignRelease(by, { id: a.id, revision: a.revision, release: a.release, reason: a.reason })));
2243
+ case "hub_release_cut":
2244
+ return JSON.stringify(tasks.publicRelease(await tasks.cutRelease(by, { id: a.id, revision: a.revision })));
2139
2245
  case "hub_release_list":
2140
2246
  return JSON.stringify(tasks.releaseList(by));
2141
2247
  case "hub_planner_groom":
@@ -2586,6 +2692,8 @@ export async function startDaemon(opts: DaemonOptions) {
2586
2692
  version: VERSION,
2587
2693
  pluginBundle: SERVER_JS,
2588
2694
  pendingRestart,
2695
+ retainedConfig,
2696
+ startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2589
2697
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2590
2698
  lifecycleStages: [...lifecycleConfig.stages],
2591
2699
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
@@ -3245,7 +3353,7 @@ export async function startDaemon(opts: DaemonOptions) {
3245
3353
  const owner = bus.peers.get(peer);
3246
3354
  const stopNotice = definitionOf(peer)?.cli.stopNotice?.(owner instanceof WsPeer ? "tui" : !owner ? startMode(peer) : "headless", owner);
3247
3355
  if (stopNotice) return { ok: false, error: stopNotice };
3248
- if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ClaudePrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3356
+ if (!(owner instanceof AcpPeer || owner instanceof PiPeer || owner instanceof LocalPeer || owner instanceof ReviewerPrintPeer)) return { ok: false, error: "unknown or non-owned peer; run ahub status and choose a hub-owned headless peer" };
3249
3357
  if (owner instanceof PiPeer && owner.mode !== "headless") return { ok: false, error: "Pi belongs to a native TUI terminal; end it in its terminal" };
3250
3358
  if (owner.state === "offline") return { ok: false, error: `peer is already offline; start it with ${startCommand(peer)} before stopping it` };
3251
3359
  if (starting.has(peer) || requestedPeerStops.has(peer) || permissionChanges.has(peer)) return { ok: false, error: "peer lifecycle or permission change is in progress; wait for ahub status before retrying" };
@@ -3309,6 +3417,7 @@ export async function startDaemon(opts: DaemonOptions) {
3309
3417
 
3310
3418
  async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; replaceSession?: boolean; fresh?: boolean; unattended?: boolean; providerProfile?: string }, mute: (p: PeerAdapter) => void, executionCwd: string): Promise<Record<string, unknown>> {
3311
3419
  const adapter = definitionOf(peer);
3420
+ if (adapter?.reviewer?.unavailable) return { ok: false, error: adapter.reviewer.unavailable };
3312
3421
  const preflight = adapter?.start.preflight?.({ instance: peer, form: args.mode });
3313
3422
  if (preflight) return { ok: false, error: preflight };
3314
3423
  if (adapter?.attach?.admission && !declarations[peer]) {
@@ -3803,24 +3912,54 @@ export async function startDaemon(opts: DaemonOptions) {
3803
3912
  await local.start();
3804
3913
  return { ok: true, model: route ? `${route} (fallback ${routing.local.fixed_model})` : (args.model ?? routing.local.fixed_model) };
3805
3914
  }
3806
- if (adapter?.start.daemonPort === "claude-print") {
3807
- // #223 R3: one fresh `claude -p` session per review request; idle with no process between reviews.
3915
+ if (adapter?.reviewer) {
3916
+ const backend = adapter.reviewer.backend;
3917
+ if (!backend || !adapter.start.create) return { ok: false, error: "reviewer has no verified runtime" };
3918
+ // #519: each backend proves isolation before admission, then starts one fresh child per review.
3808
3919
  if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
3809
3920
  if (existing) { mute(existing); await existing.stop(); }
3810
- const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["claude-print"]({
3811
- cwd: executionCwd,
3921
+ const reviewPolicies = new Map<string, string>();
3922
+ const reviewer = adapter.start.create!({ instance: peer, projectRoot: opts.cwd, stateDir: opts.stateDir, kindConfig: config, permission: permissionMode(peer), mcpServers: [], daemon: DAEMON_PORTS["reviewer-print"]({
3923
+ backend, cwd: executionCwd,
3812
3924
  mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
3813
3925
  systemPrompt: sessionRoleContract(peer),
3814
3926
  watchdogMs: config.watchdog_ms,
3815
- privatePaths: [...(config.pii?.paths ?? []), ...config.local.deny],
3927
+ privatePaths: [opts.stateDir, ...(config.pii?.paths ?? []), ...config.local.deny],
3928
+ bindingCurrent: (task,binding) => tasks.reviewBindingCurrent(peer,task,binding),
3929
+ taskMcp: (task, current) => createReviewerMcpSession({ task, current, tools: [...TASK_TOOLS, ...CONDUCTOR_TOOLS],
3930
+ call: (name, args) => taskOp(peer, name, args, true, contextPii(peer)) }),
3931
+ admit: env => {
3932
+ if (env.private || contextPii(peer) || !reviewOnlyPeer(peer) || !tasks.reviewBindingCurrent(peer, Number(env.refs?.task),
3933
+ { head: env.refs?.commit, flowRevision: Number(env.refs?.reviewRevision) })) return false;
3934
+ const policy = JSON.stringify([config.local.deny, config.pii, config.data, config.roles[peer]]);
3935
+ const before = reviewPolicies.get(env.id);
3936
+ if (before !== undefined) return before === policy;
3937
+ if (reviewPolicies.size >= 64) reviewPolicies.delete(reviewPolicies.keys().next().value!);
3938
+ reviewPolicies.set(env.id, policy);
3939
+ return true;
3940
+ },
3816
3941
  facts: env => env.private ? undefined : reviewDiff(executionCwd, env.refs?.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(executionCwd, path, config.pii?.paths ?? [], true), nameable: tasks.nameable }),
3817
3942
  verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
3818
3943
  onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
3944
+ onUnavailable: (failure, current) => {
3945
+ const attached = () => current() && bus.peers.get(peer) === reviewer;
3946
+ if (!attached()) return;
3947
+ if (failure.cause === "start_failed") reviewerUnavailable.set(peer, "reviewer isolation unavailable; restart after checking the verified CLI and isolation requirements");
3948
+ return tasks.retryReviewer(failure.task, peer, failure, attached).then(() => undefined);
3949
+ },
3819
3950
  log,
3820
- }) }) as ClaudePrintPeer;
3951
+ }) }) as ReviewerPrintPeer;
3952
+ try { await reviewer.start(); }
3953
+ catch (error) {
3954
+ await reviewer.stop().catch(() => {});
3955
+ const reason = backend.nativePolicy && error instanceof Error ? sanitize(error.message).slice(0, 1000)
3956
+ : "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
3957
+ reviewerUnavailable.set(peer, reason);
3958
+ return { ok: false, error: reason };
3959
+ }
3960
+ reviewerUnavailable.delete(peer);
3821
3961
  executionOwners.set(peer, { owner: reviewer, cwd: executionCwd });
3822
3962
  bus.add(reviewer);
3823
- await reviewer.start();
3824
3963
  return { ok: true };
3825
3964
  }
3826
3965
  return { ok: false, error: `unknown peer "${peer}"` };
@@ -4022,7 +4161,7 @@ export async function startDaemon(opts: DaemonOptions) {
4022
4161
  instanceId,
4023
4162
  attention: attentionStatus.attentionInput,
4024
4163
  pet,
4025
- status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4164
+ status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4026
4165
  boardCapabilities: { backlog: true },
4027
4166
  planningCapabilities: { enabled: true, wipPull: true, split: true },
4028
4167
  releases: tasks.releaseList(USER),
@@ -4272,6 +4411,7 @@ export async function startDaemon(opts: DaemonOptions) {
4272
4411
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4273
4412
  pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4274
4413
  pendingRestart.push(...routingChanges.filter(key => configApply(key).applies === "restart").map(key => ({ key, reason: configApply(key).reason! })));
4414
+ retainedConfig = retainedConfigAfterReload(sources, prepared.fileConfig, prepared.rejected, scope);
4275
4415
  storedConfigCache = undefined;
4276
4416
  if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
4277
4417
  bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
@@ -4280,7 +4420,7 @@ export async function startDaemon(opts: DaemonOptions) {
4280
4420
  if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
4281
4421
  const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
4282
4422
  const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
4283
- if (failure && lastReloadFailure !== failure) { try { notify(`configuration keys retained: ${sanitize(refusal).slice(0, 1200)}`); } catch { log("configuration keys retained; notice unavailable"); } }
4423
+ if (failure && lastReloadFailure !== failure) { try { notify(`config retained: ${retainedConfig.map(item => item.key).join(", ")}; run ahub settings reload`); } catch { log("configuration retained notice unavailable"); } }
4284
4424
  lastReloadFailure = failure;
4285
4425
  writeStatus();
4286
4426
  const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
@@ -4316,7 +4456,7 @@ export async function startDaemon(opts: DaemonOptions) {
4316
4456
  try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
4317
4457
  catch { return {}; }
4318
4458
  }
4319
- function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[] } {
4459
+ function settingsView(): { rows: SettingRow[]; undo?: string; pendingRestart: { key: string; reason: string }[]; retainedConfig: { key: string; reason: string }[] } {
4320
4460
  const stored = storedConfig();
4321
4461
  // What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
4322
4462
  // merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
@@ -4335,7 +4475,7 @@ export async function startDaemon(opts: DaemonOptions) {
4335
4475
  });
4336
4476
  const undo = pendingUndo(opts.stateDir);
4337
4477
  const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
4338
- return { pendingRestart, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
4478
+ return { pendingRestart, retainedConfig, rows: rows.map(row => row.key === "planner" ? { ...row, hint: planner } : row), ...(undo ? { undo } : {}) };
4339
4479
  }
4340
4480
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
4341
4481
  const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
@@ -4686,16 +4826,23 @@ export async function startDaemon(opts: DaemonOptions) {
4686
4826
  return ids.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_cancel"))
4687
4827
  ? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
4688
4828
  };
4829
+ const incompatibleRecoveryTarget = () => {
4830
+ const cancelled = incompatibleCancellationTarget();
4831
+ if (cancelled) return cancelled;
4832
+ if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return "target lacks peer_instances; declared instance identities cannot be restored by that daemon";
4833
+ if (board.releases().some(release => release.train && ["active", "frozen"].includes(release.state)) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("release_trains"))) return "target lacks release_trains; active or frozen train safeguards cannot be preserved";
4834
+ if ((extraIds.some(id => !!definitionOf(id)?.reviewer) || config.review.pool !== undefined) && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("reviewer_pool"))) return "target lacks reviewer_pool; fresh review-only variants and pool policy cannot be preserved";
4835
+ return undefined;
4836
+ };
4689
4837
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
4690
4838
  if (checkTarget) {
4691
- const error = incompatibleCancellationTarget(); if (error) return recoveryError(error);
4692
- if (extraIds.length && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("peer_instances"))) return recoveryError("target lacks peer_instances; declared instance identities cannot be restored by that daemon");
4839
+ const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
4693
4840
  }
4694
4841
 
4695
4842
  if (msg.op === "inspect" || msg.op === "prepare" || msg.op === "commit") {
4696
4843
  for (const pi of bus.peers.values()) if (pi instanceof PiPeer && pi.state === "idle") {
4697
4844
  try { await pi.captureResume(); } catch (error) { return recoveryError((error as Error).message); }
4698
- if (checkTarget) { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4845
+ if (checkTarget) { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4699
4846
  }
4700
4847
  }
4701
4848
  if (msg.op === "inspect" && msg.operationId === undefined) return { t: "recovery", ok: true, recovery: recoveryView() };
@@ -4711,7 +4858,7 @@ export async function startDaemon(opts: DaemonOptions) {
4711
4858
  recoveryPeerSnapshot = undefined;
4712
4859
  }
4713
4860
  if (recoveryOperationId && recoveryOperationId !== op) return recoveryError("another recovery operation is active");
4714
- { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4861
+ { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4715
4862
  recoveryOperationId = op;
4716
4863
  if (!recoveryPhase) recoveryPhase = "preparing";
4717
4864
  armRecoveryLease();
@@ -4719,7 +4866,7 @@ export async function startDaemon(opts: DaemonOptions) {
4719
4866
  budget.setRecoveryHold(true);
4720
4867
  bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
4721
4868
  await bus.fenceRecovery();
4722
- { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4869
+ { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4723
4870
  const blocked = [...bus.peers].filter(([, peer]) => peer.state === "busy").map(([id]) => id);
4724
4871
  if (!blocked.length && permissions.size === 0 && recoveryReady()) {
4725
4872
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
@@ -4746,6 +4893,7 @@ export async function startDaemon(opts: DaemonOptions) {
4746
4893
  }
4747
4894
  if (msg.op === "commit") {
4748
4895
  if ((recoveryPhase !== "prepared" && recoveryPhase !== "preparing") || !recoveryReady()) return recoveryError("recovery is not ready; inspect until peers are idle, approvals are complete and completion checks have finished");
4896
+ // Person ruling 2026-10-19: upgrade is excluded from release resource gates; original readiness still binds.
4749
4897
  recoveryPhase = "prepared";
4750
4898
  bus.setRecoveryHold(true, Array.isArray(msg.targetFeatures) && msg.targetFeatures.includes("recovery_deferred_hub"));
4751
4899
  recoveryPeerSnapshot ??= Object.values(recoveryPeers());
@@ -4768,7 +4916,7 @@ export async function startDaemon(opts: DaemonOptions) {
4768
4916
  peers,
4769
4917
  integrity: integrity(),
4770
4918
  };
4771
- { const error = incompatibleCancellationTarget(); if (error) return recoveryError(error); }
4919
+ { const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error); }
4772
4920
  try { writeRestartSnapshot(opts.stateDir, snapshot); } catch { return recoveryError("could not persist restart state"); }
4773
4921
  recoveryCommitted = true;
4774
4922
  clearTimeout(recoveryLeaseTimer);
@@ -4935,6 +5083,7 @@ export async function startDaemon(opts: DaemonOptions) {
4935
5083
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
4936
5084
  } else {
4937
5085
  c.peer = String(msg.peer ?? "claude");
5086
+ if (definitionOf(c.peer)?.transport === "ws-pull" && msg.channel !== false) return sock.close(4403, "this native peer supports pull-only attachment; use hub_inbox");
4938
5087
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
4939
5088
  if (!PEER_ID.test(c.peer) || (RESERVED_IDS.has(c.peer) && !kimiPull)) return sock.close(4403, definitionOf(c.peer)?.attach?.admission?.reservedRefusal ?? "peer id is reserved or malformed");
4940
5089
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
@@ -4961,7 +5110,7 @@ export async function startDaemon(opts: DaemonOptions) {
4961
5110
  }
4962
5111
  return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
4963
5112
  }
4964
- if (stopping && msg.t !== "status" && msg.t !== "kill") return void reply({ ok: false, error: "hub is stopping" });
5113
+ if (stopping && msg.t !== "status" && msg.t !== "kill" && !(c.role === "console" && msg.t === "native_hook_launch" && msg.op === "end")) return void reply({ ok: false, error: "hub is stopping" });
4965
5114
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
4966
5115
  switch (msg.t) {
4967
5116
  case "permission_default": {
@@ -5078,16 +5227,87 @@ export async function startDaemon(opts: DaemonOptions) {
5078
5227
  const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
5079
5228
  const refused = c.peer && !heldBack ? admit(admittedEnv, inReplyTo?.id) : undefined;
5080
5229
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
5081
- const targets = bus.publish(env);
5230
+ const { targets, dropped } = bus.publishResult(env);
5082
5231
  if (c.peer && inReplyTo) bus.completeReply(c.peer, inReplyTo.id); // the delivery it answers was handled all the same
5232
+ if (dropped === "hop") return void reply({ t: "sent", ok: true, targets, dropped, recorded: priority === "fyi",
5233
+ notice: `not delivered: reply chain reached the hop cap (${MAX_HOP}); send a new message without reply_to` });
5083
5234
  if (hushed.length && !targets.length) return void reply({ t: "sent", ok: false, error: `not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}` });
5084
5235
  return void reply({ t: "sent", ok: true, targets, recorded: priority === "fyi", ...(offRoster.length ? { offRoster } : {}), ...(hushed.length ? { hushed: hushed.map((h) => h.peer) } : {}), ...((offRoster.length || hushed.length) ? { notice: [offRoster.length ? `retained off roster for ${offRoster.join(", ")}` : "", hushed.length ? `not delivered to ${hushed.map((h) => h.peer).join(", ")}: ${hushed[0]!.reason}` : ""].filter(Boolean).join("; ") } : {}) });
5085
5236
  }
5237
+ case "native_hook_launch": {
5238
+ if (c.role !== "console" || typeof msg.peer !== "string" || !definitionOf(msg.peer)?.hooks?.factsHook) return void reply({ ok: false, error: "managed native launch requires the person and a supported hook adapter" });
5239
+ if (msg.op === "end") return void reply({ ok: nativeHookLaunches.end(msg.peer, msg.launchId) });
5240
+ if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding native launches" });
5241
+ if (!membershipAllowed(msg.peer)) return void reply({ ok: false, error: "peer is off roster" });
5242
+ if (msg.op !== "begin") return void reply({ ok: false, error: "unknown native launch operation" });
5243
+ const owner = bus.peers.get(msg.peer);
5244
+ if (owner && owner.state !== "offline") return void reply({ ok: false, error: "native peer is already attached; end it in its terminal first" });
5245
+ try { return void reply({ ok: true, launchId: nativeHookLaunches.begin(msg.peer, sock).id, instanceId }); }
5246
+ catch (error) { return void reply({ ok: false, error: (error as Error).message }); }
5247
+ }
5248
+ case "native_facts": {
5249
+ const peer = c.peer, port = peer ? definitionOf(peer)?.hooks?.factsHook : undefined;
5250
+ if (!peer || c.role !== "tools" || !port || msg.nativeInstanceId !== instanceId) return void reply({ ok: false });
5251
+ const launch = nativeHookLaunches.get(peer, msg.nativeLaunchId);
5252
+ const native = typeof msg.event === "string" ? port.decode(msg.event, msg.payload) : undefined;
5253
+ if (!launch || !native || !native.workspacePaths.every(root => { try { return realPath(root) === realPath(opts.cwd); } catch { return false; } })) return void reply({ ok: false });
5254
+ if (launch.session && launch.session !== native.sessionId) return void reply({ ok: false });
5255
+ launch.session = native.sessionId;
5256
+ const owner = bus.peers.get(peer);
5257
+ if (!(owner instanceof WsPeer) || owner.state === "offline" && !owner.claiming) return void reply({ ok: false });
5258
+ if (launch.generation && launch.generation !== owner.sessionGeneration) return void reply({ ok: false });
5259
+ launch.generation = owner.sessionGeneration;
5260
+ const current = () => !stopping && nativeHookLaunches.get(peer, msg.nativeLaunchId) === launch && bus.peers.get(peer) === owner && owner.sessionGeneration === launch.generation && owner.state !== "offline";
5261
+ // Preserve the start baseline at observation, even if native output is flushed before recall allows attach.
5262
+ const readback = port.read(native.sessionId, native.transcriptPath);
5263
+ const apply = () => {
5264
+ if (!current()) return void reply({ ok: false });
5265
+ if (factsOn()) { factSession(peer, native.sessionId); for (const id of readback.factIds) acked(peer, id, "native-hook"); }
5266
+ if (native.event === "PreInvocation" || native.event === "PreToolUse") {
5267
+ if (!launch.active || native.event === "PreInvocation" && native.invocation === 0) { launch.active = true; launch.turn = crypto.randomUUID(); launch.baseline = readback.completion?.index ?? -1; }
5268
+ activeAt.set(peer, Date.now()); owner.nativeStarted(native.sessionId);
5269
+ }
5270
+ if (native.event === "Stop") {
5271
+ if (!launch.active || !native.fullyIdle || native.terminationReason !== "NO_TOOL_CALL") return void reply({ ok: false });
5272
+ // Native output can be flushed after its Stop command returns. Acknowledge observation, then wait for
5273
+ // a fresh completion row while the same launcher/session/socket generation still owns the turn.
5274
+ const turn = launch.turn, baseline = launch.baseline ?? -1;
5275
+ reply({ ok: true, pending: true });
5276
+ void (async () => {
5277
+ const deadline = Date.now() + 2000;
5278
+ let delay = 5;
5279
+ // ponytail: an unverified Stop may re-read up to 4 MiB per poll within this 2 s wait.
5280
+ // Upgrade: cache unchanged transcript evidence within the wait.
5281
+ while (current() && launch.active && launch.turn === turn) {
5282
+ const completion = port.read(native.sessionId, native.transcriptPath).completion;
5283
+ if (completion && completion.index > baseline && completion.id !== launch.completed) {
5284
+ launch.completed = completion.id; launch.active = false;
5285
+ turnEnded.set(peer, Date.now()); nativeCompletedAt.set(peer, Date.now()); tasks.cohorts.turnEnded(peer);
5286
+ event({ type: "native_turn_end", peer, id: completion.id }); owner.nativeStopped(native.sessionId); return;
5287
+ }
5288
+ if (Date.now() >= deadline) { notify(`${peer}: native completion could not be verified; it stays busy until a verified turn end or detach`); return; }
5289
+ await Bun.sleep(delay); delay = Math.min(50, delay * 2);
5290
+ }
5291
+ })().catch(() => log("managed native completion unavailable"));
5292
+ return;
5293
+ }
5294
+ const offered = factsOn() && (native.event === "PreInvocation" || native.event === "SessionStart") ? offerFor(peer, native.toolUseId ?? `${native.sessionId}:invocation`) : undefined;
5295
+ return void reply({ ok: true, ...(offered ? { text: offered.text } : {}) });
5296
+ };
5297
+ if (owner.claiming) {
5298
+ void owner.waitForAttach(launch.generation).then(attached => {
5299
+ if (attached) apply(); else reply({ ok: false });
5300
+ }).catch(() => { log("managed native observation unavailable"); reply({ ok: false }); });
5301
+ return;
5302
+ }
5303
+ return apply();
5304
+ }
5086
5305
  case "facts": {
5087
5306
  // Turn-free facts (issue #108) for Claude's hooks: `pre` before a tool runs (the fact rides back as additional
5088
5307
  // context), `post` after it (its effect, and the readback of what went in before), `stop` when its turn ends.
5089
5308
  // The hook never shows an error: on any failure the answer is just empty.
5090
5309
  if (!c.peer || c.role === "console") return void reply({ t: "facts", ok: false, error: "facts are for a peer" });
5310
+ if (definitionOf(c.peer)?.hooks?.factsHook) return void reply({ t: "facts", ok: false, error: "managed native hooks require native_facts" });
5091
5311
  const phase: "session" | "start" | "pre" | "post" | "stop" = ["session", "start", "post", "stop"].includes(msg.phase) ? msg.phase : "pre";
5092
5312
  const tool = typeof msg.tool === "string" ? msg.tool.slice(0, 64) : "";
5093
5313
  const input = msg.input && typeof msg.input === "object" && !Array.isArray(msg.input) ? msg.input : {};
@@ -5362,7 +5582,7 @@ export async function startDaemon(opts: DaemonOptions) {
5362
5582
  case "peer_start":
5363
5583
  // #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
5364
5584
  if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
5365
- void hubStart(msg.peer, "the console").then(reply, (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
5585
+ void hubStart(msg.peer, "the console").then(result => reply(includeRetainedLaunchConfig(String(msg.peer), result)), (error) => { log(`peer_start ${String(msg.peer)} failed: ${(error as Error).message}`); reply({ ok: false, error: "peer start failed; inspect ahub status and hub.log" }); });
5366
5586
  return;
5367
5587
  case "peer_stop":
5368
5588
  if (c.role !== "console") return void reply({ ok: false, error: "peer_stop is a console command; the person runs ahub stop in a terminal" });
@@ -5376,7 +5596,7 @@ export async function startDaemon(opts: DaemonOptions) {
5376
5596
  .catch((e: Error) => ({ ok: false, error: e.message }))
5377
5597
  .then((r) => {
5378
5598
  if (!r.ok) log(`start ${msg.peer} failed: ${r.error}`);
5379
- reply({ t: "started", ...r });
5599
+ reply({ t: "started", ...includeRetainedLaunchConfig(String(msg.peer), r) });
5380
5600
  });
5381
5601
  return;
5382
5602
  case "task":