@staix/agent-hub 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
@@ -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");
@@ -19,6 +19,8 @@ export interface Envelope {
19
19
  refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true };
20
20
  /** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
21
21
  private?: boolean;
22
+ /** Sent by a distinct session (`<peer>~<origin>`, agent-hub#1036): a connection that named a peer id but is not that peer's current session. It grants nothing; it marks the sender for the log. */
23
+ session?: true;
22
24
  ts: number;
23
25
  }
24
26
 
package/src/hub/events.ts CHANGED
@@ -22,7 +22,10 @@ export type HubEvent =
22
22
  | { type: "config_edit"; file: "config.local.json" | "sdlc.toml"; paths: { path: string; before: string | null; after: string | null }[]; fileBefore: string; fileAfter: string; tier: 2 | 3; by: "person"; source: "dashboard" | "terminal"; backup?: string; reason?: string }
23
23
  /** #445 C2: an invocation's ids, class and outcome. Never an argument, a result or a description. */
24
24
  | { type: "invocation"; id: string; caller: string; target: string; capability: string; class: string; outcome: string; task?: number; bytes?: number }
25
- | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
25
+ /** `origin` (#604 PR B): the sending connection's role, peer and launch or instance id. Never a path or text; absent for an envelope the hub itself wrote. */
26
+ | { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string; origin?: import("./provenance.ts").Origin }
27
+ /** #604 PR B: one per peer-role or tools-role hello, with the same fields an envelope's `origin` carries. No cwd, root or token. */
28
+ | ({ type: "attach" } & import("./provenance.ts").Origin)
26
29
  | { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
27
30
  | { type: "stale"; id: string; from: string; peer: string; task?: string }
28
31
  | { type: "quiet"; id: string; from: string; peers: string[] }
@@ -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,
@@ -91,6 +90,18 @@ export class ExecutionBudget {
91
90
  return this.scopes(taskIds, peer).length > 0;
92
91
  }
93
92
 
93
+ /** Capability comes from the resolved instance definition, never the peer's spelling. */
94
+ instrumented(peer: string): boolean { return definitionOf(peer, this.instances)?.budget?.instrumented === true; }
95
+
96
+ /** The named budget must cover this task and owner; another applicable budget proves nothing. */
97
+ covers(id: string, taskId: number, peer: string, resources: readonly ExecutionUnit[]): boolean {
98
+ if (!this.instrumented(peer) || !resources.every(unit => UNITS.has(unit) && unit !== "tokens")) return false;
99
+ const row = this.scopes([taskId], peer).find(row => row.id === id);
100
+ if (!row) return false;
101
+ const limits = fromRow(row).limits;
102
+ return resources.every(unit => limits[unit] !== undefined && !(unit === "model_calls" && limits.tokens !== undefined));
103
+ }
104
+
94
105
  private scopes(taskIds: number[], peer: string): BudgetRow[] {
95
106
  const ids = [...new Set(taskIds.filter((id) => Number.isSafeInteger(id) && id > 0))];
96
107
  const taskRows = ids.length
@@ -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)");
@@ -0,0 +1,126 @@
1
+ // Cross-project task handoff (#604, agent-hub#1030). Pure: values in, values out, no clock, no file system, no socket. The daemon, the registry and the control client are
2
+ // passed in as data or as small ports, so every refusal here is testable without a second daemon and the policy cannot drift from what the daemon enforces.
3
+ import { CLASSES, type TaskClass } from "./board.ts";
4
+
5
+ /**
6
+ * What crosses projects, and nothing else: no memory, evidence, turn content or task history (#1030 design 5). The per-field limits alone allow about 13 KB, so `bodyBytes` is the real
7
+ * ceiling on the whole request: a body that fills several fields is refused whole, and no partial task is built from it.
8
+ */
9
+ export const HANDOFF_LIMITS = Object.freeze({ title: 300, detail: 4000, paths: 16, path: 512, issue: 2_000_000_000, bodyBytes: 8 * 1024 });
10
+ export const HANDOFF_FIELDS = Object.freeze(["project", "title", "class", "detail", "paths", "issue", "task"] as const);
11
+
12
+ export type HandoffRefusalCode =
13
+ | "invalid" | "too_large" | "unregistered" | "same_project" | "not_running" | "older_target" | "pii_turn" | "pii_task" | "no_authority";
14
+
15
+ /** A refusal always names its reason, and for the person-fixable ones the action. The code is for tests and logs, the message for the caller. */
16
+ export class HandoffRefusal extends Error {
17
+ constructor(readonly code: HandoffRefusalCode, message: string) { super(message); this.name = "HandoffRefusal"; }
18
+ }
19
+
20
+ /** `task` is the caller's OWN task this handoff comes from, so a `[pii]` one is refused and the target can name it in the follow-back. It never crosses as text. */
21
+ export interface HandoffRequest { title: string; class: TaskClass; detail: string; paths: string[]; issue?: number; task?: number }
22
+
23
+ const hasControl = (text: string): boolean => /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text);
24
+
25
+ /**
26
+ * Callers are models, and MCP schemas are not enforced on the way in: parse closed. An unknown field, an accessor or a non-plain object is refused, never dropped,
27
+ * because a dropped field is a request the caller thinks it made. The result is sized, so an oversized body is refused whole and no partial task is built from it.
28
+ */
29
+ export function parseHandoff(input: unknown): HandoffRequest & { project: string } {
30
+ if (!input || typeof input !== "object" || Array.isArray(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
31
+ throw new HandoffRefusal("invalid", "handoff must be a plain data object");
32
+ const descriptors = Object.getOwnPropertyDescriptors(input);
33
+ for (const [key, descriptor] of Object.entries(descriptors)) {
34
+ if (!(HANDOFF_FIELDS as readonly string[]).includes(key)) throw new HandoffRefusal("invalid", `handoff has an unknown field: ${key.slice(0, 40)}`);
35
+ if (!Object.hasOwn(descriptor, "value")) throw new HandoffRefusal("invalid", `handoff field ${key} is an accessor`);
36
+ }
37
+ const field = (name: (typeof HANDOFF_FIELDS)[number]): unknown => descriptors[name]?.value;
38
+ const text = (value: unknown, name: string, max: number, required: boolean): string => {
39
+ if (value === undefined || value === null || value === "") { if (required) throw new HandoffRefusal("invalid", `handoff ${name} is required`); return ""; }
40
+ if (typeof value !== "string") throw new HandoffRefusal("invalid", `handoff ${name} must be text`);
41
+ if (hasControl(value)) throw new HandoffRefusal("invalid", `handoff ${name} has control characters`);
42
+ if (Buffer.byteLength(value, "utf8") > max * 4) throw new HandoffRefusal("too_large", `handoff ${name} is over ${max} characters`);
43
+ if ([...value].length > max) throw new HandoffRefusal("too_large", `handoff ${name} is over ${max} characters`);
44
+ return value;
45
+ };
46
+ const project = text(field("project"), "project", HANDOFF_LIMITS.path, true).trim();
47
+ if (!project) throw new HandoffRefusal("invalid", "handoff project is required");
48
+ const title = text(field("title"), "title", HANDOFF_LIMITS.title, true).replace(/\s+/g, " ").trim();
49
+ if (!title) throw new HandoffRefusal("invalid", "handoff title is required");
50
+ const detail = text(field("detail"), "detail", HANDOFF_LIMITS.detail, false);
51
+ const cls = field("class") === undefined || field("class") === null || field("class") === "" ? "implement" : field("class");
52
+ if (typeof cls !== "string" || !(CLASSES as readonly string[]).includes(cls)) throw new HandoffRefusal("invalid", `handoff class must be one of ${CLASSES.join(", ")}`);
53
+ const rawPaths = field("paths");
54
+ if (rawPaths !== undefined && rawPaths !== null && (!Array.isArray(rawPaths) || Object.keys(rawPaths).length !== rawPaths.length)) throw new HandoffRefusal("invalid", "handoff paths must be a dense list of text");
55
+ if (Array.isArray(rawPaths) && rawPaths.length > HANDOFF_LIMITS.paths) throw new HandoffRefusal("too_large", `handoff has more than ${HANDOFF_LIMITS.paths} paths`);
56
+ const paths = (Array.isArray(rawPaths) ? rawPaths : []).map(path => {
57
+ const value = text(path, "path", HANDOFF_LIMITS.path, true).trim();
58
+ // Paths are the target's, repository-relative: an absolute path or a parent step names a place on this machine, not a file in the target's tree.
59
+ if (!value || value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]/).includes("..")) throw new HandoffRefusal("invalid", "handoff paths are repository-relative: no absolute path, ~ or ..");
60
+ return value;
61
+ });
62
+ const rawIssue = field("issue");
63
+ let issue: number | undefined;
64
+ if (rawIssue !== undefined && rawIssue !== null && rawIssue !== "") {
65
+ issue = typeof rawIssue === "string" && /^[1-9]\d{0,9}$/.test(rawIssue) ? Number(rawIssue) : rawIssue as number;
66
+ if (typeof issue !== "number" || !Number.isSafeInteger(issue) || issue < 1 || issue > HANDOFF_LIMITS.issue) throw new HandoffRefusal("invalid", "handoff issue must be a positive issue number in the target's repository");
67
+ }
68
+ const rawTask = field("task");
69
+ let task: number | undefined;
70
+ if (rawTask !== undefined && rawTask !== null && rawTask !== "") {
71
+ task = typeof rawTask === "string" && /^[1-9]\d{0,9}$/.test(rawTask) ? Number(rawTask) : rawTask as number;
72
+ if (typeof task !== "number" || !Number.isSafeInteger(task) || task < 1 || task > HANDOFF_LIMITS.issue) throw new HandoffRefusal("invalid", "handoff task must be a positive task id of this project");
73
+ }
74
+ const request = { project, title, class: cls as TaskClass, detail, paths, ...(issue === undefined ? {} : { issue }), ...(task === undefined ? {} : { task }) };
75
+ if (Buffer.byteLength(JSON.stringify(request), "utf8") > HANDOFF_LIMITS.bodyBytes) throw new HandoffRefusal("too_large", `handoff is over ${HANDOFF_LIMITS.bodyBytes} bytes`);
76
+ return request;
77
+ }
78
+
79
+ export interface RegisteredProject { id: string; root: string; stateDir: string }
80
+ export interface TargetDescriptor { features?: readonly string[]; instanceId?: string; projectId?: string; cwd?: string }
81
+
82
+ /**
83
+ * The target is named by canonical root or id and must be registered. The caller's own project and an unregistered name are refused here, before anything is read from a
84
+ * state directory. `canonical` resolves a typed path the way the registry does (`realPath`, never `realpathSync`), so a symlinked temporary root matches its registration.
85
+ */
86
+ export function resolveTarget(name: string, projects: readonly RegisteredProject[], self: { id: string; root: string }, canonical: (path: string) => string): RegisteredProject {
87
+ const typed = name.trim();
88
+ let resolved: string | undefined;
89
+ try { resolved = typed.startsWith("/") || typed.startsWith("~") || typed.startsWith(".") ? canonical(typed) : undefined; } catch { resolved = undefined; }
90
+ const match = projects.find(project => project.id === typed || (resolved !== undefined && project.root === resolved));
91
+ if (!match) throw new HandoffRefusal("unregistered", "that project is not in this machine's agent-hub registry; run `ahub projects` to see the registered ones, or `ahub up` in that project once");
92
+ if (match.id === self.id || match.root === self.root) throw new HandoffRefusal("same_project", "that is this project: a task for this project goes through hub_task_propose");
93
+ return match;
94
+ }
95
+
96
+ /** What the target's own status descriptor says. A daemon that is not running, or does not know the handoff, is refused with the person's action. */
97
+ export function checkTarget(project: RegisteredProject, descriptor: TargetDescriptor | undefined): void {
98
+ if (!descriptor) throw new HandoffRefusal("not_running", `the hub of ${project.id} is not running; run \`ahub up\` in ${project.root}, then hand the task off again (handoffs are not queued)`);
99
+ if (descriptor.projectId !== undefined && descriptor.projectId !== project.id) throw new HandoffRefusal("not_running", `the hub answering for ${project.root} belongs to another project; run \`ahub up\` in ${project.root}`);
100
+ if (!descriptor.features?.includes("task_handoff")) throw new HandoffRefusal("older_target", `the hub of ${project.id} is too old to take a handoff (no task_handoff feature): upgrade the target hub with \`ahub upgrade\` in ${project.root}`);
101
+ }
102
+
103
+ /** The states a target reports back, in the order a task can reach them. `declined` is the owner passing; the task then returns to the target's routing, so it is information, not an end. */
104
+ export const HANDOFF_EVENTS = Object.freeze(["accepted", "in_review", "approved", "declined", "cancelled"] as const);
105
+ export type HandoffEvent = (typeof HANDOFF_EVENTS)[number];
106
+
107
+ /** Map a target's task history entry to the event the origin is told about, or undefined for every other entry. Reads only the event name and the task state, never a note. */
108
+ export function handoffEventFor(entryEvent: string, state: string): HandoffEvent | undefined {
109
+ // The names are what `Tasks` writes (observed: accept writes "accepted", a done leaves the task in_review, the verdict writes "approved", a decline "declined", a cancel "cancelled").
110
+ if (entryEvent === "accepted" && state === "in_progress") return "accepted";
111
+ if (entryEvent === "declined") return "declined";
112
+ if (entryEvent === "cancelled" && state === "cancelled") return "cancelled";
113
+ if (entryEvent === "done" && state === "in_review") return "in_review";
114
+ if (entryEvent === "approved" && state === "approved") return "approved";
115
+ // A class with no reviewer: the done itself approves (`in_progress -> approved`), so the origin hears "approved" for that entry, not "in review".
116
+ if (entryEvent === "done" && state === "approved") return "approved";
117
+ return undefined;
118
+ }
119
+
120
+ const SHA = /^[0-9a-f]{40}$/;
121
+ /** One line, no free text from the target beyond a PR number and a head: `agent-hub task 123 approved: PR #7 at <sha>`. */
122
+ export function followBackLine(remoteTask: number, event: HandoffEvent, evidence: { pr?: unknown; head?: unknown } = {}): string {
123
+ const pr = typeof evidence.pr === "number" && Number.isSafeInteger(evidence.pr) && evidence.pr > 0 ? ` PR #${evidence.pr}` : "";
124
+ const head = typeof evidence.head === "string" && SHA.test(evidence.head) ? ` at ${evidence.head}` : "";
125
+ return `agent-hub task ${remoteTask} ${event.replace("_", " ")}${pr || head ? `:${pr}${head}` : ""}`;
126
+ }
@@ -8,7 +8,7 @@ import { NOTE_KINDS } from "./envelope.ts";
8
8
 
9
9
  /** Restricted to the fixed current-child tools server, never an ordinary peer tool. */
10
10
  /** The fixed MCP surface of a current owned implementer child; independent of broader project roles. */
11
- export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request"]);
11
+ export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_study_handoff"]);
12
12
 
13
13
  export const IMPLEMENTER_PERMISSION_TOOL = {
14
14
  name: "hub_permission_request",
@@ -81,6 +81,10 @@ const tool = (name: string, description: string, properties: Record<string, unkn
81
81
  inputSchema: { type: "object", properties, required, additionalProperties: false },
82
82
  });
83
83
 
84
+ /** #601: a campaign's contract and handoff are model-written text about its sources. The write tools' arguments and every study result stay out of memory capture. */
85
+ export const STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_study_handoff"]);
86
+ /** #604: a handoff's arguments cross to another project, so neither tool's arguments nor result are captured to memory. */
87
+ export const HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
84
88
  export const BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
85
89
  /** #445 C1: what a peer lists and publishes. Their text is another agent's words, so neither call is captured to memory. */
86
90
  export const CAPABILITY_TOOLS = new Set(["hub_capabilities", "hub_capabilities_publish", "hub_invoke"]);
@@ -96,6 +100,12 @@ export const TASK_TOOLS: HubTool[] = [
96
100
  tool("hub_backlog_pull", "Reserve a WIP slot and pull ready unpinned backlog work into To Do at its current revision. Preserves person order/reservations and frozen scope; pending unowned work can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "reason"]),
97
101
  tool("hub_backlog_split", "Split unstarted backlog work into two to eight staged parts. The original card retains integration/acceptance and depends on the children. Use after for existing prerequisite ids and afterParts for earlier zero-based part indexes. Latest unused unchanged children can be undone with hub_backlog_undo.", { id, revision: { type: "integer", minimum: 0 }, reason: { type: "string", minLength: 1, maxLength: 1000 }, parts: { type: "array", minItems: 2, maxItems: 8, items: { type: "object", properties: { title: { type: "string", minLength: 1, maxLength: 300 }, detail: { type: "string", maxLength: 8000 }, class: str, refs, plan, after: { type: "array", maxItems: 64, uniqueItems: true, items: { type: "integer", minimum: 1 } }, afterParts: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "integer", minimum: 0 } } }, required: ["title"], additionalProperties: false } } }, ["id", "revision", "reason", "parts"]),
98
102
  tool("hub_backlog_undo", "Undo your latest planning change at its current revision. Refuses intervening changes and another actor's edit.", { id, revision: { type: "integer", minimum: 1 } }, ["id", "revision"]),
103
+ tool("hub_study_draft", "Store a research campaign contract as a DRAFT revision 1 on an approved parent task. Configured planners only; it creates no task, dispatches nothing and never approves (only the person approves, with ahub study approve). The contract is data: question, scope, exclusions, sourceIds, units, deliverableSections, stopPolicy and an optional budget.", { parentTaskId: id, contract: { type: "object" } }, ["parentTaskId", "contract"]),
104
+ tool("hub_study_revise", "Store the next DRAFT revision of a research campaign with the revision you read. Configured planners only; a stale revision refuses, and a draft holds older bound work until the person approves it. Never approves.", { id: str, revision: { type: "integer", minimum: 1 }, contract: { type: "object" } }, ["id", "revision", "contract"]),
105
+ tool("hub_study_show", "Read a research campaign's coverage and stop projection: per unit round, state, handoff completeness and budget support, and why dispatch is held. Read-only. A private campaign, task or source shows ids, counts and states only, never its question, scope, path or excerpt. Give no id to list this project's campaigns, at most 50 per call: when the reply carries `next`, pass it as `after` for the following page.", { id: str, after: str }),
106
+ tool("hub_task_handoff", "Hand work that belongs to ANOTHER project that runs agent-hub to that project's board. Use it instead of implementing it from this session: a feature or behavior change first needs a spec issue in that project (pass its number as issue); a mechanical chore (an exact version pin, a regenerated file) goes straight to a task. The target is a registered project (its root or id) whose hub is running; it is not queued. Planner, conductor or person only. Never for a [pii] task or during a private turn. You learn the task's state changes as kind task messages.", { project: str, title: str, class: { type: "string", description: "task class, default implement" }, detail: str, paths: { type: "array", items: str, description: "repository-relative paths in the TARGET project" }, issue: { type: "integer", minimum: 1, description: "the target repository's issue number" }, task: { type: "integer", minimum: 1, description: "your own task in THIS project that this work comes from; a [pii] task is refused" } }, ["project", "title"]),
107
+ tool("hub_task_handoffs", "List what this project handed to other projects, with the last state each target reported. Read-only.", {}),
108
+ tool("hub_study_handoff", "Record the handoff for the research unit you currently own and have in progress: evidence per source, unresolved questions, contradictions and limitations, or insufficientEvidence. Only the bound owner; every required field must be present. Not a task completion.", { handoff: { type: "object" } }, ["handoff"]),
99
109
  tool("hub_release_new", "Create a future release planning row. Planner/person only; no activation, publish or release authorization.", { name: { type: "string", minLength: 1, maxLength: 64 }, goal: { type: "string", minLength: 1, maxLength: 1000 }, order: { type: "integer", minimum: 0 } }, ["name", "goal", "order"]),
100
110
  tool("hub_release_report", "Read a planned release's task readiness and recorded metrics. Planner/person only. A due date never waives unfinished scope.", { id }, ["id"]),
101
111
  tool("hub_release_assign", "Assign or move only a task's release, retaining its execution state and owner. Planner/person only; requires a reason and current planning revision. Frozen scope refuses.", { id, revision: { type: "integer", minimum: 0 }, release: { type: ["integer", "null"], minimum: 1 }, reason: { type: "string", minLength: 1, maxLength: 1000 } }, ["id", "revision", "release", "reason"]),
@@ -184,7 +194,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
184
194
  return { startPeers, readOnly: readOnlyPeers.includes(peer) };
185
195
  }
186
196
 
187
- const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
197
+ const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_capabilities", "hub_send", "hub_inbox", "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"]);
188
198
  /** Publication restricts exposure only; server-side authority remains mandatory for every call. */
189
199
  export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
190
200
  return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
@@ -1,9 +1,9 @@
1
1
  // #445 C2: read invocations. A peer asks the hub to have another peer run one capability the person shared; the target runs it in its own turn with its own
2
2
  // permissions, and the hub returns a bounded, sanitized, hub-headed result to the caller only. This file is the pure part (the gates, the argument check, the
3
3
  // two texts the hub writes) and the ledger (an ids-only `invocations` table in hub.db). It never reads a peer, a socket or the bus.
4
- import { Database } from "bun:sqlite";
4
+ import type { Database } from "./db-schema.ts";
5
+ import { openHubDatabase } from "./db-schema.ts";
5
6
  import { createHash } from "node:crypto";
6
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
7
7
  import { MAX_HOP, sanitize } from "./envelope.ts";
8
8
  import type { FlatSchema, ShareState, SideEffect } from "./capabilities.ts";
9
9
 
@@ -230,8 +230,7 @@ function validateRelay(row: InvocationRow, relay: Omit<InvocationRelay, "local"
230
230
  export class InvocationLedger {
231
231
  private readonly db: Database;
232
232
  constructor(path: string, private readonly now: () => number = Date.now) {
233
- this.db = new Database(path, { create: true });
234
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
233
+ this.db = openHubDatabase(path, { create: true });
235
234
  this.db.run("PRAGMA journal_mode = WAL");
236
235
  this.db.run(`CREATE TABLE IF NOT EXISTS invocations (
237
236
  id TEXT PRIMARY KEY, trace TEXT NOT NULL, caller TEXT NOT NULL, target TEXT NOT NULL, capability TEXT NOT NULL, digest TEXT NOT NULL,