@staix/agent-hub 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
package/src/hub/daemon.ts CHANGED
@@ -150,6 +150,7 @@ import { closeSync, constants as fsConstants, openSync, lstatSync, readSync, sta
150
150
  import { basename, isAbsolute } from "node:path";
151
151
  import { fileURLToPath } from "node:url";
152
152
  import { canonicalPath, realPath, hubHome } from "./project.ts";
153
+ import { HandoffRefusal, checkTarget, parseHandoff, resolveTarget, handoffEventFor, type HandoffEvent } from "./handoff.ts";
153
154
  import { mainCheckoutBranch, mainCheckoutWarning } from "./main-checkout.ts";
154
155
  import type { BusEvent, PublishResult } from "./bus.ts";
155
156
  import { CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, DEFAULT_ROLES, IMPLEMENTER_HUB_TOOL_NAMES, roleContract, TASK_TOOL_NAMES, TASK_TOOLS, BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, profileTaskTools } from "./hub-tools.ts";
@@ -173,15 +174,20 @@ import { githubOriginRepository, githubRemoteRepository } from "./task-repositor
173
174
  import { Bus } from "./bus.ts";
174
175
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
175
176
  import { DeliveryJournal } from "./delivery-journal.ts";
177
+ import { assertHubDbCompatible } from "./db-schema.ts";
176
178
  import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
177
179
  import { codeGroup } from "./task-code-group.ts";
178
- import { DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
180
+ import { ControlClient, DAEMON_FEATURES, PROTOCOL, PROTOCOL_MIN, readControl, stateDirFor } from "./control-client.ts";
181
+ import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
182
+ import { attachEvent, originOf, type Origin } from "./provenance.ts";
183
+ import { mintSessionId, sessionGate } from "./session-identity.ts";
179
184
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
180
185
  import { BasePeer, DEFAULT_WATCHDOG_MS, type FrontendProxyPort, type PeerAdapter } from "./peers.ts";
181
186
  import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerDoing, type TitleCursor } from "./peer-doing.ts";
182
187
  import { MemoryClient } from "../memory/client.ts";
183
188
  import { VERSION } from "../version.ts";
184
189
  import { appendRecords, taskRecords } from "./research.ts";
190
+ import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
185
191
  import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
186
192
  import { projectChain } from "@staix/agent-mem";
187
193
  import { recallFor } from "../memory/recall.ts";
@@ -196,6 +202,7 @@ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContin
196
202
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
197
203
  import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
198
204
 
205
+ import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
199
206
  import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
200
207
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
201
208
  import type { TerminalOpener } from "./terminal-open.ts";
@@ -278,13 +285,15 @@ export interface HubConfig {
278
285
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
279
286
  snapshots: { enabled: boolean; keep: number };
280
287
  /** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
281
- research: { enabled: boolean };
288
+ research: { enabled: boolean; runners?: Record<string, RegisteredRunnerV1> };
282
289
  /** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
283
290
  bench: { enabled: boolean };
284
291
  /** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
285
292
  peers: PeerStartConfig;
286
293
  /** #269: a machine-local argv that opens a terminal for a hub-made TUI start; `{command}` in one element is the command to run. */
287
294
  terminal: { open: string[] };
295
+ /** #608: the peers `ahub up` asks the hub to start, machine-local. Read by the CLI at a person's `ahub up` and by the dashboard manager's start; the daemon itself never starts one from it. */
296
+ start: { agents: string[] };
288
297
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
289
298
  limits: LimitsConfig;
290
299
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
@@ -350,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
350
359
  bench: { enabled: false },
351
360
  peers: {},
352
361
  terminal: { open: [] },
362
+ start: { agents: [...START_AGENTS_DEFAULT] },
353
363
  limits: DEFAULT_LIMITS,
354
364
  review: { adaptive: false, min_reviews: 5 },
355
365
  recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
@@ -360,14 +370,29 @@ export const DEFAULT_CONFIG: HubConfig = {
360
370
  export { stateDirFor };
361
371
 
362
372
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
363
- const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
373
+ const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
364
374
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
365
375
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
366
376
 
367
377
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
368
378
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
369
379
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
370
- const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
380
+ const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
381
+
382
+ /** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
383
+ function rawResearchRunnersPresent(cwd: string): boolean {
384
+ const texts = configSourceTexts(cwd);
385
+ let present = false;
386
+ for (const name of CONFIG_FILES) {
387
+ if (texts[name] === null) continue;
388
+ const raw: unknown = JSON.parse(texts[name]!);
389
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
390
+ const research = (raw as Record<string, unknown>).research;
391
+ if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
392
+ present ||= research !== undefined && Object.hasOwn(research, "runners");
393
+ }
394
+ return present;
395
+ }
371
396
 
372
397
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
373
398
  export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
@@ -475,6 +500,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
475
500
  peerStartConfig(file.peers, instances, adapterCatalog);
476
501
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
477
502
  terminalTemplate(file.terminal?.open);
503
+ if (file.start != null && (typeof file.start !== "object" || Array.isArray(file.start) || Object.keys(file.start).some((key: string) => key !== "agents"))) throw new Error("start must be an object with an agents list");
504
+ if (file.start?.agents !== undefined) checkStartAgents(file.start.agents);
478
505
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
479
506
  if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
480
507
  delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
@@ -484,8 +511,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
484
511
  if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
485
512
  if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
486
513
  }
514
+ if (file.research !== undefined && (!file.research || typeof file.research !== "object" || Array.isArray(file.research))) throw new Error("research must be an object");
515
+ const hasRunners = file.research && Object.hasOwn(file.research, "runners"), runnerDeclarations: unknown = hasRunners ? file.research.runners : undefined;
487
516
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
488
517
  if (why) ignored.push(why);
518
+ if (hasRunners) file.research.runners = parseResearchRunners(runnerDeclarations);
489
519
  for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
490
520
  return [file];
491
521
  });
@@ -572,10 +602,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
572
602
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
573
603
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
574
604
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
575
- research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
605
+ research: { enabled: file.research?.enabled === true, ...(file.research?.runners !== undefined ? { runners: parseResearchRunners(file.research.runners) } : {}) }, // opt-in: anything but true is off
576
606
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
577
607
  peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
578
608
  terminal: { open: terminalTemplate(file.terminal?.open) },
609
+ start: { agents: file.start?.agents === undefined ? [...START_AGENTS_DEFAULT] : checkStartAgents(file.start.agents) },
579
610
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
580
611
  review: { ...DEFAULT_CONFIG.review, ...file.review },
581
612
  recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
@@ -695,8 +726,11 @@ export interface DaemonOptions {
695
726
 
696
727
  interface Client {
697
728
  authed: boolean;
729
+ protocol?: number;
698
730
  role?: "peer" | "console" | "tools";
699
731
  peer?: PeerId;
732
+ /** agent-hub#1036: this connection is a distinct session; `peer` is the id the daemon minted for it (`<peer>~<origin>`), never a bus peer. */
733
+ session?: true;
700
734
  launchId?: string;
701
735
  externalOwner?: { owner: PeerAdapter; launchId: string; generation?: string };
702
736
  toolsOwner?: { owner: PeerAdapter; launchId: string; generation: string; cwd: string };
@@ -742,6 +776,8 @@ function capturedRecoverySourceDigest(): string | undefined {
742
776
  }
743
777
 
744
778
  export async function startDaemon(opts: DaemonOptions) {
779
+ // Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
780
+ assertHubDbCompatible(join(opts.stateDir, "hub.db"));
745
781
  const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
746
782
  resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
747
783
  const projectId = opts.projectId ?? randomUUID();
@@ -805,6 +841,27 @@ export async function startDaemon(opts: DaemonOptions) {
805
841
  const childToolAuthority = new AsyncLocalStorage<{ by: string; current: () => boolean }>();
806
842
  const implementerTranscripts = new Map<string, string>();
807
843
  const executionOwners = new Map<string, { owner: PeerAdapter; cwd: string; socket?: Sock; launchId?: string; generation?: string | number }>();
844
+ /** #604 PR B: envelope id to the connection that sent it, written where a connection builds an envelope and read once when the envelope event is logged. Bounded: an entry whose event never comes ages out. */
845
+ const envelopeOrigins = new Map<string, Origin>();
846
+ /** agent-hub#1036: the ids this daemon minted for live distinct sessions. Only the daemon makes them, so `PEER_ID` is not widened and no `@` reaches the remote-room paths. */
847
+ const sessionIds = new Set<string>();
848
+ const rememberOrigin = (id: string, origin: Origin): void => { envelopeOrigins.set(id, origin); if (envelopeOrigins.size > 1024) envelopeOrigins.delete(envelopeOrigins.keys().next().value as string); };
849
+ const takeOrigin = (id: string): Origin | undefined => { const origin = envelopeOrigins.get(id); envelopeOrigins.delete(id); return origin; };
850
+ /**
851
+ * agent-hub#1036: the origin of an envelope that no control connection sent, one an adapter published itself (a native peer's final answer goes through `peer.onMessage`, not the `send` case).
852
+ * It names the sending peer and, where the adapter knows one, the launch it runs under: a channel session's `managedLaunchId`, an owned Jcode's launch id. The hub's own writers (`hub`, `user`,
853
+ * `digest`) and remote room ids have none, as before. Evidence for the log, never a path or text.
854
+ */
855
+ const adapterOrigin = (env: Envelope): Origin | undefined => {
856
+ if (env.from === "hub" || env.from === USER || env.from === "digest" || env.from.includes("@")) return undefined;
857
+ const peer = bus.peers.get(env.from);
858
+ if (!peer) return undefined;
859
+ // Read by shape, not by a native-branch predicate: a channel session reports `managedLaunchId`, an owned Jcode `ownedLaunchId`, and the value only labels the log. Nothing here decides
860
+ // authority or ownership, so it is not an `isWsPeer` host branch (the #625 ratchet lists those, and this is not one).
861
+ const evidence = peer as { managedLaunchId?: unknown; ownedLaunchId?: unknown };
862
+ const launchId = [evidence.managedLaunchId, evidence.ownedLaunchId].find((value): value is string => typeof value === "string" && value.length > 0);
863
+ return originOf({ role: "peer", peer: env.from, ...(launchId ? { launchId } : {}) });
864
+ };
808
865
  const externalLaunches = new Map<string, { owner: PeerAdapter; options: AcpOptions; launchId: string; binding: ExternalInstanceBinding; workspace: string; generation?: string }>();
809
866
  const externalBindings = new Map<string, ExternalInstanceBinding>();
810
867
  const externalSessions = new Map<string, string>();
@@ -1154,6 +1211,7 @@ export async function startDaemon(opts: DaemonOptions) {
1154
1211
  for (const request of sock.data.invocationRequests?.values() ?? []) request.abort();
1155
1212
  sock.data.invocationRequests?.clear();
1156
1213
  nativeHookLaunches.endOwner(sock);
1214
+ if (sock.data.session && sock.data.peer) sessionIds.delete(sock.data.peer);
1157
1215
  consoles.delete(sock);
1158
1216
  operationalLogSubscribers.delete(sock);
1159
1217
  sock.data.tail?.();
@@ -1353,6 +1411,18 @@ export async function startDaemon(opts: DaemonOptions) {
1353
1411
  onImportantQueued: (peer, counts) => { const p = bus.peers.get(peer); if (isWsPeer(p)) p.notifyQueued(counts); } });
1354
1412
  startupCleanup.push(() => bus.closeJournal());
1355
1413
  const claudeLaunchProof = createClaudeLaunchProof({ stateDir: opts.stateDir, cwd: opts.cwd, instanceId, definitionOf, peer: peer => bus.peers.get(peer) });
1414
+ /**
1415
+ * agent-hub#1036: whether a tools hello is a distinct session instead of the peer it names. True when the plugin asked for it (`session`: it has no project of its own and inherited this hub's
1416
+ * state), or when the name is a Claude channel peer and the hello PRESENTS a launch id that is not the current launch record's. An ABSENT id is never enough: the facts hook, a native
1417
+ * helper or a person's own shell connects as the peer with none, and it must keep working (agent-hub#1036 AC4). Everything else keeps its admission as before: codex, kimi, qwen, agent
1418
+ * shells without an inherited id, owned ACP peers and the Claude implementer child are not affected.
1419
+ */
1420
+ const sessionHello = (msg: Record<string, any>): boolean => {
1421
+ if (typeof msg.session === "string") return true;
1422
+ const peer = String(msg.peer ?? "");
1423
+ if (definitionOf(peer)?.transport !== "claude-channel" || definitionOf(peer)?.start.daemonPort === "claude-implementer") return false;
1424
+ try { const launch = readClaudeLaunchProof(opts.stateDir, peer, instanceId); return !!launch && typeof msg.launchId === "string" && msg.launchId !== launch.launchId; } catch { return false; }
1425
+ };
1356
1426
  function frontendProxyState(peer: string, owner: PeerAdapter | undefined): { present: boolean; port: FrontendProxyPort | undefined } {
1357
1427
  if (!owner || !isFixedBuiltinDefinition(definitionOf(peer)) || !("frontendProxy" in owner)) return { present: false, port: undefined };
1358
1428
  return { present: true, port: owner.frontendProxy };
@@ -2009,6 +2079,12 @@ export async function startDaemon(opts: DaemonOptions) {
2009
2079
  };
2010
2080
  const tasks: Tasks = new Tasks({
2011
2081
  releaseWindow,
2082
+ researchRunners: () => {
2083
+ if (config.research.runners === undefined) return {};
2084
+ const why = configRefusal(opts.cwd, "config.local.json");
2085
+ if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
2086
+ return parseResearchRunners(config.research.runners);
2087
+ },
2012
2088
  capabilities: peerCapabilities, repository: memoryRepo,
2013
2089
  contextAdmission: () => ({
2014
2090
  policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
@@ -2140,6 +2216,8 @@ export async function startDaemon(opts: DaemonOptions) {
2140
2216
  // #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
2141
2217
  intake: { split: (system, user, opts) => inference?.split(system, user, opts) ?? Promise.resolve(undefined), sourceKnown: id => sourceRegistry.get(id) !== undefined,
2142
2218
  partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
2219
+ studySource: id => sourceRegistry.get(id),
2220
+ experimentCheck: run => checkRunner.result(run),
2143
2221
  // The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
2144
2222
  piiScreen: opts.piiScreen ?? ((text) => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
2145
2223
  recordScreen: (record) => event({ type: "pii_screen", ...record }),
@@ -3166,6 +3244,71 @@ export async function startDaemon(opts: DaemonOptions) {
3166
3244
  // commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
3167
3245
  // their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
3168
3246
  let taskOpsInFlight = 0;
3247
+ /**
3248
+ * #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
3249
+ * status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
3250
+ */
3251
+ async function handoffOut(by: PeerId, input: unknown): Promise<{ project: string; task: number; state: string; note?: string }> {
3252
+ const request = parseHandoff(input);
3253
+ const own = request.task === undefined ? undefined : board.get(request.task);
3254
+ if (request.task !== undefined && !own) throw new HandoffRefusal("invalid", `no task #${request.task} in this project to hand off from`);
3255
+ tasks.handoffOutAuthority(by, own);
3256
+ const self = { id: projectId, root: opts.cwd };
3257
+ const target = resolveTarget(request.project, registeredProjects(hubHome()).map(project => ({ id: project.id, root: project.root, stateDir: project.stateDir })), self, canonicalPath);
3258
+ const descriptor = readControl(target.stateDir);
3259
+ checkTarget(target, descriptor && { ...(descriptor.features ? { features: descriptor.features } : {}), ...(descriptor.projectId ? { projectId: descriptor.projectId } : {}) });
3260
+ let client: ControlClient;
3261
+ try { client = await ControlClient.connect(target.stateDir, { role: "console", projectRoot: descriptor!.cwd ?? target.root }, 5000); }
3262
+ catch { throw new HandoffRefusal("not_running", `the hub of ${target.id} did not answer; run \`ahub up\` in ${target.root}, then hand the task off again (handoffs are not queued)`); }
3263
+ try {
3264
+ const { project: _project, task: originTask, ...body } = request;
3265
+ const reply = await client.request({ t: "task_handoff", origin: { projectId, peer: by, ...(originTask === undefined ? {} : { taskId: originTask }) }, request: body }, 30_000);
3266
+ if (!reply.ok) throw new HandoffRefusal("invalid", sanitize(String(reply.error ?? "the target refused the handoff")).slice(0, 300));
3267
+ const remote = Number(reply.task);
3268
+ if (!Number.isSafeInteger(remote) || remote < 1) throw new HandoffRefusal("invalid", "the target answered without a task id");
3269
+ tasks.handoffSent(by, { projectId: target.id }, remote, request.title, request.task);
3270
+ return { project: target.id, task: remote, state: String(reply.state ?? "proposed"), ...(typeof reply.note === "string" && reply.note ? { note: sanitize(reply.note).slice(0, 300) } : {}) };
3271
+ } finally { client.close(); }
3272
+ }
3273
+ /** The head a handoff reports: the owner's `refs.commit`, else the receipt's verified head. Only a full 40-hex SHA crosses; anything else is dropped. */
3274
+ const handoffHead = (task: Task): string | undefined => { const head = task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead; return typeof head === "string" && /^[0-9a-f]{40}$/.test(head) ? head : undefined; };
3275
+ /** #604 TARGET side: tell the origin project's daemon about a state change of a task it handed us. Best effort and never blocking a board write: an origin that is down reads the state later. */
3276
+ const handoffNotifyOrigin = (task: Task, entry: { event: string }): void => {
3277
+ const event: HandoffEvent | undefined = handoffEventFor(entry.event, task.state);
3278
+ const origin = event ? board.handoffOrigin(task.id) : undefined;
3279
+ if (!event || !origin) return;
3280
+ void (async () => {
3281
+ try {
3282
+ const project = registeredProjects(hubHome()).find(candidate => candidate.id === origin.projectId);
3283
+ const descriptor = project && readControl(project.stateDir);
3284
+ if (!project || !descriptor || !descriptor.features?.includes("task_handoff")) return;
3285
+ const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
3286
+ try { await client.request({ t: "task_handoff_event", event: { projectId, remoteTask: task.id, state: event, ...(typeof task.sdlc?.pr === "number" ? { pr: task.sdlc.pr } : {}), ...(handoffHead(task) ? { head: handoffHead(task) } : {}) } }, 5000); }
3287
+ finally { client.close(); }
3288
+ } catch { /* the origin is down or refused: it reads the target's state on its next start */ }
3289
+ })();
3290
+ };
3291
+ /**
3292
+ * #604 ORIGIN side, on start: for every handoff this project made that has not reached a final state, ask the target (if it is running) where the task stands now, and record the answer.
3293
+ * A target that is down is skipped: it is asked again on the next start. Best effort, in the background, never delaying readiness.
3294
+ */
3295
+ const handoffCatchUp = (): void => {
3296
+ void (async () => {
3297
+ const open = board.handoffOutbound().filter(row => !["approved", "cancelled"].includes(row.state));
3298
+ const byProject = new Map<string, number[]>();
3299
+ for (const row of open) byProject.set(row.remoteProject, [...(byProject.get(row.remoteProject) ?? []), row.remoteTask]);
3300
+ for (const [remote, ids] of byProject) {
3301
+ try {
3302
+ const project = registeredProjects(hubHome()).find(candidate => candidate.id === remote);
3303
+ const descriptor = project && readControl(project.stateDir);
3304
+ if (!project || !descriptor?.features?.includes("task_handoff")) continue;
3305
+ const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: descriptor.cwd ?? project.root }, 3000);
3306
+ try { const reply = await client.request({ t: "task_handoff_states", projectId, tasks: ids.slice(0, 200) }, 5000); if (reply.ok && Array.isArray(reply.states)) tasks.handoffCaughtUp(remote, reply.states); }
3307
+ finally { client.close(); }
3308
+ } catch { /* that target is down or refused: it is asked again on the next start */ }
3309
+ }
3310
+ })();
3311
+ };
3169
3312
  const conductedMutations = new Set(["hub_task_propose", "hub_task_accept", "hub_task_decline", "hub_task_done", "hub_review", "hub_checkpoint", "hub_remember"]);
3170
3313
  const taskOp = async (...args: Parameters<typeof taskOpBody>): Promise<string> => {
3171
3314
  const [by, op, input] = args;
@@ -3377,7 +3520,7 @@ export async function startDaemon(opts: DaemonOptions) {
3377
3520
  }
3378
3521
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
3379
3522
  // shared memory and a new task could be routed to a cloud peer without matching any pattern.
3380
- if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3523
+ if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3381
3524
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
3382
3525
  // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
3383
3526
  if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && (a.plan != null || a.refs != null)))) {
@@ -3583,6 +3726,34 @@ export async function startDaemon(opts: DaemonOptions) {
3583
3726
  }
3584
3727
  case "hub_backlog_undo":
3585
3728
  return JSON.stringify(tasks.publicView(await tasks.undoPlanning(by, a.id, a.revision)));
3729
+ // #601: the study tools only call Tasks. Draft and revise are the configured planner's (Tasks checks planningAuthority and the private turn); none of them approves.
3730
+ // The arguments go to Tasks whole, never re-picked field by field: an unknown field is refused there (`studyFields`), not silently dropped here, and MCP schemas are not enforced on the way in.
3731
+ case "hub_study_draft":
3732
+ return JSON.stringify(await tasks.studyDraft(by, a));
3733
+ case "hub_study_revise":
3734
+ return JSON.stringify(await tasks.studyRevise(by, a));
3735
+ case "hub_study_show":
3736
+ return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
3737
+ case "hub_study_handoff":
3738
+ if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
3739
+ return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
3740
+ // #604 (agent-hub#1030): a handoff to another project. Tasks checks who may send it and the PII gates; `handoffOut` resolves the target, asks its daemon and records the answer.
3741
+ case "hub_task_handoff":
3742
+ return JSON.stringify(await handoffOut(by, a));
3743
+ case "hub_task_handoffs":
3744
+ return JSON.stringify({ handoffs: tasks.handoffList() });
3745
+ // #601: the person's own forms (a read is hub_study_show, which every connection may use). `by` is USER only on the console path; approval and close are never a tool.
3746
+ case "study_draft":
3747
+ return JSON.stringify(await tasks.studyDraft(by, a));
3748
+ case "study_revise":
3749
+ return JSON.stringify(await tasks.studyRevise(by, a));
3750
+ case "study_approve":
3751
+ return JSON.stringify(await tasks.studyApprove(by, a));
3752
+ case "study_approve_protocol":
3753
+ return JSON.stringify(await tasks.experimentApproveProtocol(by, a));
3754
+ case "study_close":
3755
+ return JSON.stringify(await tasks.studyClose(by, a));
3756
+
3586
3757
  case "hub_task_propose": {
3587
3758
  const t = await tasks.propose(by, a);
3588
3759
  const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
@@ -3790,7 +3961,7 @@ export async function startDaemon(opts: DaemonOptions) {
3790
3961
  const related = tasks.isPii(task) ? undefined : await taskMemoryResult(task, true).catch(() => ({ items: [], state: "unknown" as const }));
3791
3962
  const fresh = board.get(task.id);
3792
3963
  const relatedMemory = related && fresh?.updated === task.updated && !tasks.isPii(fresh) ? { state: related.state, count: related.items.length, ...("head" in related ? { head: related.head } : {}) } : { state: "unknown" };
3793
- return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
3964
+ return JSON.stringify({ ...detail, ...holderReasons(publicTask), relatedMemory, ...(publicTask.handoffOrigin ? { handoffOrigin: publicTask.handoffOrigin } : {}), ...(codeSymbols ? { codeSymbols } : {}), ...(consoleDeliverables ? { deliverables: consoleDeliverables } : {}), symbolOverlaps: tasks.symbolOverlaps(task), flowRevision: task.history.length, reviews: board.reviews({ task: task.id }) }, null, 2);
3794
3965
  }
3795
3966
  case "deliverables": {
3796
3967
  // #317 (`ahub deliverables`): the board's deliverable records, person only. `check` measures the files now; without it nothing is opened.
@@ -4134,6 +4305,7 @@ export async function startDaemon(opts: DaemonOptions) {
4134
4305
  if (tasks.autoPullsPending() !== 0) return false; // #785 P1c: an automatic backlog pull mid-admission would write after the commit's digest; its own line keeps the line above exact for scripts/seeds.json
4135
4306
  if (tasks.queuePressureRechecksPending() !== 0) return false;
4136
4307
  if (tasks.schedulesPending() !== 0) return false; // #512a: a scheduled run mid-admission (its PII screen is awaited) would write after the commit's digest; the hold fence refuses its write, and this waits for it to end
4308
+ if (tasks.studiesPending() !== 0) return false;
4137
4309
  if (!recoveryPeerSnapshot) return true;
4138
4310
  const current = recoveryPeers();
4139
4311
  return recoveryPeerSnapshot.every((saved) => {
@@ -4265,6 +4437,7 @@ export async function startDaemon(opts: DaemonOptions) {
4265
4437
  const metadata = peer.recoveryMetadata?.() ?? {};
4266
4438
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
4267
4439
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
4440
+ if (typeof metadata.controlProtocol === "number" && Number.isSafeInteger(metadata.controlProtocol) && metadata.controlProtocol > 0) row.controlProtocol = metadata.controlProtocol;
4268
4441
  const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
4269
4442
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
4270
4443
  return [id, row];
@@ -4283,7 +4456,12 @@ export async function startDaemon(opts: DaemonOptions) {
4283
4456
  return row;
4284
4457
  });
4285
4458
  const budgetState = budget.persistedPauseDigestRows().sort((a, b) => a.peer.localeCompare(b.peer));
4286
- return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
4459
+ const research = board.researchIntegrity();
4460
+ const researchDispatch = board.researchDispatchIntegrity();
4461
+ const researchExperiments = board.researchExperimentsIntegrity();
4462
+ const researchComparisons = board.researchComparisonIntegrity();
4463
+ const researchRunners = board.runnerReceiptIntegrity();
4464
+ return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
4287
4465
  };
4288
4466
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
4289
4467
  const integrityAs = (expected: unknown) => {
@@ -4373,7 +4551,7 @@ export async function startDaemon(opts: DaemonOptions) {
4373
4551
  ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
4374
4552
  // Every supported peer's membership, for launchers that run outside the console role (#484).
4375
4553
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
4376
- protocol: PROTOCOL,
4554
+ protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL,
4377
4555
  features: DAEMON_FEATURES,
4378
4556
  configuredAdapters: configuredIds,
4379
4557
  ...(externalAdapters.length ? { externalAdapters } : {}),
@@ -4451,6 +4629,7 @@ export async function startDaemon(opts: DaemonOptions) {
4451
4629
  board.onChange = (task, entry) => {
4452
4630
  try { taskChanged?.(task, entry); }
4453
4631
  finally {
4632
+ try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
4454
4633
  try { settleSupersededDeliveries(); }
4455
4634
  finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
4456
4635
  }
@@ -4946,7 +5125,8 @@ export async function startDaemon(opts: DaemonOptions) {
4946
5125
  } else {
4947
5126
  log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
4948
5127
  const env = e.env;
4949
- event({ type: "envelope", id: env.id, from: env.from, ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, ...(env.kind ? { kind: env.kind } : {}), ...(env.refs?.task ? { task: env.refs.task } : {}), ...(env.private ? {} : { bytes: Buffer.byteLength(env.body) }), ...(env.private ? { private: true } : {}), ...(e.dropped ? { dropped: e.dropped } : {}) });
5128
+ const origin = takeOrigin(env.id) ?? adapterOrigin(env);
5129
+ event({ type: "envelope", id: env.id, from: env.from, ...(origin ? { origin } : {}), ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, ...(env.kind ? { kind: env.kind } : {}), ...(env.refs?.task ? { task: env.refs.task } : {}), ...(env.private ? {} : { bytes: Buffer.byteLength(env.body) }), ...(env.private ? { private: true } : {}), ...(e.dropped ? { dropped: e.dropped } : {}) });
4950
5130
  }
4951
5131
  if (!stopping) writeStatus();
4952
5132
  });
@@ -6567,6 +6747,7 @@ export async function startDaemon(opts: DaemonOptions) {
6567
6747
  // Retired dictionaries never enter active publication; hub ids remain opaque keys.
6568
6748
  if (prefix === "routing.targets" || prefix === "routing.routes") return [];
6569
6749
  if (prefix === "routing.hub_routes") return [prefix];
6750
+ if (prefix === "research.runners") return [prefix];
6570
6751
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
6571
6752
  return prefix ? [prefix] : [];
6572
6753
  }
@@ -7386,6 +7567,7 @@ export async function startDaemon(opts: DaemonOptions) {
7386
7567
  ...(tasks.autoPullsPending() !== 0 ? ["an automatic backlog pull in flight"] : []),
7387
7568
  ...(tasks.queuePressureRechecksPending() !== 0 ? ["a queue-pressure assignment recheck in flight"] : []),
7388
7569
  ...(tasks.schedulesPending() !== 0 ? ["a scheduled task proposal in flight"] : []),
7570
+ ...(tasks.studiesPending() !== 0 ? ["a research campaign admission in flight"] : []),
7389
7571
  ...(checkPending.size !== 0 ? ["isolated checks queued or running"] : []),
7390
7572
  ...(piCallsInFlight() !== 0 ? ["Pi tool calls in flight"] : []),
7391
7573
  ...(releasing ? ["ready tasks being released"] : []),
@@ -7427,6 +7609,44 @@ export async function startDaemon(opts: DaemonOptions) {
7427
7609
  && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_planners"))) return "target lacks task_planners; configured or persisted planner authority cannot be restored by that daemon";
7428
7610
  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";
7429
7611
  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";
7612
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_records")) {
7613
+ if (board.hasResearchState()) return "target lacks research_records; persisted research campaign records require a supporting daemon";
7614
+ let namesRunners = false;
7615
+ try {
7616
+ const texts = configSourceTexts(opts.cwd);
7617
+ for (const name of CONFIG_FILES) {
7618
+ if (texts[name] === null) continue;
7619
+ const raw: unknown = JSON.parse(texts[name]!);
7620
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
7621
+ const research = (raw as Record<string, unknown>).research;
7622
+ if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
7623
+ namesRunners ||= research !== undefined && Object.hasOwn(research, "runners");
7624
+ }
7625
+ } catch { return "research_records compatibility is unknown; configuration could not be read"; }
7626
+ // Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
7627
+ if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
7628
+ }
7629
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_runners")) {
7630
+ if (board.hasRunnerReceipts()) return "target lacks research_runners; persisted runner attestations require a supporting daemon";
7631
+ try {
7632
+ if (config.research.runners !== undefined || rawResearchRunnersPresent(opts.cwd)) return "target lacks research_runners; the project's research.runners requires a supporting daemon";
7633
+ } catch { return "research_runners compatibility is unknown; configuration could not be read"; }
7634
+ }
7635
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
7636
+ return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
7637
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
7638
+ return "target lacks research_dropped_offers; persisted study re-offers require a supporting daemon";
7639
+ // agent-hub#1036: an unsettled delivery that holds a distinct session's message is an envelope with `session: true` and a minted `<peer>~<id>` sender. A target without the feature
7640
+ // would read that sender as a peer it does not know and queue a reply to it; this is its own label, so a daemon that has the older floors but not this one is still refused.
7641
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("session_identity")) && bus.queueList(undefined, true).some(row => (row.originals as Envelope[]).some(env => env.session === true)))
7642
+ return "target lacks session_identity; unsettled deliveries hold messages from distinct sessions that require a supporting daemon";
7643
+ // #604: a handoff row in either direction (a task another project handed us, or one we handed over) needs a target that knows `task_handoff`; without it the origin is silently lost.
7644
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("task_handoff")) && board.hasHandoffState())
7645
+ return "target lacks task_handoff; stored handoffs require a supporting daemon";
7646
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_experiments")) && board.hasResearchExperimentState())
7647
+ return "target lacks research_experiments; persisted experiment records require a supporting daemon";
7648
+ if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_comparisons")) && board.hasResearchComparisonState())
7649
+ return "target lacks research_comparisons; persisted comparison plans, approvals or protocol context require a supporting daemon";
7430
7650
  if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("project_autonomy")) {
7431
7651
  let needsAutonomy = autonomyConfirmed.size > 0;
7432
7652
  try {
@@ -7450,8 +7670,29 @@ export async function startDaemon(opts: DaemonOptions) {
7450
7670
  } catch { return "action_policy_floor compatibility is unknown; configuration could not be read"; }
7451
7671
  if (namesFloor) return "target lacks action_policy_floor; the project's action_policy.floor cannot be loaded by that daemon";
7452
7672
  }
7673
+ // #1017: an older target refuses a `cron` or `tz` key in a `schedules` entry ("cron not supported yet"), so the project would not load there. The raw files decide, as for the floor: a calendar
7674
+ // entry in either file, even one an ordinary reload would reject, names the key; unreadable files are unknown, never absent.
7675
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("calendar_schedules")) {
7676
+ let namesCalendar = config.schedules.some(entry => entry.cron !== undefined || entry.tz !== undefined);
7677
+ try {
7678
+ const texts = configSourceTexts(opts.cwd);
7679
+ namesCalendar ||= CONFIG_FILES.some(name => {
7680
+ const list = texts[name] === null ? undefined : (JSON.parse(texts[name]!) as Record<string, unknown>).schedules;
7681
+ return Array.isArray(list) && list.some(entry => !!entry && typeof entry === "object" && (Object.hasOwn(entry, "cron") || Object.hasOwn(entry, "tz")));
7682
+ });
7683
+ } catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
7684
+ if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
7685
+ }
7686
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("schema_guard"))
7687
+ return "target lacks schema_guard; controlled recovery requires a guard-aware runtime";
7453
7688
  return undefined;
7454
7689
  };
7690
+ // Preserve lifecycle fences before capability probes; the prepare branch rechecks them after resume capture awaits.
7691
+ if (msg.op === "prepare") {
7692
+ if (rosterChanging) return recoveryError("roster change is in progress; wait for ahub status before preparing recovery");
7693
+ if (requestedPeerStops.size) return recoveryError("peer stop is in progress; wait for ahub status before preparing recovery");
7694
+ if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
7695
+ }
7455
7696
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
7456
7697
  if (checkTarget) {
7457
7698
  const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
@@ -7719,7 +7960,8 @@ export async function startDaemon(opts: DaemonOptions) {
7719
7960
  if (!c.authed) {
7720
7961
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7721
7962
  if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
7722
- if (msg.v !== PROTOCOL) {
7963
+ const offered = protocolWindow(msg.v, msg.vMin), selectedProtocol = offered && negotiateProtocol({ v: PROTOCOL, vMin: PROTOCOL_MIN }, offered);
7964
+ if (selectedProtocol === undefined) {
7723
7965
  // An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
7724
7966
  log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
7725
7967
  return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
@@ -7730,14 +7972,25 @@ export async function startDaemon(opts: DaemonOptions) {
7730
7972
  try { refreshConductorPolicy(); } catch { return sock.close(4403, "invalid conductor role/feed configuration"); }
7731
7973
  if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
7732
7974
  c.authed = true;
7975
+ c.protocol = selectedProtocol;
7733
7976
  c.role = msg.role;
7734
7977
  if (c.role === "console") consoles.add(sock);
7978
+ else if (c.role === "tools" && sessionHello(msg)) {
7979
+ // agent-hub#1036: a connection that names a peer id but is not that peer's current session gets an id of its own instead of becoming the peer. It can send and read; it holds none of the
7980
+ // peer's board roles, is never a bus peer (no delivery, no push) and replaces nobody. Nothing is refused: a hello that worked before still works, as a session.
7981
+ const named = String(msg.peer ?? "");
7982
+ if (!PEER_ID.test(named) || named === USER || named === "hub") return sock.close(4403, "peer id is reserved or malformed");
7983
+ c.peer = mintSessionId(named, msg.session, id => sessionIds.has(id) || bus.peers.has(id), () => crypto.randomUUID().slice(0, 6));
7984
+ c.session = true; sessionIds.add(c.peer);
7985
+ if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
7986
+ }
7735
7987
  else if (c.role === "tools") {
7736
7988
  // Acts for a peer the hub manages itself (kimi, codex): may send and use the board as that peer, is never a delivery target.
7737
7989
  c.peer = String(msg.peer ?? "");
7738
7990
  if (!PEER_ID.test(c.peer) || c.peer === USER || c.peer === "hub") return sock.close(4403, "peer id is reserved or malformed");
7739
7991
  if (externalRefusedRows.has(c.peer)) return sock.close(4403, "instance kind is unavailable in the startup catalog");
7740
7992
  if (!membershipAllowed(c.peer)) return sock.close(4000, "peer is off roster; retry when a person adds it");
7993
+ if (typeof msg.launchId === "string" && msg.launchId.length <= 128) c.launchId = msg.launchId;
7741
7994
  if (definitionOf(c.peer)?.start.daemonPort === "claude-implementer") {
7742
7995
  const owner = bus.peers.get(c.peer), launch = owner?.ownedTools?.(msg.launchId), root = ownerWriteRoot(c.peer);
7743
7996
  if (!owner || !launch || launch.signal.aborted || !root || root === opts.cwd || typeof msg.launchId !== "string") return sock.close(4403, "this process is not the current Claude child");
@@ -7804,7 +8057,9 @@ export async function startDaemon(opts: DaemonOptions) {
7804
8057
  if (important) ws.notifyQueued({ queued: bus.queued(c.peer!), important });
7805
8058
  });
7806
8059
  }
7807
- return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES });
8060
+ // #604 PR B: one attach event per admitted peer or tools hello, with the same fields an envelope's `origin` carries. A refused hello returned above and writes none.
8061
+ if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
8062
+ return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: c.protocol, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
7808
8063
  }
7809
8064
  const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
7810
8065
  && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
@@ -7812,6 +8067,11 @@ export async function startDaemon(opts: DaemonOptions) {
7812
8067
  if (c.role !== "console" && c.peer && !membershipAllowed(c.peer)) return void reply({ ok: false, error: `${c.peer} is off roster` });
7813
8068
  if (c.externalOwner && !currentExternalTools(c)) return void reply({ ok: false, error: "external instance launch or execution root changed" });
7814
8069
  if (c.toolsOwner && !currentImplementerTools(c)) return void reply({ ok: false, error: "Claude child generation or execution root changed" });
8070
+ if (c.session) {
8071
+ // agent-hub#1036: a distinct session sends and reads, and nothing else. The gate runs before any case, so a new control message is refused to a session unless it is added to the list.
8072
+ const refusal = sessionGate(c.peer!, msg);
8073
+ if (refusal) return void reply({ t: msg.t, ok: false, error: refusal });
8074
+ }
7815
8075
  switch (msg.t) {
7816
8076
  case "request_cancel": {
7817
8077
  if (!Number.isSafeInteger(msg.request) || msg.request < 1) return void reply({ t: "request_cancel", ok: false, error: "invalid request id" });
@@ -8068,11 +8328,15 @@ export async function startDaemon(opts: DaemonOptions) {
8068
8328
  const plan = msg.plan as UpgradePlan;
8069
8329
  const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
8070
8330
  if (!plan || !["restart", "upgrade"].includes(plan.kind) || plan.projects.some(row => row.terminals.length > 0) || planFingerprint(body) !== fingerprint || fingerprint !== raw.planFingerprint || plan.kind !== raw.kind || existsSync(operationPath(raw.operationId))
8071
- || raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock() || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
8331
+ || raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock(undefined, opts.cwd) || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
8332
+ const { recoveryScope } = await import("../cli/upgrade.ts"); current();
8333
+ const scope = recoveryScope(plan);
8334
+ if (scope !== undefined && scope !== opts.cwd) throw new RecoveryAuthorityError();
8072
8335
  if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
8073
8336
  if (plan.kind === "upgrade") {
8074
- // Shared package/plugin effects cover every running or uncertain registered project, not a caller-selected subset.
8337
+ // Scoped upgrades bind the source's one registered project; retained shared upgrades still cover every affected project.
8075
8338
  const affected = registeredProjects().filter(project => {
8339
+ if (scope !== undefined) return project.root === scope;
8076
8340
  const control = readControl(project.stateDir);
8077
8341
  return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
8078
8342
  });
@@ -8085,23 +8349,26 @@ export async function startDaemon(opts: DaemonOptions) {
8085
8349
  if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
8086
8350
  for (const id of selected.reconnectOnly) {
8087
8351
  const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
8088
- if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation"].includes(key)) || !row.observation
8352
+ if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation", "controlProtocol"].includes(key)) || !row.observation
8089
8353
  || Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
8090
8354
  || !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
8355
+ || !protocolWindow(row.controlProtocol) || owner.recoveryMetadata().controlProtocol !== row.controlProtocol
8091
8356
  || row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
8092
8357
  const generation = owner.sessionGeneration;
8093
8358
  const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
8094
- if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)) throw new RecoveryAuthorityError();
8359
+ if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
8360
+ || owner.recoveryMetadata().controlProtocol !== row.controlProtocol) throw new RecoveryAuthorityError();
8095
8361
  reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
8362
+ && owner.recoveryMetadata().controlProtocol === row.controlProtocol
8096
8363
  && definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
8097
8364
  reconnect.set(id, row.observation);
8098
8365
  }
8099
8366
  }
8100
8367
  if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
8101
- const normalize = (row: { id: string; state: string; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
8368
+ const normalize = (row: { id: string; state: string; controlProtocol?: number; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
8102
8369
  const launch = row.args ?? row.launch ?? {};
8103
8370
  const observed = reconnect.get(row.id);
8104
- return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
8371
+ return { id: row.id, active: row.state !== "offline", controlProtocol: row.controlProtocol, threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
8105
8372
  ...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
8106
8373
  ...(observed ? { kind: row.observation?.kind ?? definitionOf(row.id)?.kind, attached: row.observation?.attached ?? (isWsPeer(bus.peers.get(row.id)) && (bus.peers.get(row.id) as import("../adapters/channel-peer.ts").WsPeer).attached) } : {}),
8107
8374
  sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
@@ -8115,9 +8382,11 @@ export async function startDaemon(opts: DaemonOptions) {
8115
8382
  if (raw.kind === "restart") {
8116
8383
  if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
8117
8384
  } else {
8118
- const staged = join(hubHome(), "releases", plan.version);
8119
- if (raw.targetRoot !== join(staged, "node_modules/@staix/agent-hub")) throw new RecoveryAuthorityError();
8120
- const verified = JSON.parse(readFileSync(join(staged, "verified.json"), "utf8"));
8385
+ const staged = join(scope === undefined ? hubHome() : realPath(hubHome()), "releases", plan.version);
8386
+ const expectedTarget = scope === undefined ? join(staged, "node_modules/@staix/agent-hub") : join(staged, `source-${raw.targetDigest}`);
8387
+ if (raw.targetRoot !== expectedTarget) throw new RecoveryAuthorityError();
8388
+ // A new/expired grant still needs registry proof; verification of an already issued grant below uses only retained target bytes.
8389
+ const verified = JSON.parse(readFileSync(join(scope === undefined ? staged : join(staged, "registry"), "verified.json"), "utf8"));
8121
8390
  if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
8122
8391
  }
8123
8392
  if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
@@ -8184,6 +8453,8 @@ export async function startDaemon(opts: DaemonOptions) {
8184
8453
  }
8185
8454
  // Built first: limits count the audience it really has (a reply goes to the parent's sender).
8186
8455
  let env = newEnvelope(c.peer ?? USER, body, { priority, ...(to ? { to } : {}), ...(inReplyTo ? { inReplyTo } : {}) });
8456
+ if (c.session) env = { ...env, session: true }; // agent-hub#1036: sent by a distinct session; `from` is its minted id, and the header a recipient reads shows it as is
8457
+ rememberOrigin(env.id, originOf({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) })); // the connection that sent it, for events.jsonl (never a path or text)
8187
8458
  try { bus.checkRemotePublication(env); }
8188
8459
  catch (error) { return void reply({ t: "sent", ok: false, error: error instanceof Error && /^(?:room|remote) /.test(error.message) ? sanitize(error.message).slice(0, 8192) : "message admission unavailable" }); }
8189
8460
  const offRoster = bus.offRosterRecipients(env);
@@ -8802,6 +9073,8 @@ export async function startDaemon(opts: DaemonOptions) {
8802
9073
  try { const current = publicReadCurrent(sock); current(); const release = publicReleaseShow(msg.args ?? {}); current(); return void reply({ t: "task", ok: true, text: JSON.stringify(release) }); }
8803
9074
  catch { return void reply({ t: "task", ok: false, error: "public release read unavailable" }); }
8804
9075
  }
9076
+ // #601: ahub study is the person's. A tools connection (any agent) may use the hub_study_* tools, never these op names, whatever Tasks would answer.
9077
+ if (String(msg.op).startsWith("study_") && c.role !== "console") return void reply({ t: "task", ok: false, error: "ahub study draft, revise, approve and close are console commands; agents use the hub_study_* tools" });
8805
9078
  if (c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(String(msg.op))) return void reply({ t: "task", ok: false, error: "tool is not available to this Claude implementer child" });
8806
9079
  if (recoveryActive() && recoveryPhase !== "preparing" && String(msg.op) !== "hub_checkpoint") return void reply({ t: "task", ok: false, error: "recovery is holding mutations" });
8807
9080
  if (c.role === "tools" && c.peer && Object.hasOwn(CONDUCTOR_TASK_OPS, String(msg.op))) {
@@ -8973,6 +9246,32 @@ export async function startDaemon(opts: DaemonOptions) {
8973
9246
  // Let the acknowledgement flush before closing the control listener.
8974
9247
  setTimeout(() => void stop().catch((error) => log(`shutdown incomplete: ${(error as Error).message}`)), 0);
8975
9248
  return;
9249
+ // #604 (agent-hub#1030): another project's daemon hands us a task, or reports a state change of one we handed it. Console role only (the same token and the same machine user as every
9250
+ // control message): an agent connection never reaches these. The origin peer named in the request is a record, never an identity here, and nothing in it grants a role.
9251
+ case "task_handoff": {
9252
+ if (c.role !== "console") return void reply({ t: "task_handoff", ok: false, error: "a handoff comes from another project's hub, not from an agent connection" });
9253
+ if (recoveryActive()) return void reply({ t: "task_handoff", ok: false, error: "recovery is holding mutations; hand the task off again after it ends" });
9254
+ // Counted like a task op: a recovery commit waits for it (`recoveryReady` reads `taskOpsInFlight`), and the fence is asked again right before the board write, after the PII screen.
9255
+ taskOpsInFlight++;
9256
+ const recoveryFence = (): string | undefined => recoveryActive() || stopping ? "recovery or shutdown is holding mutations; hand the task off again after it ends" : undefined;
9257
+ void tasks.handoffIn(msg.origin ?? {}, msg.request, recoveryFence).then(
9258
+ result => reply({ t: "task_handoff", ok: true, ...result }),
9259
+ error => reply({ t: "task_handoff", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff was refused" })).finally(() => { taskOpsInFlight--; });
9260
+ return;
9261
+ }
9262
+ case "task_handoff_event": {
9263
+ if (c.role !== "console") return void reply({ t: "task_handoff_event", ok: false, error: "a handoff event comes from another project's hub, not from an agent connection" });
9264
+ try { reply({ t: "task_handoff_event", ok: true, ...tasks.handoffEvent(msg.event) }); }
9265
+ catch (error) { reply({ t: "task_handoff_event", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff event was refused" }); }
9266
+ return;
9267
+ }
9268
+ case "task_handoff_states": {
9269
+ // #604: the origin asks, after a restart, where the tasks it handed us stand now. Console role only, and only for tasks whose recorded origin is the asking project.
9270
+ if (c.role !== "console") return void reply({ t: "task_handoff_states", ok: false, error: "a handoff state request comes from another project's hub, not from an agent connection" });
9271
+ try { reply({ t: "task_handoff_states", ok: true, states: tasks.handoffStatesFor(msg.projectId, msg.tasks) }); }
9272
+ catch (error) { reply({ t: "task_handoff_states", ok: false, error: error instanceof HandoffRefusal ? error.message : "the handoff state request was refused" }); }
9273
+ return;
9274
+ }
8976
9275
  default:
8977
9276
  // A newer CLI talking to an older hub must get an answer, not wait forever.
8978
9277
  if (msg.rid !== undefined) reply({ t: String(msg.t), ok: false, error: `this hub does not know "${msg.t}" (restart it: ahub kill && ahub up)` });
@@ -8991,6 +9290,7 @@ export async function startDaemon(opts: DaemonOptions) {
8991
9290
  stopping = true;
8992
9291
  roomCommands.stop();
8993
9292
  tasks.stopQueuePressureRechecks();
9293
+ const studiesStopped = tasks.stopStudies();
8994
9294
  let implementerShellCleanupFailed = false;
8995
9295
  const implementerShellStopped = implementerShellStart?.then(wrapper => wrapper.close()).catch(() => { implementerShellCleanupFailed = true; log("host shell cleanup failed during shutdown"); });
8996
9296
  terminalLaunches.clear();
@@ -9013,11 +9313,11 @@ export async function startDaemon(opts: DaemonOptions) {
9013
9313
  for (const pending of checkpointWaits.values()) pending.done(undefined);
9014
9314
  for (const p of permissions.values()) p.done(undefined);
9015
9315
  try {
9016
- // A schedules pass may be inside its PII screen: `stopping` is set, so its fence refuses the write when the screen returns. Wait for it (bounded, the screen has
9017
- // its own timeout) before the peers stop, the board closes and the store closes, so no pass runs against a closed database.
9018
- if (schedulesPass || manualSchedulePasses.size) {
9316
+ // A schedule or study admission may be inside its PII screen: `stopping` is set, so its fence refuses the write when the screen returns. Wait for it (bounded, the screen has
9317
+ // its own timeout) before the peers stop, the board closes and the store closes, so no admission runs against a closed database.
9318
+ if (schedulesPass || manualSchedulePasses.size || tasks.studiesPending() !== 0) {
9019
9319
  let timer: ReturnType<typeof setTimeout> | undefined;
9020
- await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
9320
+ await Promise.race([Promise.all([schedulesPass?.catch(() => undefined), ...manualSchedulePasses, studiesStopped]), new Promise<void>(resolve => { timer = setTimeout(resolve, 5_000); timer.unref?.(); })]);
9021
9321
  clearTimeout(timer);
9022
9322
  }
9023
9323
  await Promise.all([terminalReadsStopped, codeFactsStopped, implementerShellStopped]);
@@ -9087,6 +9387,7 @@ export async function startDaemon(opts: DaemonOptions) {
9087
9387
  bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
9088
9388
  log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
9089
9389
  ready = true;
9390
+ handoffCatchUp();
9090
9391
  tasks.scheduleQueuePressureRecheck();
9091
9392
  for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
9092
9393
  const path = join(opts.cwd, ".agenthub", name);