@staix/agent-hub 0.12.37 → 0.12.39

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 (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +12 -6
  3. package/docs/agent-notes/adapters.md +19 -8
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +3 -2
  6. package/docs/agent-notes/daemon.md +6 -3
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +3 -3
  9. package/docs/operations.md +45 -17
  10. package/docs/quickstart.md +19 -5
  11. package/docs/security.md +7 -3
  12. package/docs/smoke.md +22 -2
  13. package/docs/specs/2026-09-19-agent-hub-design.md +40 -9
  14. package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
  15. package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
  16. package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
  17. package/docs/verification/2026-10-20-0.12.38.md +56 -0
  18. package/docs/verification/2026-10-20-0.12.39.md +55 -0
  19. package/docs/verification/2026-10-20-jcode-tui-0.98.1.md +39 -0
  20. package/docs/verified.json +22 -14
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1759 -437
  24. package/src/adapters/acp.ts +20 -2
  25. package/src/adapters/agy/definition.ts +61 -0
  26. package/src/adapters/agy/entry.ts +76 -0
  27. package/src/adapters/agy/hooks.ts +81 -0
  28. package/src/adapters/claude-channel.ts +1 -0
  29. package/src/adapters/codex-review-isolation.ts +30 -16
  30. package/src/adapters/definitions/builtin-metadata.ts +8 -8
  31. package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
  32. package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
  33. package/src/adapters/definitions/native-cli-entry-jcode.ts +82 -11
  34. package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
  35. package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
  36. package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
  37. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
  38. package/src/adapters/definitions/native-cli-entry-types.ts +1 -0
  39. package/src/adapters/definitions/native-cli-entry.ts +2 -0
  40. package/src/adapters/definitions/native-cli-preview-common.ts +1 -0
  41. package/src/adapters/definitions/native-cli-preview-jcode.ts +7 -7
  42. package/src/adapters/definitions/native-cli-retained.ts +15 -0
  43. package/src/adapters/definitions/native-daemon-policy.ts +1 -1
  44. package/src/adapters/definitions/qwen.ts +1 -1
  45. package/src/adapters/definitions/reviewer-variants.ts +4 -3
  46. package/src/adapters/jcode.ts +58 -1
  47. package/src/adapters/kimi-review.ts +161 -0
  48. package/src/adapters/registry-types.ts +29 -1
  49. package/src/adapters/registry.ts +16 -7
  50. package/src/adapters/reviewer-mcp.ts +85 -0
  51. package/src/adapters/reviewer-print.ts +83 -20
  52. package/src/cli/agy-mcp.ts +370 -0
  53. package/src/cli/console-state.ts +6 -0
  54. package/src/cli/console.ts +2 -0
  55. package/src/cli/facts-hook.ts +15 -2
  56. package/src/cli/init.ts +7 -0
  57. package/src/cli/launch.ts +3 -1
  58. package/src/cli/main.ts +5 -4
  59. package/src/cli/output.ts +5 -0
  60. package/src/cli/preview.ts +2 -2
  61. package/src/hub/bus.ts +17 -6
  62. package/src/hub/control-client.ts +7 -1
  63. package/src/hub/daemon.ts +182 -24
  64. package/src/hub/native-hook-launch.ts +37 -0
  65. package/src/hub/peer-catalog.ts +2 -3
  66. package/src/hub/peers.ts +2 -2
  67. package/src/hub/reviewer-pool.ts +12 -6
  68. package/src/hub/routing.ts +3 -0
  69. package/src/hub/start-mode.ts +1 -1
  70. package/src/hub/tasks.ts +7 -0
  71. package/src/ui/index.html +6 -1
  72. package/templates/AGENTS.block.md +1 -1
@@ -8,7 +8,7 @@ export function stateDirFor(cwd: string): string {
8
8
 
9
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
10
10
  export const PROTOCOL = 16;
11
- export const DAEMON_FEATURES = ["task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool"] as const;
11
+ export const DAEMON_FEATURES = ["native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "release_trains", "reviewer_pool", "native_tui_launch"] as const;
12
12
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
13
13
  export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
14
14
 
@@ -32,6 +32,8 @@ export interface ControlDescriptor {
32
32
  protocol?: number;
33
33
  pid?: number;
34
34
  features?: readonly string[];
35
+ /** The daemon's accepted native start modes, used by launchers when the file value is retained. */
36
+ startModes?: Readonly<Record<string, "tui" | "headless">>;
35
37
  /** #226: the daemon's processSignature, written into status.json only (not the status reply); absent before 0.12.21. */
36
38
  pidSignature?: string;
37
39
  }
@@ -42,8 +44,12 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
42
44
  const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
43
45
  const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
44
46
  if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
47
+ const startModes = status.startModes && typeof status.startModes === "object" && !Array.isArray(status.startModes)
48
+ ? Object.fromEntries(Object.entries(status.startModes).filter((entry): entry is [string, "tui" | "headless"] => entry[1] === "tui" || entry[1] === "headless"))
49
+ : undefined;
45
50
  return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
46
51
  instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
52
+ ...(startModes ? { startModes } : {}),
47
53
  ...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
48
54
  } catch {
49
55
  return undefined;
package/src/hub/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { NativeHookLaunches } from "./native-hook-launch.ts";
1
2
  import { readReleaseHostAdmission, type ReleaseHostAdmission } from "./release-admission.ts";
2
3
  import { ContainerRunner, type RunnerOptions, type RunReceipt } from "./check-runner.ts";
3
4
  import { fetchSpec, specAvailable, specRefs } from "./spec-read.ts";
@@ -24,11 +25,11 @@ import { jcodeMcpReady } from "../cli/jcode-mcp.ts";
24
25
  import { loadSdlc, readSpecEdit, sdlcGh } from "./sdlc.ts";
25
26
  import { claudeStateFile, ownTerminalRow, pathEvidence, transcriptPersisted } from "./claude-state.ts";
26
27
  import { peerRosterConfig, rosterAllows, type PeerRoster } from "./roster.ts";
27
- 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";
28
29
  import { RecoveryDeferredHubError } from "./deferred-hub.ts";
29
30
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "./recovery-error.ts";
30
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";
31
- 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";
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";
32
33
  import { effectiveInstanceRoles, instancePeerIds, validateInstances, type InstancesConfig } from "./instances.ts";
33
34
  import { INSTANCE_CONFIG_FILES, readInstanceConfig, readInstanceConfigLayers, readInstanceRoleConfig } from "./instance-config.ts";
34
35
  import { instanceBudgetHooks, projectBudgetHeadroom } from "./instance-budget.ts";
@@ -65,6 +66,7 @@ import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../lo
65
66
  import { runTool, toolResultFailed, TOOL_SCHEMAS, type ToolApproval, type ApprovalObserver, type ToolContext } from "../local/tools.ts";
66
67
  import { LocalPeer } from "../adapters/local-worker.ts";
67
68
  import { ReviewerPrintPeer } from "../adapters/reviewer-print.ts";
69
+ import { createReviewerMcpSession } from "../adapters/reviewer-mcp.ts";
68
70
  import { reviewerPoolConfig } from "./reviewer-pool-config.ts";
69
71
  import { reviewDiff } from "../adapters/claude-print.ts";
70
72
  import { Capture, skipTools } from "../memory/capture.ts";
@@ -95,7 +97,7 @@ import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts"
95
97
  import { DeliveryJournal } from "./delivery-journal.ts";
96
98
  import { startDashboard, type DashboardSession } from "./ui.ts";
97
99
  import { DAEMON_FEATURES, PROTOCOL, stateDirFor } from "./control-client.ts";
98
- 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";
99
101
  import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
100
102
  import { MemoryClient, workerUrl } from "../memory/client.ts";
101
103
  import { VERSION } from "../version.ts";
@@ -255,16 +257,26 @@ export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<st
255
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"];
256
258
 
257
259
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
258
- 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[] } {
259
261
  const roles: Record<string, string[]> = { ...DEFAULT_ROLES };
260
262
  const conductor = { ...DEFAULT_CONFIG.conductor };
263
+ const notices: string[] = [];
261
264
  let planner: string | undefined;
262
265
  for (const name of CONFIG_FILES) {
263
266
  let file: Record<string, unknown>;
264
267
  if (layers && !layers.some(layer => layer.name === name)) continue;
265
268
  try { file = layers ? structuredClone(layers.find(layer => layer.name === name)!.raw) : JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8")); }
266
269
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
267
- 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
+ }
268
280
  if (file.planner !== undefined) {
269
281
  if (!(PLANNER_PEERS as readonly unknown[]).includes(file.planner)) throw new Error(`planner must be one of ${PLANNER_PEERS.join(", ")}`);
270
282
  planner = file.planner as string;
@@ -283,7 +295,7 @@ export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[]
283
295
  }
284
296
  }
285
297
  conductorPeer(roles);
286
- return { roles, conductor, ...(planner ? { planner } : {}) };
298
+ return { roles, conductor, ...(planner ? { planner } : {}), ...(notices.length ? { notices } : {}) };
287
299
  }
288
300
 
289
301
  /** `memory.short_ttl_days`: a whole number of days from 1 to 3650; anything else would make every short-term deadline invalid. */
@@ -364,6 +376,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[]): HubCon
364
376
  }
365
377
  const mlx = { ...(file.mlx?.provider === "legacy" ? { enabled: true, provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
366
378
  const conductorPolicy = loadConductorPolicy(cwd, captured);
379
+ retired.push(...conductorPolicy.notices ?? []);
367
380
  const extraRoles = Object.fromEntries(Object.keys(instances ?? {}).map(id => [id, effectiveInstanceRoles(id, instances, conductorPolicy.roles)]));
368
381
  const instancePermissions: Record<string, PermissionMode> = {};
369
382
  const instanceModes: PeerStartConfig = {};
@@ -506,8 +519,18 @@ class WsPeer extends BasePeer {
506
519
  steer: PeerAdapter["steer"] = undefined;
507
520
  lastInboxAt: number | undefined;
508
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
+ }
509
531
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
510
532
  claim(sock: Sock, pullOnly = false): void {
533
+ this.settleAttachmentWaiters(false);
511
534
  this.claimGeneration = crypto.randomUUID();
512
535
  this.claimed = sock;
513
536
  this.claimedPullOnly = pullOnly;
@@ -539,9 +562,10 @@ class WsPeer extends BasePeer {
539
562
  };
540
563
  this.lastInboxAt = undefined;
541
564
  this.setState("idle");
565
+ this.settleAttachmentWaiters(true);
542
566
  }
543
567
  detach(sock: Sock): void {
544
- if (this.claimed === sock) this.claimed = undefined;
568
+ if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
545
569
  if (this.sock !== sock) return;
546
570
  this.sock = undefined;
547
571
  this.steer = undefined;
@@ -550,6 +574,7 @@ class WsPeer extends BasePeer {
550
574
  this.setState("offline");
551
575
  }
552
576
  detachForRoster(): void {
577
+ this.settleAttachmentWaiters(false);
553
578
  this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
554
579
  this.sock?.close(4000, "peer is off roster; retry when a person adds it");
555
580
  this.claimed = undefined;
@@ -591,6 +616,7 @@ class WsPeer extends BasePeer {
591
616
  ownsDelivery(sock: Sock, generation: unknown, id: string): boolean { return this.owns(sock) && generation === this.generation && this.delivered.has(id); }
592
617
  async start(): Promise<void> {}
593
618
  async stop(): Promise<void> {
619
+ this.settleAttachmentWaiters(false);
594
620
  this.sock?.close();
595
621
  }
596
622
 
@@ -642,7 +668,7 @@ export async function startDaemon(opts: DaemonOptions) {
642
668
  const KNOWN_PEERS: readonly string[] = [...BASE_KNOWN_PEERS, ...extraIds];
643
669
  const SUPPORTED_PEERS: readonly string[] = [...BASE_SUPPORTED_PEERS, ...extraIds];
644
670
  const STARTABLE_PEERS: readonly string[] = [...BASE_STARTABLE_PEERS, ...extraIds.filter(id => !channelExtras.includes(id))];
645
- const isSupportedPeer = (peer: unknown): peer is string => typeof peer === "string" && definitionOf(peer) !== undefined;
671
+ const isSupportedPeer = (peer: unknown): peer is string => registrySupportedPeer(peer, declarations);
646
672
  const isStartablePeer = (peer: unknown): peer is string => typeof peer === "string" && !channelExtras.includes(peer) && definitionOf(peer)?.start.conductorStartable === true;
647
673
  const RESERVED_IDS = new Set([...BASE_RESERVED_IDS, ...extraIds.filter(id => !channelExtras.includes(id))]);
648
674
  // #223 R2: a declared instance whose effective roles are exactly reviewer gets review requests and its own task notices only.
@@ -682,6 +708,28 @@ export async function startDaemon(opts: DaemonOptions) {
682
708
  let configRevision = 0;
683
709
  let reloadConfirmation: { token: string; fingerprint: string; revision: number; keys: string[] } | undefined;
684
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
+ };
685
733
  let lastReloadFailure: string | undefined;
686
734
  let reloadDebounce: ReturnType<typeof setTimeout> | undefined;
687
735
  let configWatchPending = false;
@@ -787,6 +835,7 @@ export async function startDaemon(opts: DaemonOptions) {
787
835
  // Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
788
836
  // The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
789
837
  const token = randomBytes(24).toString("hex");
838
+ const nativeHookLaunches = new NativeHookLaunches();
790
839
 
791
840
  const server = listenWithoutPortCollision(() => Bun.serve<Client>({
792
841
  hostname: "127.0.0.1",
@@ -814,6 +863,7 @@ export async function startDaemon(opts: DaemonOptions) {
814
863
  }
815
864
  },
816
865
  close(sock) {
866
+ nativeHookLaunches.endOwner(sock);
817
867
  consoles.delete(sock);
818
868
  sock.data.tail?.();
819
869
  sock.data.watch?.();
@@ -1331,6 +1381,13 @@ export async function startDaemon(opts: DaemonOptions) {
1331
1381
  if (stopping || recoveryActive()) quota.reasons.push("recovery or shutdown is holding release planning");
1332
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 };
1333
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
+ };
1334
1391
  const tasks: Tasks = new Tasks({
1335
1392
  releaseWindow,
1336
1393
  capabilities: peerCapabilities, repository: memoryRepo,
@@ -1343,11 +1400,7 @@ export async function startDaemon(opts: DaemonOptions) {
1343
1400
  instances: () => declarations,
1344
1401
  reviewOnly: reviewOnlyPeer,
1345
1402
  // #223: a headless reviewer's verdict holds only while the same child it started with still runs for that task.
1346
- reviewAuthority: (by, task) => {
1347
- if (!definitionOf(by)?.reviewer) return undefined;
1348
- const peer = bus.peers.get(by) as ReviewerPrintPeer | undefined, token = peer?.reviewToken(task);
1349
- return () => token !== undefined && bus.peers.get(by) === peer && peer?.reviewToken(task) === token ? undefined : `no review of task #${task} is running for ${by}`;
1350
- },
1403
+ reviewAuthority: runningReviewAuthority,
1351
1404
  // #223 R4: fresh only when the verdict comes from a reviewer session the hub started for this task's review.
1352
1405
  reviewContext: (by, task) => !!definitionOf(by)?.reviewer && (bus.peers.get(by) as ReviewerPrintPeer | undefined)?.reviewing === task ? "fresh" : "continuing",
1353
1406
  // Proof belongs to the adapter generation actually started in the validated root. A role-only
@@ -2017,7 +2070,10 @@ export async function startDaemon(opts: DaemonOptions) {
2017
2070
  const admittedId = typeof input?.id === "number" && Number.isSafeInteger(input.id) && input.id > 0 ? input.id : undefined;
2018
2071
  taskOpsInFlight++;
2019
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);
2020
2075
  const text = await taskOpBody(...args);
2076
+ const after=readAuthority?.();if(after)throw new PreEffectToolRefusal(after);
2021
2077
  if (conducted) {
2022
2078
  const proposed = op === "hub_task_propose" ? Number(text.match(/task #(\d+):/)?.[1]) : undefined;
2023
2079
  const id = Number.isSafeInteger(proposed) && proposed! > 0 ? proposed : admittedId;
@@ -2636,6 +2692,8 @@ export async function startDaemon(opts: DaemonOptions) {
2636
2692
  version: VERSION,
2637
2693
  pluginBundle: SERVER_JS,
2638
2694
  pendingRestart,
2695
+ retainedConfig,
2696
+ startModes: Object.fromEntries(START_MODE_PEERS.map(peer => [peer, startModeOf(config.peers, peer, declarations)])),
2639
2697
  memoryProfile: memoryProfileStatus(includeAttentionItems),
2640
2698
  lifecycleStages: [...lifecycleConfig.stages],
2641
2699
  // ponytail: off keeps the #257 phase schema until RETIRED_WORKFLOW_FROM, when the alias and this flag go together.
@@ -3264,7 +3322,7 @@ export async function startDaemon(opts: DaemonOptions) {
3264
3322
  if (owner instanceof PiPeer && owner.state !== "offline" && owner.mode === "tui") return { ok: false, error: "Pi runs in a terminal; end it there before starting a headless one", command: "ahub pi --headless" };
3265
3323
  return startPeer(peer, definitionOf(peer)?.start.explicitHeadlessMode ? { mode: "headless", ...(resume ?? {}) } : {});
3266
3324
  }
3267
- if (owner && owner.state !== "offline") return { ok: true, already: true, mode };
3325
+ if (owner && owner.state !== "offline" && definitionOf(peer)?.start.daemonPort !== "owned-acp") return { ok: true, already: true, mode };
3268
3326
  const words = [...startWords(peer, mode)];
3269
3327
  if (resume) words.push(...(resume.sessionFile ? ["--session-file", resume.sessionFile] : ["--session-id", resume.sessionId]), ...(resume.backend ? ["--backend", resume.backend] : []), ...(resume.model ? ["--model", resume.model] : []));
3270
3328
  const command = `ahub ${resume ? words.map(shellQuote).join(" ") : words.join(" ")}`;
@@ -3860,12 +3918,26 @@ export async function startDaemon(opts: DaemonOptions) {
3860
3918
  // #519: each backend proves isolation before admission, then starts one fresh child per review.
3861
3919
  if (existing?.state === "busy") return { ok: false, error: `${peer} is reviewing; retry when it is idle` };
3862
3920
  if (existing) { mute(existing); await existing.stop(); }
3921
+ const reviewPolicies = new Map<string, string>();
3863
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"]({
3864
3923
  backend, cwd: executionCwd,
3865
3924
  mcpConfig: JSON.stringify({ mcpServers: { "agent-hub": { command: process.execPath, args: ["run", SERVER_JS], env: toolEnv(peer, true) } } }),
3866
3925
  systemPrompt: sessionRoleContract(peer),
3867
3926
  watchdogMs: config.watchdog_ms,
3868
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
+ },
3869
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 }),
3870
3942
  verdictSince: (task, since) => board.get(task)?.history.some(h => h.by === peer && h.at >= since && (h.event === "approved" || h.event === "changes_requested")) === true,
3871
3943
  onUsage: usage => void addTokens(peer, (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0)),
@@ -3878,9 +3950,10 @@ export async function startDaemon(opts: DaemonOptions) {
3878
3950
  log,
3879
3951
  }) }) as ReviewerPrintPeer;
3880
3952
  try { await reviewer.start(); }
3881
- catch {
3953
+ catch (error) {
3882
3954
  await reviewer.stop().catch(() => {});
3883
- const reason = "reviewer isolation unavailable; inspect the verified CLI version and isolation requirements";
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";
3884
3957
  reviewerUnavailable.set(peer, reason);
3885
3958
  return { ok: false, error: reason };
3886
3959
  }
@@ -4088,7 +4161,7 @@ export async function startDaemon(opts: DaemonOptions) {
4088
4161
  instanceId,
4089
4162
  attention: attentionStatus.attentionInput,
4090
4163
  pet,
4091
- status: { peers: peerView, pendingRestart, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4164
+ status: { peers: peerView, pendingRestart, retainedConfig, taskWip: attentionStatus.taskWip, roleInfo: attentionStatus.roleInfo },
4092
4165
  boardCapabilities: { backlog: true },
4093
4166
  planningCapabilities: { enabled: true, wipPull: true, split: true },
4094
4167
  releases: tasks.releaseList(USER),
@@ -4338,6 +4411,7 @@ export async function startDaemon(opts: DaemonOptions) {
4338
4411
  if (priorDesired !== JSON.stringify([desiredConfig, desiredRouting, acceptedSources.trustLayers])) configRevision++;
4339
4412
  pendingRestart = configChanges({ ...config, roles: policyRoles }, candidate).filter(change => change.applies === "restart").map(change => ({ key: change.key, reason: change.reason! }));
4340
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);
4341
4415
  storedConfigCache = undefined;
4342
4416
  if (beforeConductor && (beforeConductor !== conductorPeer(config.roles) || config.conductor.feed === "off")) {
4343
4417
  bus.revokeSupervision(beforeConductor); supervision.resetPending(beforeConductor); budgetFeedSeen.clear();
@@ -4346,7 +4420,7 @@ export async function startDaemon(opts: DaemonOptions) {
4346
4420
  if (prepared.guarded.length) reloadConfirmation = { token: randomUUID(), fingerprint, revision: configRevision, keys: prepared.guarded };
4347
4421
  const refusal = prepared.rejected.map(item => `${item.key}: ${item.reason}`).join("; ");
4348
4422
  const failure = refusal ? `${fingerprint}:${refusal}` : undefined;
4349
- 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"); } }
4350
4424
  lastReloadFailure = failure;
4351
4425
  writeStatus();
4352
4426
  const applied = [...changes.filter(change => change.applies !== "restart").map(change => change.key), ...routingChanges.filter(key => configApply(key).applies !== "restart")];
@@ -4382,7 +4456,7 @@ export async function startDaemon(opts: DaemonOptions) {
4382
4456
  try { return { settings: { ...settingsView(), ...(session?.settings ? { sessionUntil: session.settingsUntil } : {}) } }; }
4383
4457
  catch { return {}; }
4384
4458
  }
4385
- 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 }[] } {
4386
4460
  const stored = storedConfig();
4387
4461
  // What the loader would say about the files as they are: an overlay that does not parse, or one that no longer
4388
4462
  // merges (it names a route routing.toml dropped), leaves the last good policy in force, and the rows say why.
@@ -4401,7 +4475,7 @@ export async function startDaemon(opts: DaemonOptions) {
4401
4475
  });
4402
4476
  const undo = pendingUndo(opts.stateDir);
4403
4477
  const planner = `In force: ${plannerPeers().join(", ") || "nobody"} (${plannerSource}). Setting it makes that peer the only planner; agents cannot change it.`;
4404
- 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 } : {}) };
4405
4479
  }
4406
4480
  /** The same announcement whatever surface made the change: events.jsonl, hub.log and every open console. */
4407
4481
  const recordSetting = (key: string, from: SettingValue, to: SettingValue, source: "dashboard" | "terminal", undo = false, reason?: string) => {
@@ -5009,6 +5083,7 @@ export async function startDaemon(opts: DaemonOptions) {
5009
5083
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
5010
5084
  } else {
5011
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");
5012
5087
  const kimiPull = !!definitionOf(c.peer)?.attach?.admission && (kimiSessionMode ?? startMode(c.peer)) === "tui" && msg.channel === false;
5013
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");
5014
5089
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
@@ -5035,7 +5110,7 @@ export async function startDaemon(opts: DaemonOptions) {
5035
5110
  }
5036
5111
  return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
5037
5112
  }
5038
- 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" });
5039
5114
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
5040
5115
  switch (msg.t) {
5041
5116
  case "permission_default": {
@@ -5152,16 +5227,87 @@ export async function startDaemon(opts: DaemonOptions) {
5152
5227
  const heldBack = hushed.length > 0 && hushed.length === bus.audience(env).length;
5153
5228
  const refused = c.peer && !heldBack ? admit(admittedEnv, inReplyTo?.id) : undefined;
5154
5229
  if (refused) return void reply({ t: "sent", ok: false, error: refused });
5155
- const targets = bus.publish(env);
5230
+ const { targets, dropped } = bus.publishResult(env);
5156
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` });
5157
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}` });
5158
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("; ") } : {}) });
5159
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
+ }
5160
5305
  case "facts": {
5161
5306
  // Turn-free facts (issue #108) for Claude's hooks: `pre` before a tool runs (the fact rides back as additional
5162
5307
  // context), `post` after it (its effect, and the readback of what went in before), `stop` when its turn ends.
5163
5308
  // The hook never shows an error: on any failure the answer is just empty.
5164
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" });
5165
5311
  const phase: "session" | "start" | "pre" | "post" | "stop" = ["session", "start", "post", "stop"].includes(msg.phase) ? msg.phase : "pre";
5166
5312
  const tool = typeof msg.tool === "string" ? msg.tool.slice(0, 64) : "";
5167
5313
  const input = msg.input && typeof msg.input === "object" && !Array.isArray(msg.input) ? msg.input : {};
@@ -5433,10 +5579,22 @@ export async function startDaemon(opts: DaemonOptions) {
5433
5579
  if (typeof msg.admissionId === "string" && msg.admissionId === kimiAdmissionId) clearKimiAdmission();
5434
5580
  return void reply({ ok: true });
5435
5581
  }
5582
+ case "native_tui_launch": {
5583
+ if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
5584
+ const peer = String(msg.peer), owner = bus.peers.get(peer);
5585
+ if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer) ||
5586
+ peer !== definitionOf(peer)?.kind || !(owner instanceof JcodePeer) || !jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return void reply({ ok: false, error: "owned native TUI launch is unavailable" });
5587
+ try {
5588
+ if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
5589
+ const options = msg.args ?? {};
5590
+ for (const key of ["model", "providerProfile", "sessionId"]) if (options[key] !== undefined && typeof options[key] !== "string") throw new Error("invalid option");
5591
+ return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
5592
+ } catch { return void reply({ ok: false, error: "owned Jcode launch or saved session could not be verified; details withheld" }); }
5593
+ }
5436
5594
  case "peer_start":
5437
5595
  // #269: a start the hub makes in the peer's start mode, for a console child with no terminal of its own.
5438
5596
  if (c.role !== "console") return void reply({ ok: false, error: "peer_start is a console command; use hub_peer_start with the conductor role" });
5439
- 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" }); });
5597
+ 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" }); });
5440
5598
  return;
5441
5599
  case "peer_stop":
5442
5600
  if (c.role !== "console") return void reply({ ok: false, error: "peer_stop is a console command; the person runs ahub stop in a terminal" });
@@ -5450,7 +5608,7 @@ export async function startDaemon(opts: DaemonOptions) {
5450
5608
  .catch((e: Error) => ({ ok: false, error: e.message }))
5451
5609
  .then((r) => {
5452
5610
  if (!r.ok) log(`start ${msg.peer} failed: ${r.error}`);
5453
- reply({ t: "started", ...r });
5611
+ reply({ t: "started", ...includeRetainedLaunchConfig(String(msg.peer), r) });
5454
5612
  });
5455
5613
  return;
5456
5614
  case "task":
@@ -0,0 +1,37 @@
1
+ /** Managed launch authority is ephemeral and scoped to one peer, daemon and native session. */
2
+ export interface NativeHookLaunch {
3
+ id: string;
4
+ session?: string;
5
+ generation?: string;
6
+ baseline?: number;
7
+ turn?: string;
8
+ active: boolean;
9
+ completed?: string;
10
+ }
11
+ export class NativeHookLaunches {
12
+ private readonly launches = new Map<string, NativeHookLaunch>();
13
+ private readonly owners = new Map<string, object>();
14
+ begin(peer: string, owner: object): NativeHookLaunch {
15
+ if (this.launches.has(peer)) throw new Error("a managed native launch is already active; end it in its terminal first");
16
+ const launch: NativeHookLaunch = { id: crypto.randomUUID(), active: false };
17
+ this.launches.set(peer, launch);
18
+ this.owners.set(peer, owner);
19
+ return launch;
20
+ }
21
+ get(peer: string, id: unknown): NativeHookLaunch | undefined {
22
+ const launch = this.launches.get(peer);
23
+ return launch && typeof id === "string" && launch.id === id ? launch : undefined;
24
+ }
25
+ end(peer: string, id: unknown): boolean {
26
+ if (!this.get(peer, id)) return false;
27
+ this.owners.delete(peer);
28
+ return this.launches.delete(peer);
29
+ }
30
+ endOwner(owner: object): void {
31
+ for (const [peer, current] of this.owners) {
32
+ if (current !== owner) continue;
33
+ this.launches.delete(peer);
34
+ this.owners.delete(peer);
35
+ }
36
+ }
37
+ }
@@ -6,14 +6,13 @@ export { SUPPORTED_PEERS } from "../adapters/registry.ts";
6
6
  /** Native peers the hub knows; membership checks share the registry. */
7
7
  export const KNOWN_PEERS = ["claude", "codex", "kimi", "pi", "local"] as const;
8
8
  /** Optional production peers are added explicitly; legacy defaults remain stable. */
9
- export const OPTIONAL_PEERS = ["qwen", "jcode"] as const;
9
+ export const OPTIONAL_PEERS = ["qwen", "jcode", "agy"] as const;
10
10
  export type KnownPeer = typeof SUPPORTED_PEERS[number];
11
11
 
12
12
  export const isKnownPeer = (value: unknown): value is KnownPeer =>
13
13
  typeof value === "string" && !!definitionOf(value) && !definitionOf(value)!.optional;
14
14
 
15
- export const isSupportedPeer = (value: unknown): value is KnownPeer =>
16
- typeof value === "string" && !!definitionOf(value);
15
+ export { isSupportedPeer } from "../adapters/registry.ts";
17
16
 
18
17
  // Policy and historical projection order belong to the registry; public compatibility exports remain intact.
19
18
  export { defaultPeerRoles, PLANNER_PEERS } from "../adapters/registry.ts";
package/src/hub/peers.ts CHANGED
@@ -73,11 +73,11 @@ export abstract class BasePeer implements PeerAdapter {
73
73
  }
74
74
 
75
75
  /** Any sign of life from a busy peer re-arms the watchdog (it measures inactivity, not duration). */
76
- protected touch(): void {
76
+ protected touch(ms = this.watchdogMs): void {
77
77
  this.clearWatchdog();
78
78
  this.timer = setTimeout(() => {
79
79
  if (this._state === "busy") this.onWatchdog();
80
- }, this.watchdogMs);
80
+ }, ms);
81
81
  this.timer.unref?.();
82
82
  }
83
83
 
@@ -22,12 +22,18 @@ export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelect
22
22
  const kinds = [...new Set(input.pool)];
23
23
  const walk: { id: string; kind: string; family: string; reason: string }[] = [];
24
24
  if (!kinds.length) return { reviewer: null, walk, blocked: "no reviewer can run" };
25
+ // A configured kind without any declaration cannot take a modulo share from actual reviewers.
26
+ // Blocked declarations still own their slot: current availability never changes the denominator.
27
+ const declaredKinds = kinds.filter(kind => input.candidates.some(candidate => candidate.kind === kind));
28
+ const undeclared = kinds.filter(kind => !declaredKinds.includes(kind)).map(kind => ({ id: "", kind, family: "", reason: "no declared reviewer" }));
29
+ const finish = (selection: Omit<ReviewerPoolSelection, "walk">): ReviewerPoolSelection => ({ ...selection, walk: [...walk, ...undeclared] });
30
+ if (!declaredKinds.length) return finish({ reviewer: null, blocked: "no reviewer can run" });
25
31
  const seed = Number.isSafeInteger(input.seed) ? input.seed : 0;
26
- const start = ((seed % kinds.length) + kinds.length) % kinds.length;
27
- const rotated = [...kinds.slice(start), ...kinds.slice(0, start)];
32
+ const start = ((seed % declaredKinds.length) + declaredKinds.length) % declaredKinds.length;
33
+ const rotated = [...declaredKinds.slice(start), ...declaredKinds.slice(0, start)];
28
34
  const slots = rotated.flatMap(kind => {
29
35
  const declared = input.candidates.filter(candidate => candidate.kind === kind).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
30
- return declared.length ? declared : [{ id: "", kind, family: "", blocked: "no declared reviewer" }];
36
+ return declared;
31
37
  });
32
38
  let ordered = slots;
33
39
  // Preserve the rotated order within each family, including sibling instance ids.
@@ -56,7 +62,7 @@ export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelect
56
62
  }
57
63
  const reason: ReviewerPoolReason = skippedUnavailable ? "unavailable skip" : skippedOwner ? "implementer skip" : "rotation";
58
64
  record(candidate, `selected (${continuity && candidate.family === input.continuityFamily ? "continuity" : reason})`);
59
- return { reviewer: candidate.id, family: candidate.family, reason, walk };
65
+ return finish({ reviewer: candidate.id, family: candidate.family, reason });
60
66
  }
61
67
  // The caller supplies separate fresh reviewer instances; this does not authorize an implementer's own session.
62
68
  for (const candidate of ordered) {
@@ -64,7 +70,7 @@ export function selectReviewerPool(input: ReviewerPoolInput): ReviewerPoolSelect
64
70
  const blocked = unavailable(candidate);
65
71
  if (blocked !== undefined) { record(candidate, `unavailable skip: ${blocked}`); continue; }
66
72
  record(candidate, "selected (same-family fallback)");
67
- return { reviewer: candidate.id, family: candidate.family, reason: "same-family fallback", walk };
73
+ return finish({ reviewer: candidate.id, family: candidate.family, reason: "same-family fallback" });
68
74
  }
69
- return { reviewer: null, walk, blocked: "no reviewer can run" };
75
+ return finish({ reviewer: null, blocked: "no reviewer can run" });
70
76
  }
@@ -368,6 +368,9 @@ export function assign(
368
368
  const refusals = new Map<string, RoutingRefusal>();
369
369
  const blocked = (peer: PeerId, role: "owner" | "reviewer"): string | undefined => {
370
370
  const deny = (code: RoutingRefusalCode, reason: string): string => { refusals.set(`${role}:${peer}`, { peer, role, code, reason }); return reason; };
371
+ const ceiling = definitionOf(peer, opts.instances)?.routing?.allowedRoles;
372
+ const requiredRole = role === "reviewer" || task.class === "review" ? "reviewer" : task.class === "plan" ? "planner" : "implementer";
373
+ if (ceiling && !ceiling.includes(requiredRole)) return deny("policy", "adapter role ceiling excludes this assignment");
371
374
  const unavailable = role === "reviewer" ? definitionOf(peer, opts.instances)?.reviewer?.unavailable : undefined;
372
375
  if (unavailable) return deny("policy", unavailable);
373
376
  if (role === "owner" && opts.capabilities && !pii) {