@staix/agent-hub 0.25.0 → 0.27.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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +12 -6
  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 +7 -5
  6. package/docs/agent-notes/tasks.md +7 -6
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +7 -1
  9. package/docs/operations.md +160 -17
  10. package/docs/profiles.md +1 -0
  11. package/docs/quickstart.md +18 -4
  12. package/docs/sdlc.md +3 -3
  13. package/docs/security.md +52 -9
  14. package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
  15. package/docs/specs/2026-11-01-research-campaigns-design.md +21 -7
  16. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  17. package/docs/verification/2026-11-01-0.27.0.md +49 -0
  18. package/docs/verified.json +28 -16
  19. package/package.json +1 -1
  20. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  21. package/plugins/agent-hub/server.js +190 -101
  22. package/src/adapters/channel-peer.ts +4 -2
  23. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  24. package/src/adapters/definitions/qwen.ts +2 -0
  25. package/src/adapters/registry-types.ts +2 -0
  26. package/src/adapters/registry.ts +1 -0
  27. package/src/cli/conductor-recovery.ts +3 -2
  28. package/src/cli/help.ts +9 -8
  29. package/src/cli/identity.ts +5 -5
  30. package/src/cli/main.ts +68 -24
  31. package/src/cli/recovery-package.ts +94 -4
  32. package/src/cli/reset.ts +14 -4
  33. package/src/cli/runtime-version-warning.ts +28 -0
  34. package/src/cli/start-agents-run.ts +125 -0
  35. package/src/cli/study-output.ts +56 -0
  36. package/src/cli/study.ts +26 -3
  37. package/src/cli/task-output.ts +5 -0
  38. package/src/cli/upgrade-interactive.ts +4 -4
  39. package/src/cli/upgrade-runtime.ts +105 -57
  40. package/src/cli/upgrade.ts +90 -28
  41. package/src/hub/action-policy.ts +5 -3
  42. package/src/hub/autonomy-defaults.ts +32 -7
  43. package/src/hub/board.ts +129 -13
  44. package/src/hub/budget.ts +3 -4
  45. package/src/hub/child-process.ts +4 -3
  46. package/src/hub/conductor.ts +3 -4
  47. package/src/hub/config-apply.ts +13 -2
  48. package/src/hub/config-edit-host.ts +1 -0
  49. package/src/hub/config-tier.ts +3 -0
  50. package/src/hub/config-trust.ts +10 -2
  51. package/src/hub/control-client.ts +30 -10
  52. package/src/hub/daemon.ts +103 -26
  53. package/src/hub/db-schema.ts +143 -0
  54. package/src/hub/delivery-journal.ts +3 -4
  55. package/src/hub/events.ts +2 -1
  56. package/src/hub/execution-budget.ts +3 -4
  57. package/src/hub/folder-trigger.ts +3 -4
  58. package/src/hub/hub-tools.ts +7 -5
  59. package/src/hub/invocations.ts +3 -4
  60. package/src/hub/lifecycle.ts +87 -29
  61. package/src/hub/manager.ts +49 -33
  62. package/src/hub/no-writes.ts +1 -1
  63. package/src/hub/orca-env.ts +19 -0
  64. package/src/hub/project-runtime-state.ts +164 -0
  65. package/src/hub/project-runtime.ts +62 -0
  66. package/src/hub/protocol-window.ts +19 -0
  67. package/src/hub/recovery-store.ts +102 -12
  68. package/src/hub/registry.ts +5 -0
  69. package/src/hub/research-experiments.ts +33 -4
  70. package/src/hub/research-runner.ts +172 -0
  71. package/src/hub/restart.ts +9 -0
  72. package/src/hub/runtime-capture-prune.ts +415 -0
  73. package/src/hub/runtime-prune-health.ts +112 -0
  74. package/src/hub/schedules.ts +3 -4
  75. package/src/hub/sdlc-merge.ts +7 -0
  76. package/src/hub/sdlc-queue.ts +13 -1
  77. package/src/hub/sdlc-release-authorization.ts +11 -5
  78. package/src/hub/session-identity.ts +1 -1
  79. package/src/hub/settings.ts +4 -0
  80. package/src/hub/snapshots.ts +3 -4
  81. package/src/hub/start-agents.ts +44 -0
  82. package/src/hub/task-approval.ts +23 -0
  83. package/src/hub/tasks.ts +133 -34
  84. package/src/hub/usage-rollup.ts +3 -4
  85. package/src/pi/tool-receipts.ts +3 -4
  86. package/templates/presets/confirmation-matrix.json +5 -5
  87. package/templates/release-guard.block.md +2 -1
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 },
@@ -363,14 +370,29 @@ export const DEFAULT_CONFIG: HubConfig = {
363
370
  export { stateDirFor };
364
371
 
365
372
  /** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
366
- 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"]);
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_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
367
374
  const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
368
375
  const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
369
376
 
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 },
@@ -3483,6 +3520,7 @@ export async function startDaemon(opts: DaemonOptions) {
3483
3520
  }
3484
3521
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
3485
3522
  // shared memory and a new task could be routed to a cloud peer without matching any pattern.
3523
+ if (op === "hub_experiment_record" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
3486
3524
  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`);
3487
3525
  // The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
3488
3526
  // completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
@@ -3697,6 +3735,11 @@ export async function startDaemon(opts: DaemonOptions) {
3697
3735
  return JSON.stringify(await tasks.studyRevise(by, a));
3698
3736
  case "hub_study_show":
3699
3737
  return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
3738
+ // #602 (issue 932 T3): the read of the experiment records Tasks already keeps; it runs nothing and reads no file, and a private record is blanked inside the view.
3739
+ case "hub_experiment_show":
3740
+ return JSON.stringify(await tasks.experimentShow(by, a));
3741
+ case "hub_experiment_record":
3742
+ return JSON.stringify(await tasks.experimentRecord(by, a));
3700
3743
  case "hub_study_handoff":
3701
3744
  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");
3702
3745
  return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
@@ -4217,7 +4260,11 @@ export async function startDaemon(opts: DaemonOptions) {
4217
4260
  docs: opts.releaseDocEvidence,
4218
4261
  gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
4219
4262
  current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
4220
- record: receipt => event({ type: "release_authorization", by: "user", authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
4263
+ conductor: () => {
4264
+ const peer = conductorPeer(config.roles);
4265
+ return peer && !turns.get(peer)?.private && !contextPii(peer) ? peer : undefined;
4266
+ },
4267
+ record: receipt => event({ type: "release_authorization", by: receipt.by, authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
4221
4268
  });
4222
4269
  const terminalReads = terminalOwnerReads();
4223
4270
  const terminalLaunches = new TerminalLaunches({
@@ -4400,6 +4447,7 @@ export async function startDaemon(opts: DaemonOptions) {
4400
4447
  const metadata = peer.recoveryMetadata?.() ?? {};
4401
4448
  const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
4402
4449
  if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
4450
+ if (typeof metadata.controlProtocol === "number" && Number.isSafeInteger(metadata.controlProtocol) && metadata.controlProtocol > 0) row.controlProtocol = metadata.controlProtocol;
4403
4451
  const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
4404
4452
  if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
4405
4453
  return [id, row];
@@ -4422,7 +4470,8 @@ export async function startDaemon(opts: DaemonOptions) {
4422
4470
  const researchDispatch = board.researchDispatchIntegrity();
4423
4471
  const researchExperiments = board.researchExperimentsIntegrity();
4424
4472
  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])) } : {}) };
4473
+ const researchRunners = board.runnerReceiptIntegrity();
4474
+ 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
4475
  };
4427
4476
  /** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
4428
4477
  const integrityAs = (expected: unknown) => {
@@ -4512,7 +4561,7 @@ export async function startDaemon(opts: DaemonOptions) {
4512
4561
  ...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
4513
4562
  // Every supported peer's membership, for launchers that run outside the console role (#484).
4514
4563
  roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
4515
- protocol: PROTOCOL,
4564
+ protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL,
4516
4565
  features: DAEMON_FEATURES,
4517
4566
  configuredAdapters: configuredIds,
4518
4567
  ...(externalAdapters.length ? { externalAdapters } : {}),
@@ -6708,6 +6757,7 @@ export async function startDaemon(opts: DaemonOptions) {
6708
6757
  // Retired dictionaries never enter active publication; hub ids remain opaque keys.
6709
6758
  if (prefix === "routing.targets" || prefix === "routing.routes") return [];
6710
6759
  if (prefix === "routing.hub_routes") return [prefix];
6760
+ if (prefix === "research.runners") return [prefix];
6711
6761
  if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
6712
6762
  return prefix ? [prefix] : [];
6713
6763
  }
@@ -7586,6 +7636,12 @@ export async function startDaemon(opts: DaemonOptions) {
7586
7636
  // Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
7587
7637
  if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
7588
7638
  }
7639
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_runners")) {
7640
+ if (board.hasRunnerReceipts()) return "target lacks research_runners; persisted runner attestations require a supporting daemon";
7641
+ try {
7642
+ if (config.research.runners !== undefined || rawResearchRunnersPresent(opts.cwd)) return "target lacks research_runners; the project's research.runners requires a supporting daemon";
7643
+ } catch { return "research_runners compatibility is unknown; configuration could not be read"; }
7644
+ }
7589
7645
  if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
7590
7646
  return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
7591
7647
  if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
@@ -7637,8 +7693,16 @@ export async function startDaemon(opts: DaemonOptions) {
7637
7693
  } catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
7638
7694
  if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
7639
7695
  }
7696
+ if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("schema_guard"))
7697
+ return "target lacks schema_guard; controlled recovery requires a guard-aware runtime";
7640
7698
  return undefined;
7641
7699
  };
7700
+ // Preserve lifecycle fences before capability probes; the prepare branch rechecks them after resume capture awaits.
7701
+ if (msg.op === "prepare") {
7702
+ if (rosterChanging) return recoveryError("roster change is in progress; wait for ahub status before preparing recovery");
7703
+ if (requestedPeerStops.size) return recoveryError("peer stop is in progress; wait for ahub status before preparing recovery");
7704
+ if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
7705
+ }
7642
7706
  const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
7643
7707
  if (checkTarget) {
7644
7708
  const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
@@ -7906,7 +7970,8 @@ export async function startDaemon(opts: DaemonOptions) {
7906
7970
  if (!c.authed) {
7907
7971
  if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
7908
7972
  if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
7909
- if (msg.v !== PROTOCOL) {
7973
+ const offered = protocolWindow(msg.v, msg.vMin), selectedProtocol = offered && negotiateProtocol({ v: PROTOCOL, vMin: PROTOCOL_MIN }, offered);
7974
+ if (selectedProtocol === undefined) {
7910
7975
  // An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
7911
7976
  log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
7912
7977
  return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
@@ -7917,6 +7982,7 @@ export async function startDaemon(opts: DaemonOptions) {
7917
7982
  try { refreshConductorPolicy(); } catch { return sock.close(4403, "invalid conductor role/feed configuration"); }
7918
7983
  if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
7919
7984
  c.authed = true;
7985
+ c.protocol = selectedProtocol;
7920
7986
  c.role = msg.role;
7921
7987
  if (c.role === "console") consoles.add(sock);
7922
7988
  else if (c.role === "tools" && sessionHello(msg)) {
@@ -8003,7 +8069,7 @@ export async function startDaemon(opts: DaemonOptions) {
8003
8069
  }
8004
8070
  // #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
8071
  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 } : {}) });
8072
+ 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
8073
  }
8008
8074
  const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
8009
8075
  && msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
@@ -8272,11 +8338,15 @@ export async function startDaemon(opts: DaemonOptions) {
8272
8338
  const plan = msg.plan as UpgradePlan;
8273
8339
  const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
8274
8340
  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();
8341
+ || 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();
8342
+ const { recoveryScope } = await import("../cli/upgrade.ts"); current();
8343
+ const scope = recoveryScope(plan);
8344
+ if (scope !== undefined && scope !== opts.cwd) throw new RecoveryAuthorityError();
8276
8345
  if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
8277
8346
  if (plan.kind === "upgrade") {
8278
- // Shared package/plugin effects cover every running or uncertain registered project, not a caller-selected subset.
8347
+ // Scoped upgrades bind the source's one registered project; retained shared upgrades still cover every affected project.
8279
8348
  const affected = registeredProjects().filter(project => {
8349
+ if (scope !== undefined) return project.root === scope;
8280
8350
  const control = readControl(project.stateDir);
8281
8351
  return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
8282
8352
  });
@@ -8289,23 +8359,26 @@ export async function startDaemon(opts: DaemonOptions) {
8289
8359
  if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
8290
8360
  for (const id of selected.reconnectOnly) {
8291
8361
  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
8362
+ if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation", "controlProtocol"].includes(key)) || !row.observation
8293
8363
  || Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
8294
8364
  || !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
8365
+ || !protocolWindow(row.controlProtocol) || owner.recoveryMetadata().controlProtocol !== row.controlProtocol
8295
8366
  || row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
8296
8367
  const generation = owner.sessionGeneration;
8297
8368
  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();
8369
+ if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
8370
+ || owner.recoveryMetadata().controlProtocol !== row.controlProtocol) throw new RecoveryAuthorityError();
8299
8371
  reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
8372
+ && owner.recoveryMetadata().controlProtocol === row.controlProtocol
8300
8373
  && definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
8301
8374
  reconnect.set(id, row.observation);
8302
8375
  }
8303
8376
  }
8304
8377
  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> }) => {
8378
+ 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
8379
  const launch = row.args ?? row.launch ?? {};
8307
8380
  const observed = reconnect.get(row.id);
8308
- return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
8381
+ return { id: row.id, active: row.state !== "offline", controlProtocol: row.controlProtocol, threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
8309
8382
  ...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
8310
8383
  ...(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
8384
  sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
@@ -8319,9 +8392,11 @@ export async function startDaemon(opts: DaemonOptions) {
8319
8392
  if (raw.kind === "restart") {
8320
8393
  if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
8321
8394
  } 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"));
8395
+ const staged = join(scope === undefined ? hubHome() : realPath(hubHome()), "releases", plan.version);
8396
+ const expectedTarget = scope === undefined ? join(staged, "node_modules/@staix/agent-hub") : join(staged, `source-${raw.targetDigest}`);
8397
+ if (raw.targetRoot !== expectedTarget) throw new RecoveryAuthorityError();
8398
+ // A new/expired grant still needs registry proof; verification of an already issued grant below uses only retained target bytes.
8399
+ const verified = JSON.parse(readFileSync(join(scope === undefined ? staged : join(staged, "registry"), "verified.json"), "utf8"));
8325
8400
  if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
8326
8401
  }
8327
8402
  if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
@@ -8726,6 +8801,15 @@ export async function startDaemon(opts: DaemonOptions) {
8726
8801
  );
8727
8802
  return;
8728
8803
  }
8804
+ if (msg.op === "authorize-release") {
8805
+ const asker = c.role === "console" ? USER : c.role === "tools" && c.peer && conductorPeer(config.roles) === c.peer && !c.toolsOwner && !turns.get(c.peer)?.private && !contextPii(c.peer) ? c.peer : undefined;
8806
+ if (!asker) return void reply({ ok: false, error: "only the console user or the configured conductor may authorize a release" });
8807
+ void releaseAuthorizations.authorize(asker, msg.version, msg.commit).then(
8808
+ authorization => reply({ ok: true, authorization }),
8809
+ error => reply({ ok: false, error: releaseAuthorizationError(error) }),
8810
+ );
8811
+ return;
8812
+ }
8729
8813
  if (c.role !== "console" && ["status", "explain", "queue", "release"].includes(msg.op)) {
8730
8814
  let current: () => void;
8731
8815
  try { current = publicReadCurrent(sock); current(); } catch { return void reply({ ok: false, error: "public lifecycle read unavailable for this caller" }); }
@@ -8766,13 +8850,6 @@ export async function startDaemon(opts: DaemonOptions) {
8766
8850
  return;
8767
8851
  }
8768
8852
  if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
8769
- if (msg.op === "authorize-release") {
8770
- void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
8771
- authorization => reply({ ok: true, authorization }),
8772
- error => reply({ ok: false, error: releaseAuthorizationError(error) }),
8773
- );
8774
- return;
8775
- }
8776
8853
  if (msg.op === "queue" || msg.op === "release") {
8777
8854
  if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
8778
8855
  const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
@@ -0,0 +1,143 @@
1
+ import { constants, Database } from "bun:sqlite";
2
+ export type { Database } from "bun:sqlite";
3
+ import { closeSync, lstatSync, openSync, readSync, statSync } from "node:fs";
4
+ import { pathToFileURL } from "node:url";
5
+ import { VERSION } from "../version.ts";
6
+ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
7
+
8
+ /** Bump only for an incompatible hub.db format; compatible releases keep the introducing version. */
9
+ export const HUB_DB_SCHEMA = 2;
10
+ /** New/legacy databases keep format 1 until an incompatible record is committed. */
11
+ export const HUB_DB_BASE_SCHEMA = 1;
12
+ /** schema is the maximum supported format, never a request to migrate an existing compatible database. */
13
+ export interface HubDbSchemaReader { schema: number; version: string }
14
+ export interface HubDatabaseOptions {
15
+ readonly?: boolean;
16
+ create?: boolean;
17
+ reader?: HubDbSchemaReader;
18
+ }
19
+ export class HubDbSchemaError extends Error {
20
+ readonly code = "HUB_DB_SCHEMA";
21
+ constructor(message: string, readonly requiredVersion?: string) { super(message); this.name = "HubDbSchemaError"; }
22
+ }
23
+ interface HubDbSchema { schema: number; requiredVersion: string }
24
+ const VERSION_TEXT = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
25
+ const safeVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 64 && VERSION_TEXT.exec(value)?.[0] === value;
26
+ const defaultReader = (): HubDbSchemaReader => ({ schema: HUB_DB_SCHEMA, version: VERSION });
27
+ const invalidSchema = () => new HubDbSchemaError("this project's hub.db schema metadata is invalid; inspect or restore the database before starting agent-hub");
28
+ const busy = (error: unknown): boolean => {
29
+ const value = error as { code?: unknown; message?: unknown } | null;
30
+ return typeof value?.code === "string" && /^SQLITE_(BUSY|LOCKED)(?:_|$)/.test(value.code)
31
+ || typeof value?.message === "string" && /database (?:is )?(?:locked|busy)|database table is locked/i.test(value.message);
32
+ };
33
+ function readerValue(reader: HubDbSchemaReader): HubDbSchemaReader {
34
+ if (!reader || !Number.isSafeInteger(reader.schema) || reader.schema < 1 || !safeVersion(reader.version)) throw invalidSchema();
35
+ return { schema: reader.schema, version: reader.version };
36
+ }
37
+
38
+ /** No persistent PRAGMA or schema/data write runs while reading the marker. */
39
+ function schemaOn(db: Database): HubDbSchema | undefined {
40
+ try {
41
+ const entries = db.query("SELECT type FROM sqlite_master WHERE name='hub_schema' COLLATE NOCASE LIMIT 2").all() as { type: string }[];
42
+ if (!entries.length) return undefined;
43
+ if (entries.length !== 1 || entries[0]!.type !== "table") throw invalidSchema();
44
+ const rows = db.query("SELECT id,schema,CASE WHEN typeof(required_version)='text' AND length(CAST(required_version AS BLOB))<=64 THEN required_version END AS required_version FROM hub_schema LIMIT 2")
45
+ .all() as { id: number; schema: number; required_version: string | null }[];
46
+ const row = rows[0];
47
+ if (rows.length !== 1 || !row || row.id !== 1 || !Number.isSafeInteger(row.schema) || row.schema < 1 || !safeVersion(row.required_version)) throw invalidSchema();
48
+ return { schema: row.schema, requiredVersion: row.required_version };
49
+ } catch (error) {
50
+ // Native corruption is unreadable storage, not a parsed schema claim; reset may archive it intact.
51
+ const code = (error as { code?: unknown } | null)?.code;
52
+ if (error instanceof HubDbSchemaError || busy(error) || typeof code === "string" && /^(?:SQLITE_NOTADB|SQLITE_CORRUPT)(?:_|$)/.test(code)) throw error;
53
+ throw invalidSchema();
54
+ }
55
+ }
56
+ function checkOn(db: Database, reader: HubDbSchemaReader): HubDbSchema | undefined {
57
+ const stored = schemaOn(db);
58
+ if (stored && stored.schema > reader.schema) throw new HubDbSchemaError(`this project was opened by agent-hub ${stored.requiredVersion}; use ${stored.requiredVersion} or later`, stored.requiredVersion);
59
+ return stored;
60
+ }
61
+ function existingFile(file: string): boolean {
62
+ if (file === ":memory:") return false;
63
+ let info;
64
+ try { info = lstatSync(file); }
65
+ catch (error) { if ((error as { code?: string }).code === "ENOENT") return false; throw error; }
66
+ // A dangling link or non-file is unknown storage, never a missing legacy database.
67
+ if (info.isSymbolicLink()) { try { info = statSync(file); } catch { throw invalidSchema(); } }
68
+ if (!info.isFile()) throw invalidSchema();
69
+ return true;
70
+ }
71
+
72
+ /** Closed, checkpointed WAL files need a quiet metadata probe on macOS; unknown file/mode state never qualifies. */
73
+ function checkpointedWal(file: string): boolean {
74
+ let fd: number | undefined;
75
+ try {
76
+ fd = openSync(file, "r");
77
+ const header = Buffer.alloc(20);
78
+ return readSync(fd, header, 0, header.length, 0) === header.length && header.toString("ascii", 0, 15) === "SQLite format 3"
79
+ && header[15] === 0 && header[18] === 2 && header[19] === 2;
80
+ } catch { return false; }
81
+ finally { if (fd !== undefined) closeSync(fd); }
82
+ }
83
+ function noSidecars(file: string): boolean {
84
+ for (const suffix of ["-wal", "-shm", "-journal"]) {
85
+ try { lstatSync(`${file}${suffix}`); return false; }
86
+ catch (error) { if ((error as { code?: string }).code !== "ENOENT") return false; }
87
+ }
88
+ return true;
89
+ }
90
+ function probeCompatible(file: string, reader: HubDbSchemaReader, quiet: boolean): void {
91
+ const uri = pathToFileURL(file); uri.searchParams.set("immutable", "1");
92
+ const db = quiet ? new Database(uri.href, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true, create: false });
93
+ try {
94
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`); // connection-local, not persisted
95
+ checkOn(db, reader);
96
+ } finally { db.close(); }
97
+ }
98
+
99
+ /** Live WAL reads may take SHM read locks. Refusal performs no SQL/schema/data or DB/WAL write; SHM locks are SQLite's ceiling. */
100
+ export function assertHubDbCompatible(file: string, reader: HubDbSchemaReader = defaultReader()): void {
101
+ const supported = readerValue(reader);
102
+ if (!existingFile(file)) return;
103
+ if (checkpointedWal(file) && noSidecars(file)) {
104
+ // This handle is short-lived and never escapes. A live returned handle must continue observing later WAL commits.
105
+ let failure: unknown, failed = false;
106
+ try { probeCompatible(file, supported, true); } catch (error) { failure = error; failed = true; }
107
+ if (!noSidecars(file)) { probeCompatible(file, supported, false); return; }
108
+ if (failed) throw failure;
109
+ return;
110
+ }
111
+ probeCompatible(file, supported, false);
112
+ }
113
+
114
+ /** Every hub.db writer enters here before its own persistent PRAGMAs, migrations or maintenance writes. */
115
+ export function openHubDatabase(file: string, options: HubDatabaseOptions = {}): Database {
116
+ const reader = readerValue(options.reader ?? defaultReader());
117
+ // Refuse a future file without opening a writable connection, which can perform SQLite recovery work itself.
118
+ assertHubDbCompatible(file, reader);
119
+ const db = new Database(file, options.readonly ? { readonly: true, create: false } : { readwrite: true, create: options.create ?? true });
120
+ try {
121
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
122
+ if (options.readonly) { checkOn(db, reader); return db; }
123
+ // The second read and legacy baseline stamp share the writer lock, without filesystem work in SQL.
124
+ // Existing compatible markers never advance here; an incompatible migration must advance its marker with its actual DDL/data transaction.
125
+ db.transaction(() => {
126
+ const stored = checkOn(db, reader);
127
+ if (!stored) {
128
+ db.run("CREATE TABLE hub_schema (id INTEGER PRIMARY KEY CHECK(id=1),schema INTEGER NOT NULL,required_version TEXT NOT NULL)");
129
+ db.query("INSERT INTO hub_schema(id,schema,required_version) VALUES(1,?,?)").run(HUB_DB_BASE_SCHEMA, VERSION);
130
+ }
131
+ }).immediate();
132
+ return db;
133
+ } catch (error) { db.close(); throw error; }
134
+ }
135
+
136
+ /** The format marker advances only in the same transaction as the incompatible data it protects. */
137
+ export function advanceHubDbSchema(db: Database, schema: number, requiredVersion: string): void {
138
+ if (!db.inTransaction || !Number.isSafeInteger(schema) || schema < HUB_DB_BASE_SCHEMA || schema > HUB_DB_SCHEMA || !safeVersion(requiredVersion))
139
+ throw new HubDbSchemaError("hub.db schema advance requires an active data transaction and a supported format");
140
+ const stored = checkOn(db, defaultReader());
141
+ if (!stored) throw invalidSchema();
142
+ if (stored.schema < schema) db.query("UPDATE hub_schema SET schema=?,required_version=? WHERE id=1").run(schema, requiredVersion);
143
+ }
@@ -1,12 +1,12 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { Database } from "bun:sqlite";
3
+ import type { Database } from "./db-schema.ts";
4
+ import { openHubDatabase } from "./db-schema.ts";
4
5
  import type { BusSnapshot } from "./bus.ts";
5
6
  import type { QueuePressure } from "./queue-backpressure.ts";
6
7
  import { HUB, KINDS, USER, type Envelope, type Kind } from "./envelope.ts";
7
8
  import { heldDeliveryHints } from "./delivery-hints.ts";
8
9
  import { processLiveness } from "../pi/process-signature.ts";
9
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
10
10
  import { parseRemotePeerId } from "../rooms/application.ts";
11
11
  import { validateDeferredHub } from "./deferred-hub.ts";
12
12
 
@@ -282,8 +282,7 @@ export class DeliveryJournal {
282
282
  this.operationId = options.operationId;
283
283
  this.terminalHistoryLimit = options.terminalHistoryLimit;
284
284
  mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
285
- this.db = new Database(options.file, { create: true });
286
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
285
+ this.db = openHubDatabase(options.file, { create: true });
287
286
  this.harden();
288
287
  this.db.run("PRAGMA journal_mode = WAL");
289
288
  this.db.run("PRAGMA synchronous = FULL");
package/src/hub/events.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
2
  import type { TaskAttribution } from "./attribution.ts";
3
+ import type { PeerId } from "./envelope.ts";
3
4
  import type { SwitchTrace } from "../models/route/stage.ts";
4
5
  import type { ScreenRecord } from "./inference.ts";
5
6
 
@@ -13,7 +14,7 @@ export const EVENTS_SCHEMA = 1;
13
14
  * ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
14
15
  */
15
16
  export type HubEvent =
16
- | { type: "release_authorization"; by: "user"; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
17
+ | { type: "release_authorization"; by: PeerId; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
17
18
  | { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
18
19
  | { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
19
20
  /** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
@@ -1,6 +1,6 @@
1
1
  import { definitionOf, type KindMap } from "../adapters/registry.ts";
2
- import { Database } from "bun:sqlite";
3
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
2
+ import type { Database } from "./db-schema.ts";
3
+ import { openHubDatabase } from "./db-schema.ts";
4
4
 
5
5
  export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
6
6
  export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
@@ -43,8 +43,7 @@ const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
43
43
  export class ExecutionBudget {
44
44
  private readonly db: Database;
45
45
  constructor(path: string, private readonly instances?: KindMap) {
46
- this.db = new Database(path, { create: true });
47
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
46
+ this.db = openHubDatabase(path, { create: true });
48
47
  this.db.run("PRAGMA journal_mode = WAL");
49
48
  this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
50
49
  id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
@@ -1,10 +1,10 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "./db-schema.ts";
2
+ import { openHubDatabase } from "./db-schema.ts";
2
3
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
3
4
  import { join, resolve, sep } from "node:path";
4
5
  import { createHash } from "node:crypto";
5
6
  import { isDenied } from "../local/deny.ts";
6
7
  import { checkCorpusRoot } from "./corpus.ts";
7
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
8
8
  import { realPath } from "./project.ts";
9
9
  import { folderGlobMatches, MAX_NEW_PER_POLL, type FolderEntry, type FolderRunResult, type FolderScan, type ScannedFile } from "./schedules.ts";
10
10
  import { MAX_SOURCE_BYTES, screenRegistration, type SourceRegistry } from "./sources.ts";
@@ -169,8 +169,7 @@ export interface FolderSeenRow { trigger: string; real_path: string; size: numbe
169
169
  export class FolderStore {
170
170
  private readonly db: Database;
171
171
  constructor(dbPath: string) {
172
- this.db = new Database(dbPath, { create: true });
173
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
172
+ this.db = openHubDatabase(dbPath, { create: true });
174
173
  this.db.run("CREATE TABLE IF NOT EXISTS folder_seen (trigger TEXT NOT NULL, real_path TEXT NOT NULL, size INTEGER NOT NULL, mtime INTEGER NOT NULL, sha256 TEXT NOT NULL, task INTEGER, at INTEGER NOT NULL, PRIMARY KEY (trigger, real_path, sha256))");
175
174
  this.db.run("CREATE INDEX IF NOT EXISTS folder_seen_hash ON folder_seen (trigger, sha256)");
176
175
  this.db.run("CREATE TABLE IF NOT EXISTS folder_poll (trigger TEXT PRIMARY KEY, started INTEGER NOT NULL, baselined INTEGER NOT NULL DEFAULT 0, last_poll INTEGER, last_error TEXT)");