@staix/agent-hub 0.25.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 (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -5
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -4
  6. package/docs/agent-notes/tasks.md +5 -4
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +4 -0
  9. package/docs/operations.md +131 -14
  10. package/docs/quickstart.md +14 -4
  11. package/docs/security.md +51 -8
  12. package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
  13. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  14. package/docs/verified.json +28 -16
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +182 -95
  18. package/src/adapters/channel-peer.ts +4 -2
  19. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  20. package/src/adapters/definitions/qwen.ts +2 -0
  21. package/src/adapters/registry-types.ts +2 -0
  22. package/src/adapters/registry.ts +1 -0
  23. package/src/cli/conductor-recovery.ts +3 -2
  24. package/src/cli/help.ts +7 -7
  25. package/src/cli/main.ts +62 -23
  26. package/src/cli/recovery-package.ts +94 -4
  27. package/src/cli/reset.ts +14 -4
  28. package/src/cli/runtime-version-warning.ts +28 -0
  29. package/src/cli/start-agents-run.ts +125 -0
  30. package/src/cli/task-output.ts +5 -0
  31. package/src/cli/upgrade-interactive.ts +4 -4
  32. package/src/cli/upgrade-runtime.ts +105 -57
  33. package/src/cli/upgrade.ts +90 -28
  34. package/src/hub/autonomy-defaults.ts +32 -7
  35. package/src/hub/board.ts +129 -13
  36. package/src/hub/budget.ts +3 -4
  37. package/src/hub/child-process.ts +4 -3
  38. package/src/hub/conductor.ts +3 -4
  39. package/src/hub/config-apply.ts +13 -2
  40. package/src/hub/config-edit-host.ts +1 -0
  41. package/src/hub/config-tier.ts +3 -0
  42. package/src/hub/config-trust.ts +10 -2
  43. package/src/hub/control-client.ts +30 -10
  44. package/src/hub/daemon.ts +82 -17
  45. package/src/hub/db-schema.ts +143 -0
  46. package/src/hub/delivery-journal.ts +3 -4
  47. package/src/hub/execution-budget.ts +3 -4
  48. package/src/hub/folder-trigger.ts +3 -4
  49. package/src/hub/invocations.ts +3 -4
  50. package/src/hub/lifecycle.ts +87 -29
  51. package/src/hub/manager.ts +49 -33
  52. package/src/hub/orca-env.ts +19 -0
  53. package/src/hub/project-runtime-state.ts +139 -0
  54. package/src/hub/project-runtime.ts +62 -0
  55. package/src/hub/protocol-window.ts +19 -0
  56. package/src/hub/recovery-store.ts +102 -12
  57. package/src/hub/registry.ts +5 -0
  58. package/src/hub/research-experiments.ts +33 -4
  59. package/src/hub/research-runner.ts +172 -0
  60. package/src/hub/restart.ts +9 -0
  61. package/src/hub/runtime-capture-prune.ts +415 -0
  62. package/src/hub/runtime-prune-health.ts +112 -0
  63. package/src/hub/schedules.ts +3 -4
  64. package/src/hub/sdlc-merge.ts +7 -0
  65. package/src/hub/sdlc-queue.ts +13 -1
  66. package/src/hub/settings.ts +4 -0
  67. package/src/hub/snapshots.ts +3 -4
  68. package/src/hub/start-agents.ts +44 -0
  69. package/src/hub/task-approval.ts +23 -0
  70. package/src/hub/tasks.ts +133 -34
  71. package/src/hub/usage-rollup.ts +3 -4
  72. package/src/pi/tool-receipts.ts +3 -4
package/src/hub/budget.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { definitionOf, type AdapterDefinition } from "../adapters/registry.ts";
2
- import { Database } from "bun:sqlite";
2
+ import type { Database } from "./db-schema.ts";
3
+ import { openHubDatabase } from "./db-schema.ts";
3
4
  import type { PeerId } from "./envelope.ts";
4
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
5
5
  import { windowKind, type WindowKind } from "./window-kind.ts";
6
6
  export { windowKind, type WindowKind };
7
7
 
@@ -136,8 +136,7 @@ export class Budget {
136
136
  private readonly hooks: BudgetHooks,
137
137
  private readonly now: () => number = Date.now,
138
138
  ) {
139
- this.db = new Database(dbPath, { create: true });
140
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
139
+ this.db = openHubDatabase(dbPath, { create: true });
141
140
  this.db.run(`CREATE TABLE IF NOT EXISTS budget_pauses (peer TEXT PRIMARY KEY, since INTEGER NOT NULL, resets_at INTEGER NOT NULL,
142
141
  reason TEXT NOT NULL, summary TEXT, handed_off INTEGER NOT NULL DEFAULT 0, moved TEXT NOT NULL DEFAULT '[]')`);
143
142
  try { this.normalizeStoredPauses(); } catch (error) { this.db.close(); throw error; }
@@ -1,4 +1,5 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
+ import { withoutOrcaEnv } from "./orca-env.ts";
2
3
 
3
4
  /**
4
5
  * Recovery authority belongs to the short-lived ahub wrapper/launcher. Native
@@ -19,9 +20,9 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
19
20
  * typed or started there has the console's identity, whatever started the hub.
20
21
  */
21
22
  export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
22
- const env = childEnv(source);
23
- // The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own.
24
- for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]) delete env[name];
23
+ // The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own, so every ORCA_ variable goes (#613).
24
+ const env = withoutOrcaEnv(childEnv(source));
25
+ for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"]) delete env[name];
25
26
  return env;
26
27
  }
27
28
 
@@ -1,5 +1,6 @@
1
1
  import { definitionOf, type KindMap, type ProjectAdapterCatalog } from "../adapters/registry.ts";
2
- import { Database } from "bun:sqlite";
2
+ import type { Database } from "./db-schema.ts";
3
+ import { openHubDatabase } from "./db-schema.ts";
3
4
  import type { Task } from "./board.ts";
4
5
  import { CONDUCTOR_TOOL_NAMES } from "./hub-tools.ts";
5
6
  import { HUB, sanitize, USER } from "./envelope.ts";
@@ -11,7 +12,6 @@ import type { SupervisionFeed } from "./supervision.ts";
11
12
  import { DOING_STUB, type PeerDoing } from "./peer-doing.ts";
12
13
  import type { Budget } from "./budget.ts";
13
14
  import { windowKind, type WindowKind } from "./window-kind.ts";
14
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
15
15
  import { supportsHeadlessInstances } from "./instances.ts";
16
16
 
17
17
  /** The production ProgressObserver sink forwards structured verdicts, never its observations/reasoning. */
@@ -72,8 +72,7 @@ export interface ConductorHold { peer: string; actor: string; since: number }
72
72
  export class ConductorHolds {
73
73
  private readonly db: Database;
74
74
  constructor(dbPath: string, private readonly now: () => number = Date.now) {
75
- this.db = new Database(dbPath, { create: true });
76
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
75
+ this.db = openHubDatabase(dbPath, { create: true });
77
76
  this.db.run("CREATE TABLE IF NOT EXISTS conductor_holds (peer TEXT PRIMARY KEY, actor TEXT NOT NULL, since INTEGER NOT NULL)");
78
77
  this.db.run("CREATE TABLE IF NOT EXISTS supervision_rounds (peer TEXT PRIMARY KEY, signature TEXT NOT NULL)");
79
78
  }
@@ -1,4 +1,5 @@
1
1
  import { operationalNumber, OPERATIONAL_NUMBERS } from "./operational-settings.ts";
2
+ import { parseResearchRunners } from "./research-runner.ts";
2
3
  import { QUEUE_BACKPRESSURE_LIMITS } from "./queue-backpressure.ts";
3
4
  import { operationalSettingKey } from "./operational-authority.ts";
4
5
  import { remoteRunnerConfig } from "./check-runner.ts";
@@ -14,6 +15,7 @@ import { createProjectAdapterCatalog, type ProjectAdapterCatalog, ADAPTERS, DEFA
14
15
  import { CLASSES } from "./board.ts";
15
16
  import { permissionDefaults } from "./permission-mode.ts";
16
17
  import { peerStartConfig, terminalTemplate } from "./start-mode.ts";
18
+ import { checkStartAgents } from "./start-agents.ts";
17
19
  import { catalogAssignments } from "./roles.ts";
18
20
  import { resolveProfile } from "./profiles.ts";
19
21
  import { foldersConfig, schedulesConfig } from "./schedules.ts";
@@ -62,6 +64,12 @@ export interface ConfigAuthorizationDecision { key: string; allowed: boolean; gu
62
64
  export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuthorizationDecision {
63
65
  const { key, before, after } = input;
64
66
  const result = (allowed: boolean, guarded: boolean, reason: string): ConfigAuthorizationDecision => ({ key, allowed, guarded, reason });
67
+ const runners = key === "research.runners" || key.startsWith("research.runners.") || key === "research"
68
+ && [before, after].some(value => !!value && typeof value === "object" && Object.hasOwn(value, "runners"));
69
+ if (runners) {
70
+ if (input.source !== "config" || input.sourceFile !== "config.local.json" || input.tracked !== false) return result(false, true, "research.runners requires untracked config.local.json");
71
+ if (input.authority === "conductor") return result(false, true, "runner registration is person only");
72
+ }
65
73
  if (key === "action_policy" || key.startsWith("action_policy.")) {
66
74
  if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
67
75
  if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
@@ -175,6 +183,8 @@ export const validatePetConfig: Check = (v, k) => {
175
183
  };
176
184
  shapes.set(validatePetConfig, { fields: petFields });
177
185
  const nativeValidators = { bool, text, strings, argv, number, words, block };
186
+ const researchRunners: Check = value => { parseResearchRunners(value); };
187
+ shapes.set(researchRunners, { item: block({ publicKey: text, projects: strings, revoked: bool }) });
178
188
  const schema: Record<string, Check> = {
179
189
  _autonomy: v => { autonomyOwnership(v); },
180
190
  ...Object.assign({}, ...ADAPTERS.map(adapter => adapter.configuration?.schema?.(nativeValidators) ?? {})),
@@ -214,9 +224,10 @@ const schema: Record<string, Check> = {
214
224
  else if (key === "remote") remoteRunnerConfig(value);
215
225
  else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
216
226
  } },
217
- snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
227
+ snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool, runners: researchRunners }), bench: block({ enabled: bool }),
218
228
  peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
219
229
  terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
230
+ start: (v, k) => { block({ agents: strings })(v, k); if (object(v, k).agents !== undefined) checkStartAgents(object(v, k).agents, `${k}.agents`); },
220
231
  limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
221
232
  review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool, reconnect_wait_s: operationalNumberCheck("recovery.reconnect_wait_s"), unmanaged_reconnect_wait_s: operationalNumberCheck("recovery.unmanaged_reconnect_wait_s"), control_timeout_s: operationalNumberCheck("recovery.control_timeout_s"), shutdown_wait_s: operationalNumberCheck("recovery.shutdown_wait_s"), terminal_idle_wait_s: operationalNumberCheck("recovery.terminal_idle_wait_s") }),
222
233
  capabilities: map(strings), invocation: block({ share: map(map(() => {})), timeout_s: number(10, 86_400, true) }), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
@@ -269,7 +280,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
269
280
  if (top !== "routing" && top !== "permission" && key.includes(".")) {
270
281
  const special: Record<string, readonly string[]> = {
271
282
  task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
272
- checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"],
283
+ checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"], start: ["agents"],
273
284
  };
274
285
  let check = schema[top];
275
286
  for (const [index, part] of key.split(".").slice(1).entries()) {
@@ -160,6 +160,7 @@ export function createConfigEditor(host: ConfigEditHost) {
160
160
  function leafKeys(plan: Extract<ConfigEditPlan, { ok: true }>): string[] {
161
161
  const out = new Set<string>();
162
162
  const walk = (prefix: string, before: unknown, after: unknown): void => {
163
+ if (prefix === "research.runners") { out.add(prefix); return; }
163
164
  const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
164
165
  if (object(before) || object(after)) {
165
166
  const old = object(before) ? before : {}, next = object(after) ? after : {};
@@ -41,6 +41,9 @@ export function configTier(file: ConfigEditFile, path: readonly string[], value?
41
41
  const key = path.join(".");
42
42
  if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
43
43
  if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
44
+ if (path[0] === "research" && path[1] === "runners") {
45
+ return path.length === 2 ? { tier: 2 } : refuse("research.runners: edit the whole registration block");
46
+ }
44
47
  if (path[0] === "instances") {
45
48
  if (path.length === 1) return refuse("instances: name one instance (instances.<id>)");
46
49
  if (!PEER.test(path[1]!)) return refuse(`instances.${path[1]}: an instance id is lower-case letters, digits and hyphens`);
@@ -36,7 +36,9 @@ export const MACHINE_LOCAL = [
36
36
  "invocation", // #445 C1: what another agent may be offered from this machine's agents (a digest-pinned allowlist); a committed config may not share a person's agents' commands with someone else's peers
37
37
  ] as const;
38
38
  /** Legacy exported field list stays compatible; new policies use the complete list below. */
39
- export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
39
+ export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "research.runners", "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "start.agents", "adapters", "adapter_modules", "text_projection", "instances"] as const;
40
+ /** An empty list of these is a value (`start.agents` empty starts no agent), not "the default", so the empty-value drop below skips them. */
41
+ const EMPTY_IS_VALUE: readonly string[] = ["start.agents"];
40
42
 
41
43
  /** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
42
44
  export function configRepositoryPresent(cwd: string): boolean {
@@ -102,11 +104,17 @@ const drop = (file: Record<string, unknown>, path: string) => {
102
104
  * once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
103
105
  */
104
106
  export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
107
+ const research = file.research;
108
+ if (research && typeof research === "object" && Object.hasOwn(research, "runners")) {
109
+ if (name !== "config.local.json") throw new Error("research.runners requires untracked config.local.json");
110
+ const why = configRefusal(cwd, name);
111
+ if (why) throw new Error(`research.runners requires untracked config.local.json: ${why}`);
112
+ }
105
113
  if (name !== "config.local.json") delete file.rooms;
106
114
  const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
107
115
  if (localOnly) delete file.action_policy;
108
116
  const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
109
- for (const p of ALL_MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
117
+ for (const p of ALL_MACHINE_LOCAL) if (!EMPTY_IS_VALUE.includes(p) && valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
110
118
  const set = ALL_MACHINE_LOCAL.filter((p) => {
111
119
  const v = valueAt(file, p);
112
120
  return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
@@ -2,16 +2,27 @@ import { readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { projectContext } from "./project.ts";
4
4
  import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
5
+ import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
5
6
 
6
7
  export function stateDirFor(cwd: string): string {
7
8
  return projectContext(cwd).stateDir;
8
9
  }
9
10
 
10
11
  /** 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. */
11
- export const PROTOCOL = 16;
12
- export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "task_handoff", "session_identity"] as const;
12
+ export const PROTOCOL = 17;
13
+ /** The descriptor's legacy protocol field remains this floor for previous published clients. */
14
+ export const PROTOCOL_MIN = 16;
15
+ export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard"] as const;
13
16
  /** Protocols a current coordinator may authenticate while upgrading a running source. */
14
- export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
17
+ export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
18
+
19
+ /** Modern descriptors advertise their maximum separately; old descriptors represent singleton windows. */
20
+ export function controlProtocolWindow(descriptor: { protocol?: unknown; protocolMin?: unknown; protocolMax?: unknown }): ProtocolWindow | undefined {
21
+ const legacy = protocolWindow(descriptor.protocol);
22
+ const window = protocolWindow(descriptor.protocolMax ?? descriptor.protocol, descriptor.protocolMin ?? descriptor.protocol);
23
+ if (!legacy || !window || legacy.v < window.vMin || legacy.v > window.v) return undefined;
24
+ return window;
25
+ }
15
26
 
16
27
  /** Only a launch retries transient transport failures. Refusals and identity mismatches
17
28
  * remain terminal; every attempt rereads the descriptor and applies the instance pin. */
@@ -62,6 +73,8 @@ export interface ControlDescriptor {
62
73
  instanceId?: string;
63
74
  cwd?: string;
64
75
  protocol?: number;
76
+ protocolMin?: number;
77
+ protocolMax?: number;
65
78
  pid?: number;
66
79
  features?: readonly string[];
67
80
  /** The daemon's accepted native start modes, used by launchers when the file value is retained. */
@@ -81,7 +94,7 @@ export function readControl(stateDir: string): ControlDescriptor | undefined {
81
94
  ? Object.fromEntries(Object.entries(status.startModes).filter((entry): entry is [string, "tui" | "headless"] => entry[1] === "tui" || entry[1] === "headless"))
82
95
  : undefined;
83
96
  return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
84
- 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") : [],
97
+ instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, protocolMin: status.protocolMin, protocolMax: status.protocolMax, pid: status.pid, features: Array.isArray(status.features) ? status.features.filter((feature: unknown): feature is string => typeof feature === "string") : [],
85
98
  ...(startModes ? { startModes } : {}),
86
99
  ...(status.externalAdapters !== undefined ? { externalAdapters: externalAdapterMetadata(status.externalAdapters) } : {}),
87
100
  ...(typeof status.pidSignature === "string" && status.pidSignature ? { pidSignature: status.pidSignature } : {}) };
@@ -105,6 +118,8 @@ export class ControlClient {
105
118
  features: readonly string[] = [];
106
119
  /** The id the hub minted for a distinct session (agent-hub#1036); absent for every ordinary connection. */
107
120
  sessionId?: string;
121
+ /** Selected wire version, checked against the welcome when that legacy-optional field is present. */
122
+ protocol?: number;
108
123
  private welcomeInstanceId?: string;
109
124
  /** Identity from the authenticated welcome, never the unauthenticated descriptor alone. */
110
125
  get instanceId(): string | undefined { return this.welcomeInstanceId; }
@@ -128,17 +143,20 @@ export class ControlClient {
128
143
  }
129
144
 
130
145
  /** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
131
- static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol = PROTOCOL): Promise<ControlClient> {
132
- if (!RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
146
+ static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol?: number): Promise<ControlClient> {
147
+ if (protocol !== undefined && !RECOVERY_SOURCE_PROTOCOLS.includes(protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
133
148
  const control = readControl(stateDir);
134
149
  if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
135
150
  const pin = instancePins.get(resolve(stateDir));
136
151
  if (pin && (control.instanceId !== pin.instanceId || hello.instanceId !== undefined && hello.instanceId !== pin.instanceId)) {
137
152
  return Promise.reject(new Error("delegated audit consumer changed; refusing a connection to its replacement"));
138
153
  }
139
- if (control.protocol !== undefined && control.protocol !== protocol) {
140
- return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${protocol}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
141
- }
154
+ const local = protocolWindow(protocol ?? PROTOCOL, protocol ?? PROTOCOL_MIN)!;
155
+ const versioned = control.protocol !== undefined || control.protocolMin !== undefined || control.protocolMax !== undefined;
156
+ const remote = versioned ? controlProtocolWindow(control) : undefined;
157
+ const selected = versioned ? remote && negotiateProtocol(local, remote) : protocol ?? PROTOCOL_MIN;
158
+ const mismatch = () => Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocolMax ?? control.protocol}, CLI speaks ${protocol ?? PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 });
159
+ if (selected === undefined) return Promise.reject(mismatch());
142
160
  if ((hello.projectId && hello.projectId !== control.projectId) ||
143
161
  (hello.instanceId && hello.instanceId !== control.instanceId) ||
144
162
  (hello.projectRoot && hello.projectRoot !== control.cwd)) {
@@ -169,13 +187,15 @@ export class ControlClient {
169
187
  client.pending.delete(msg.rid);
170
188
  done(msg);
171
189
  };
172
- ws.onopen = () => void client.request({ t: "hello", v: protocol, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
190
+ ws.onopen = () => void client.request({ t: "hello", token: control.token, ...hello, ...expected, v: selected, vMin: remote?.vMin === undefined ? selected : Math.max(local.vMin, remote.vMin) }, timeoutMs).then((reply) => {
173
191
  if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
174
192
  if ((expected.projectId && reply.projectId !== expected.projectId) ||
175
193
  (expected.instanceId && reply.instanceId !== expected.instanceId) ||
176
194
  (expected.projectRoot && reply.cwd !== expected.projectRoot)) {
177
195
  return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
178
196
  }
197
+ if (reply.protocol !== undefined && reply.protocol !== selected) return refuse(mismatch());
198
+ client.protocol = selected;
179
199
  client.features = Object.freeze(Array.isArray(reply.features) ? reply.features.filter((feature: unknown): feature is string => typeof feature === "string") : []);
180
200
  client.welcomeInstanceId = typeof reply.instanceId === "string" && reply.instanceId ? reply.instanceId : undefined;
181
201
  client.sessionId = typeof reply.session === "string" && reply.session.length <= 64 ? reply.session : undefined;
package/src/hub/daemon.ts CHANGED
@@ -174,9 +174,11 @@ import { githubOriginRepository, githubRemoteRepository } from "./task-repositor
174
174
  import { Bus } from "./bus.ts";
175
175
  import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
176
176
  import { DeliveryJournal } from "./delivery-journal.ts";
177
+ import { assertHubDbCompatible } from "./db-schema.ts";
177
178
  import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
178
179
  import { codeGroup } from "./task-code-group.ts";
179
- import { ControlClient, 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";
180
182
  import { attachEvent, originOf, type Origin } from "./provenance.ts";
181
183
  import { mintSessionId, sessionGate } from "./session-identity.ts";
182
184
  import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
@@ -185,6 +187,7 @@ import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerD
185
187
  import { MemoryClient } from "../memory/client.ts";
186
188
  import { VERSION } from "../version.ts";
187
189
  import { appendRecords, taskRecords } from "./research.ts";
190
+ import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
188
191
  import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
189
192
  import { projectChain } from "@staix/agent-mem";
190
193
  import { recallFor } from "../memory/recall.ts";
@@ -199,6 +202,7 @@ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContin
199
202
  import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
200
203
  import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
201
204
 
205
+ import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
202
206
  import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
203
207
  import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
204
208
  import type { TerminalOpener } from "./terminal-open.ts";
@@ -281,13 +285,15 @@ export interface HubConfig {
281
285
  /** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
282
286
  snapshots: { enabled: boolean; keep: number };
283
287
  /** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
284
- research: { enabled: boolean };
288
+ research: { enabled: boolean; runners?: Record<string, RegisteredRunnerV1> };
285
289
  /** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
286
290
  bench: { enabled: boolean };
287
291
  /** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
288
292
  peers: PeerStartConfig;
289
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. */
290
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[] };
291
297
  /** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
292
298
  limits: LimitsConfig;
293
299
  /** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
@@ -353,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
353
359
  bench: { enabled: false },
354
360
  peers: {},
355
361
  terminal: { open: [] },
362
+ start: { agents: [...START_AGENTS_DEFAULT] },
356
363
  limits: DEFAULT_LIMITS,
357
364
  review: { adaptive: false, min_reviews: 5 },
358
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 },
@@ -370,7 +377,22 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
370
377
  /** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
371
378
  const CONFIG_FILES = INSTANCE_CONFIG_FILES;
372
379
  export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
373
- 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
+ }
374
396
 
375
397
  /** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
376
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[] } {
@@ -478,6 +500,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
478
500
  peerStartConfig(file.peers, instances, adapterCatalog);
479
501
  if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
480
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);
481
505
  if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
482
506
  if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
483
507
  delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
@@ -487,8 +511,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
487
511
  if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
488
512
  if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
489
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;
490
516
  const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
491
517
  if (why) ignored.push(why);
518
+ if (hasRunners) file.research.runners = parseResearchRunners(runnerDeclarations);
492
519
  for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
493
520
  return [file];
494
521
  });
@@ -575,10 +602,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
575
602
  checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
576
603
  ...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
577
604
  snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
578
- 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
579
606
  bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
580
607
  peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
581
608
  terminal: { open: terminalTemplate(file.terminal?.open) },
609
+ start: { agents: file.start?.agents === undefined ? [...START_AGENTS_DEFAULT] : checkStartAgents(file.start.agents) },
582
610
  limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
583
611
  review: { ...DEFAULT_CONFIG.review, ...file.review },
584
612
  recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
@@ -698,6 +726,7 @@ export interface DaemonOptions {
698
726
 
699
727
  interface Client {
700
728
  authed: boolean;
729
+ protocol?: number;
701
730
  role?: "peer" | "console" | "tools";
702
731
  peer?: PeerId;
703
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. */
@@ -747,6 +776,8 @@ function capturedRecoverySourceDigest(): string | undefined {
747
776
  }
748
777
 
749
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"));
750
781
  const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
751
782
  resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
752
783
  const projectId = opts.projectId ?? randomUUID();
@@ -2048,6 +2079,12 @@ export async function startDaemon(opts: DaemonOptions) {
2048
2079
  };
2049
2080
  const tasks: Tasks = new Tasks({
2050
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
+ },
2051
2088
  capabilities: peerCapabilities, repository: memoryRepo,
2052
2089
  contextAdmission: () => ({
2053
2090
  policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
@@ -4400,6 +4437,7 @@ export async function startDaemon(opts: DaemonOptions) {
4400
4437
  const metadata = peer.recoveryMetadata?.() ?? {};
4401
4438
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
4402
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;
4403
4441
  const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
4404
4442
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
4405
4443
  return [id, row];
@@ -4422,7 +4460,8 @@ export async function startDaemon(opts: DaemonOptions) {
4422
4460
  const researchDispatch = board.researchDispatchIntegrity();
4423
4461
  const researchExperiments = board.researchExperimentsIntegrity();
4424
4462
  const researchComparisons = board.researchComparisonIntegrity();
4425
- 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 } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
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])) } : {}) };
4426
4465
  };
4427
4466
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
4428
4467
  const integrityAs = (expected: unknown) => {
@@ -4512,7 +4551,7 @@ export async function startDaemon(opts: DaemonOptions) {
4512
4551
  ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
4513
4552
  // Every supported peer's membership, for launchers that run outside the console role (#484).
4514
4553
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
4515
- protocol: PROTOCOL,
4554
+ protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL,
4516
4555
  features: DAEMON_FEATURES,
4517
4556
  configuredAdapters: configuredIds,
4518
4557
  ...(externalAdapters.length ? { externalAdapters } : {}),
@@ -6708,6 +6747,7 @@ export async function startDaemon(opts: DaemonOptions) {
6708
6747
  // Retired dictionaries never enter active publication; hub ids remain opaque keys.
6709
6748
  if (prefix === "routing.targets" || prefix === "routing.routes") return [];
6710
6749
  if (prefix === "routing.hub_routes") return [prefix];
6750
+ if (prefix === "research.runners") return [prefix];
6711
6751
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
6712
6752
  return prefix ? [prefix] : [];
6713
6753
  }
@@ -7586,6 +7626,12 @@ export async function startDaemon(opts: DaemonOptions) {
7586
7626
  // Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
7587
7627
  if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
7588
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
+ }
7589
7635
  if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
7590
7636
  return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
7591
7637
  if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
@@ -7637,8 +7683,16 @@ export async function startDaemon(opts: DaemonOptions) {
7637
7683
  } catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
7638
7684
  if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
7639
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";
7640
7688
  return undefined;
7641
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
+ }
7642
7696
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
7643
7697
  if (checkTarget) {
7644
7698
  const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
@@ -7906,7 +7960,8 @@ export async function startDaemon(opts: DaemonOptions) {
7906
7960
  if (!c.authed) {
7907
7961
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7908
7962
  if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
7909
- 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) {
7910
7965
  // An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
7911
7966
  log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
7912
7967
  return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
@@ -7917,6 +7972,7 @@ export async function startDaemon(opts: DaemonOptions) {
7917
7972
  try { refreshConductorPolicy(); } catch { return sock.close(4403, "invalid conductor role/feed configuration"); }
7918
7973
  if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
7919
7974
  c.authed = true;
7975
+ c.protocol = selectedProtocol;
7920
7976
  c.role = msg.role;
7921
7977
  if (c.role === "console") consoles.add(sock);
7922
7978
  else if (c.role === "tools" && sessionHello(msg)) {
@@ -8003,7 +8059,7 @@ export async function startDaemon(opts: DaemonOptions) {
8003
8059
  }
8004
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.
8005
8061
  if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
8006
- return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
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 } : {}) });
8007
8063
  }
8008
8064
  const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
8009
8065
  && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
@@ -8272,11 +8328,15 @@ export async function startDaemon(opts: DaemonOptions) {
8272
8328
  const plan = msg.plan as UpgradePlan;
8273
8329
  const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
8274
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))
8275
- || 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();
8276
8335
  if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
8277
8336
  if (plan.kind === "upgrade") {
8278
- // 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.
8279
8338
  const affected = registeredProjects().filter(project => {
8339
+ if (scope !== undefined) return project.root === scope;
8280
8340
  const control = readControl(project.stateDir);
8281
8341
  return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
8282
8342
  });
@@ -8289,23 +8349,26 @@ export async function startDaemon(opts: DaemonOptions) {
8289
8349
  if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
8290
8350
  for (const id of selected.reconnectOnly) {
8291
8351
  const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
8292
- 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
8293
8353
  || Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
8294
8354
  || !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
8355
+ || !protocolWindow(row.controlProtocol) || owner.recoveryMetadata().controlProtocol !== row.controlProtocol
8295
8356
  || row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
8296
8357
  const generation = owner.sessionGeneration;
8297
8358
  const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
8298
- 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();
8299
8361
  reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
8362
+ && owner.recoveryMetadata().controlProtocol === row.controlProtocol
8300
8363
  && definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
8301
8364
  reconnect.set(id, row.observation);
8302
8365
  }
8303
8366
  }
8304
8367
  if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
8305
- 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> }) => {
8306
8369
  const launch = row.args ?? row.launch ?? {};
8307
8370
  const observed = reconnect.get(row.id);
8308
- 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,
8309
8372
  ...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
8310
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) } : {}),
8311
8374
  sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
@@ -8319,9 +8382,11 @@ export async function startDaemon(opts: DaemonOptions) {
8319
8382
  if (raw.kind === "restart") {
8320
8383
  if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
8321
8384
  } else {
8322
- const staged = join(hubHome(), "releases", plan.version);
8323
- if (raw.targetRoot !== join(staged, "node_modules/@staix/agent-hub")) throw new RecoveryAuthorityError();
8324
- 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"));
8325
8390
  if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
8326
8391
  }
8327
8392
  if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();