@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
@@ -0,0 +1,172 @@
1
+ import { createPublicKey, verify } from "node:crypto";
2
+ import { types } from "node:util";
3
+ import { canonicalJson } from "./packs.ts";
4
+
5
+ export interface RegisteredRunnerV1 { publicKey: string; projects: string[]; revoked: boolean }
6
+ export interface RunnerContainer { id: string; role: "candidate" | "evaluator" | "other" }
7
+ export interface RunnerMount { container: string; path: string; readOnly: boolean; purpose: "evaluator-code" | "evaluator-data" | "candidate-output" | "other" }
8
+ export interface RunnerOutput { name: string; sha256: string }
9
+ export interface AttemptReceiptV1 {
10
+ receiptId: string; runner: string; projectId: string; attemptId: string; protocolHash: string;
11
+ codeCommit?: string; imageDigest?: string; containers?: RunnerContainer[]; mounts?: RunnerMount[];
12
+ network?: "none" | "restricted" | "open";
13
+ resourceLimits?: { name: string; value: number; unit: string }[];
14
+ startedAt?: number; endedAt?: number; exitStatus?: number; outputs?: RunnerOutput[]; metricsFile?: RunnerOutput;
15
+ }
16
+ export interface RunnerVerificationContext {
17
+ runners: unknown;
18
+ projectId: string;
19
+ attempt: { id: string; projectId: string; protocolHash: string; codeCommit?: string } | undefined;
20
+ }
21
+
22
+ const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/, HASH = /^[0-9a-f]{64}$/, ATTEMPT = /^ra-[0-9a-f]{12}$/;
23
+ const BODY = 64 * 1024, SHORT = 512;
24
+ /** Exactly the receipt domain and its NUL separator; no executable runner protocol. */
25
+ export const RUNNER_RECEIPT_DOMAIN = "agent-hub.runner-receipt/v1" + String.fromCharCode(0);
26
+ function fail(field: string, reason: string): never { throw new Error(`${field}: ${reason}`); }
27
+ function object(input: unknown, keys: readonly string[] | undefined, field: string): Record<string, unknown> {
28
+ if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input)
29
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
30
+ fail(field, "expected a plain data object");
31
+ const descriptors = Object.getOwnPropertyDescriptors(input);
32
+ if (Object.values(descriptors).some(value => !Object.hasOwn(value, "value"))) fail(field, "accessors are unavailable");
33
+ if (keys && Object.keys(descriptors).some(key => !keys.includes(key))) fail(field, "unknown field");
34
+ if (Object.values(descriptors).some(value => value.value === undefined)) fail(field, "omit absent fields");
35
+ return Object.fromEntries(Object.entries(descriptors).map(([key, value]) => [key, value.value]));
36
+ }
37
+ function array(input: unknown, max: number, field: string): unknown[] {
38
+ if (!Array.isArray(input) || types.isProxy(input) || Object.getPrototypeOf(input) !== Array.prototype
39
+ || input.length > max || Object.getOwnPropertySymbols(input).length) fail(field, `expected a list of at most ${max} items`);
40
+ const descriptors = Object.getOwnPropertyDescriptors(input);
41
+ if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9][0-9]*)$/.test(key))) fail(field, "unknown list fields");
42
+ return Array.from({ length: input.length }, (_, index) => {
43
+ const value = descriptors[String(index)];
44
+ if (!value || !Object.hasOwn(value, "value")) fail(field, "expected a dense data list");
45
+ return value.value;
46
+ });
47
+ }
48
+ function text(input: unknown, field: string): string {
49
+ if (typeof input !== "string" || !input.trim() || input.length > SHORT || Buffer.byteLength(input, "utf8") > SHORT
50
+ || Buffer.from(input, "utf8").toString("utf8") !== input) fail(field, "expected non-empty UTF-8 text of at most 512 bytes");
51
+ return input;
52
+ }
53
+ function pattern(input: unknown, rule: RegExp, field: string): string {
54
+ if (typeof input !== "string" || rule.exec(input)?.[0] !== input) fail(field, "invalid identifier or hash");
55
+ return input;
56
+ }
57
+ function integer(input: unknown, field: string, min = 0): number {
58
+ if (typeof input !== "number" || !Number.isSafeInteger(input) || input < min) fail(field, "expected a bounded integer");
59
+ return input;
60
+ }
61
+ function choice<T extends string>(input: unknown, values: readonly T[], field: string): T {
62
+ if (typeof input !== "string" || !values.includes(input as T)) fail(field, "unsupported value");
63
+ return input as T;
64
+ }
65
+ function bounded<T>(value: T, field: string): T {
66
+ if (Buffer.byteLength(canonicalJson(value), "utf8") > BODY) fail(field, "stored body exceeds 64 KiB");
67
+ return value;
68
+ }
69
+ function base64(input: unknown, bytes: number, field: string): Buffer {
70
+ if (typeof input !== "string" || input.length !== 4 * Math.ceil(bytes / 3) || !/^[A-Za-z0-9+/]+={0,2}$/.test(input))
71
+ fail(field, "invalid canonical base64");
72
+ const decoded = Buffer.from(input, "base64");
73
+ if (decoded.length !== bytes || decoded.toString("base64") !== input) fail(field, "invalid canonical base64");
74
+ return decoded;
75
+ }
76
+ function publicKey(input: unknown, field: string): string {
77
+ const bytes = base64(input, 44, field);
78
+ try {
79
+ const key = createPublicKey({ key: bytes, format: "der", type: "spki" });
80
+ if (key.asymmetricKeyType !== "ed25519" || !key.export({ format: "der", type: "spki" }).equals(bytes))
81
+ fail(field, "expected an Ed25519 SPKI public key");
82
+ } catch { fail(field, "expected an Ed25519 SPKI public key"); }
83
+ return input as string;
84
+ }
85
+
86
+ /** Data validation only. Person-only registration and machine-local placement belong to the host. */
87
+ export function parseResearchRunners(input: unknown): Record<string, RegisteredRunnerV1> {
88
+ const raw = object(input, undefined, "research.runners"), entries = Object.entries(raw);
89
+ if (entries.length > 16) fail("research.runners", "at most 16 runners");
90
+ return bounded(Object.fromEntries(entries.map(([name, value]) => {
91
+ const field = `research.runners.${pattern(name, ID, "research.runners.name")}`, row = object(value, ["publicKey", "projects", "revoked"], field);
92
+ const projects = array(row.projects, 64, `${field}.projects`).map(value => text(value, `${field}.projects`));
93
+ if (new Set(projects).size !== projects.length) fail(`${field}.projects`, "duplicate projects");
94
+ if (typeof row.revoked !== "boolean") fail(`${field}.revoked`, "expected a boolean");
95
+ return [name, { publicKey: publicKey(row.publicKey, `${field}.publicKey`), projects, revoked: row.revoked }];
96
+ })), "research.runners");
97
+ }
98
+
99
+ function output(input: unknown, field: string): RunnerOutput {
100
+ const row = object(input, ["name", "sha256"], field);
101
+ return { name: text(row.name, `${field}.name`), sha256: pattern(row.sha256, HASH, `${field}.sha256`) };
102
+ }
103
+
104
+ /** Absent observations stay absent. Isolation inconsistencies are signed evidence, not schema failures. */
105
+ export function parseAttemptReceipt(input: unknown): AttemptReceiptV1 {
106
+ const raw = object(input, ["receiptId", "runner", "projectId", "attemptId", "protocolHash", "codeCommit", "imageDigest", "containers", "mounts", "network", "resourceLimits", "startedAt", "endedAt", "exitStatus", "outputs", "metricsFile"], "receipt");
107
+ const receipt: AttemptReceiptV1 = { receiptId: pattern(raw.receiptId, ID, "receipt.receiptId"), runner: pattern(raw.runner, ID, "receipt.runner"),
108
+ projectId: text(raw.projectId, "receipt.projectId"), attemptId: pattern(raw.attemptId, ATTEMPT, "receipt.attemptId"), protocolHash: pattern(raw.protocolHash, HASH, "receipt.protocolHash") };
109
+ if (raw.codeCommit !== undefined) receipt.codeCommit = pattern(raw.codeCommit, /^[0-9a-f]{40}$/, "receipt.codeCommit");
110
+ if (raw.imageDigest !== undefined) receipt.imageDigest = pattern(raw.imageDigest, /^sha256:[0-9a-f]{64}$/, "receipt.imageDigest");
111
+ if (raw.containers !== undefined) receipt.containers = array(raw.containers, 64, "receipt.containers").map((value, index) => {
112
+ const field = `receipt.containers[${index}]`, row = object(value, ["id", "role"], field);
113
+ return { id: pattern(row.id, ID, `${field}.id`), role: choice(row.role, ["candidate", "evaluator", "other"], `${field}.role`) };
114
+ });
115
+ if (raw.mounts !== undefined) receipt.mounts = array(raw.mounts, 256, "receipt.mounts").map((value, index) => {
116
+ const field = `receipt.mounts[${index}]`, row = object(value, ["container", "path", "readOnly", "purpose"], field);
117
+ if (typeof row.readOnly !== "boolean") fail(`${field}.readOnly`, "expected a boolean");
118
+ return { container: pattern(row.container, ID, `${field}.container`), path: text(row.path, `${field}.path`), readOnly: row.readOnly,
119
+ purpose: choice(row.purpose, ["evaluator-code", "evaluator-data", "candidate-output", "other"], `${field}.purpose`) };
120
+ });
121
+ if (raw.network !== undefined) receipt.network = choice<NonNullable<AttemptReceiptV1["network"]>>(raw.network, ["none", "restricted", "open"], "receipt.network");
122
+ if (raw.resourceLimits !== undefined) receipt.resourceLimits = array(raw.resourceLimits, 16, "receipt.resourceLimits").map((value, index) => {
123
+ const field = `receipt.resourceLimits[${index}]`, row = object(value, ["name", "value", "unit"], field);
124
+ if (typeof row.value !== "number" || !Number.isFinite(row.value) || row.value < 0) fail(`${field}.value`, "expected a nonnegative finite number");
125
+ return { name: text(row.name, `${field}.name`), value: row.value, unit: text(row.unit, `${field}.unit`) };
126
+ });
127
+ if (raw.startedAt !== undefined) receipt.startedAt = integer(raw.startedAt, "receipt.startedAt");
128
+ if (raw.endedAt !== undefined) receipt.endedAt = integer(raw.endedAt, "receipt.endedAt");
129
+ if (receipt.startedAt !== undefined && receipt.endedAt !== undefined && receipt.endedAt < receipt.startedAt) fail("receipt.endedAt", "precedes startedAt");
130
+ if (raw.exitStatus !== undefined) receipt.exitStatus = integer(raw.exitStatus, "receipt.exitStatus", Number.MIN_SAFE_INTEGER);
131
+ if (raw.outputs !== undefined) receipt.outputs = array(raw.outputs, 64, "receipt.outputs").map((value, index) => output(value, `receipt.outputs[${index}]`));
132
+ if (raw.metricsFile !== undefined) receipt.metricsFile = output(raw.metricsFile, "receipt.metricsFile");
133
+ return bounded(receipt, "receipt");
134
+ }
135
+
136
+ /** Predicate on evidence only; callers must verify its signature before treating sealing as attested. */
137
+ export function runnerSealing(receipt: AttemptReceiptV1): boolean {
138
+ const parsed = parseAttemptReceipt(receipt), containers = parsed.containers ?? [], mounts = parsed.mounts ?? [];
139
+ const roles = new Map(containers.map(container => [container.id, container.role]));
140
+ if (roles.size !== containers.length || !containers.some(container => container.role === "candidate")
141
+ || !containers.some(container => container.role === "evaluator") || !mounts.length) return false;
142
+ let evaluatorEvidence = false;
143
+ for (const mount of mounts) {
144
+ const role = roles.get(mount.container);
145
+ if (!role) return false;
146
+ if (mount.purpose === "evaluator-code" || mount.purpose === "evaluator-data") {
147
+ if (role !== "evaluator" || !mount.readOnly) return false;
148
+ evaluatorEvidence = true;
149
+ }
150
+ }
151
+ return evaluatorEvidence;
152
+ }
153
+
154
+ /** Pure verification against the current host-supplied registration and attempt identity. No host-observed or scientific claim. */
155
+ export function verifyAttemptReceipt(input: unknown, signature: unknown, context: RunnerVerificationContext): {
156
+ receipt: AttemptReceiptV1; provenance: "runner-attested"; sealed: boolean;
157
+ } {
158
+ const receipt = parseAttemptReceipt(input), runners = parseResearchRunners(context.runners);
159
+ if (!Object.hasOwn(runners, receipt.runner)) fail("receipt.runner", "unknown runner");
160
+ const runner = runners[receipt.runner]!;
161
+ if (runner.revoked) fail("receipt.runner", "revoked runner");
162
+ if (receipt.projectId !== context.projectId || !runner.projects.includes(receipt.projectId)) fail("receipt.projectId", "project is not allowed");
163
+ const attempt = context.attempt;
164
+ if (!attempt || attempt.id !== receipt.attemptId || attempt.projectId !== context.projectId || attempt.protocolHash !== receipt.protocolHash)
165
+ fail("receipt.attemptId", "attempt or protocol identity mismatch");
166
+ if (receipt.codeCommit !== undefined && attempt.codeCommit !== undefined && receipt.codeCommit !== attempt.codeCommit)
167
+ fail("receipt.codeCommit", "protocol commit mismatch");
168
+ const bytes = base64(signature, 64, "receipt.signature");
169
+ const key = createPublicKey({ key: Buffer.from(runner.publicKey, "base64"), format: "der", type: "spki" });
170
+ if (!verify(null, Buffer.from(RUNNER_RECEIPT_DOMAIN + canonicalJson(receipt), "utf8"), key, bytes)) fail("receipt.signature", "invalid signature");
171
+ return { receipt, provenance: "runner-attested", sealed: runnerSealing(receipt) };
172
+ }
@@ -16,6 +16,8 @@ export type RecoveryPhase = "preparing" | "prepared" | "restored" | "released";
16
16
  export interface RestartPeerSnapshot {
17
17
  id: PeerId;
18
18
  state: PeerState;
19
+ /** Attached channel's selected wire, not the daemon descriptor's compatibility floor. */
20
+ controlProtocol?: number;
19
21
  queueIds: string[];
20
22
  launch?: Record<string, unknown>;
21
23
  /** Safe native correlation metadata. Never include prompt/message text here. */
@@ -51,6 +53,7 @@ export interface RestartSnapshot {
51
53
  researchDispatch?: { activations: number; handoffs: number; digest: string };
52
54
  researchExperiments?: { protocols: number; attempts: number; events: number; observations: number; digest: string };
53
55
  researchComparisons?: { plans: number; approvals: number; contexts: number; digest: string };
56
+ researchRunners?: { count: number; digest: string };
54
57
  };
55
58
  }
56
59
 
@@ -98,6 +101,7 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
98
101
  } catch (error) { if (error instanceof RecoveryDeferredHubError) throw error; return undefined; }
99
102
  if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
100
103
  if (s.peers.some(peer => Object.hasOwn(peer, "externalInstance") && (peer.state !== "offline" || !validExternalInstanceBinding(peer.externalInstance, expected.projectRoot, peer.id)))) return undefined;
104
+ if (s.peers.some(peer => Object.hasOwn(peer, "controlProtocol") && (typeof peer.controlProtocol !== "number" || !Number.isSafeInteger(peer.controlProtocol) || peer.controlProtocol < 1))) return undefined;
101
105
  if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
102
106
  if (s.integrity && Object.hasOwn(s.integrity, "externalBindingsDigest") && (typeof s.integrity.externalBindingsDigest !== "string" || !/^[a-f0-9]{64}$/.test(s.integrity.externalBindingsDigest))) return undefined;
103
107
  if (s.integrity && Object.hasOwn(s.integrity, "research")) {
@@ -121,6 +125,11 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
121
125
  if (!value || typeof value !== "object" || Array.isArray(value) || ![value.plans, value.approvals, value.contexts].every(count => Number.isSafeInteger(count) && count >= 0)
122
126
  || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
123
127
  }
128
+ if (s.integrity && Object.hasOwn(s.integrity, "researchRunners")) {
129
+ const value = s.integrity.researchRunners;
130
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Number.isSafeInteger(value.count) || value.count < 0
131
+ || typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest)) return undefined;
132
+ }
124
133
  if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
125
134
  return s as RestartSnapshot;
126
135
  }
@@ -0,0 +1,415 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, readdirSync, renameSync, rmSync, type Dirent, type Stats } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { verifyPackage } from "../cli/recovery-package.ts";
7
+ import { processLiveness, processSignature } from "../pi/process-signature.ts";
8
+ import { childEnv } from "./child-process.ts";
9
+ import { hubHome, realPath } from "./project.ts";
10
+ import { readProjectRuntimeReference, projectRuntimeFile } from "./project-runtime-state.ts";
11
+ import { Registry, REGISTRY_DB_BUSY_TIMEOUT_MS } from "./registry.ts";
12
+
13
+ export interface RuntimeCapturePrunePlan { remove: string[]; kept: string[]; deferred?: string }
14
+ export interface RuntimeCapturePruneResult { removed: string[]; kept: string[]; quarantined?: string[]; deferred?: string }
15
+ interface Capture { root: string; version: string; digest: string; dev: number; ino: number; modified: number }
16
+ interface ManagerOwner { slot: number; instance_id: string; pid: number; signature: string | null }
17
+ interface Observation { files: Map<string, string | null>; lists: Map<string, string[]>; projects: string; roots: Set<string>; managerRoot?: string; bytes: number }
18
+ interface LockBudget { deadline: number; remaining: number }
19
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
20
+ const CAPTURE = /^source-([a-f0-9]{64})$/;
21
+ const OPERATION = /^[a-f0-9-]{36}\.json$/;
22
+ const MAX_METADATA_BYTES = 8 * 1024 * 1024;
23
+ const MAX_INVENTORY_BYTES = 16 * 1024 * 1024;
24
+ // ponytail: larger inventories defer; upgrade to indexed/batched reference inventory, never larger lock/busy limits.
25
+ const MAX_PROJECTS = 128, MAX_RECEIPTS = 128, MAX_CAPTURE_ENTRIES = 512, MAX_REFERENCE_FILES = 512;
26
+ const LOCK_METADATA_MS = 250;
27
+ export const RUNTIME_PRUNE_MAX_BATCH = 3;
28
+ export const RUNTIME_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
29
+ const RUNTIME_PRUNE_ADMISSION_BUSY_TIMEOUT_MS = 1;
30
+ function fail(reason: string): never { throw new Error(reason); }
31
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
32
+ const stamp = (info: Stats | undefined): string | null => info ? JSON.stringify([info.dev, info.ino, info.mode, info.nlink, info.size, info.mtimeMs, info.ctimeMs]) : null;
33
+ function tick(budget?: LockBudget): void {
34
+ if (budget && (--budget.remaining < 0 || performance.now() > budget.deadline)) fail("runtime retention metadata lock budget exhausted");
35
+ }
36
+ function remember(observation: Observation | undefined, file: string, value: string | null): void {
37
+ if (!observation) return;
38
+ if (observation.files.has(file) && observation.files.get(file) !== value) fail("runtime reference metadata changed during observation");
39
+ if (!observation.files.has(file) && observation.files.size >= MAX_REFERENCE_FILES) fail("runtime reference inventory exceeds its file bound");
40
+ observation.files.set(file, value);
41
+ }
42
+ function optionalInfo(file: string, observation?: Observation, budget?: LockBudget): Stats | undefined {
43
+ tick(budget);
44
+ let info: Stats | undefined;
45
+ try { info = lstatSync(file); }
46
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
47
+ remember(observation, file, stamp(info)); tick(budget); return info;
48
+ }
49
+ function directory(file: string, observation?: Observation): boolean {
50
+ const info = optionalInfo(file, observation);
51
+ if (!info) return false;
52
+ if (!info.isDirectory() || info.isSymbolicLink()) fail("runtime retention inventory contains a non-directory or symlink");
53
+ return true;
54
+ }
55
+ function entries(directory: string, observation?: Observation, budget?: LockBudget): Dirent[] {
56
+ const handle = opendirSync(directory), rows: Dirent[] = [];
57
+ try {
58
+ for (;;) {
59
+ tick(budget);
60
+ const entry = handle.readSync();
61
+ if (!entry) break;
62
+ if (rows.length >= MAX_CAPTURE_ENTRIES) fail("runtime retention directory inventory exceeds its bound");
63
+ rows.push(entry);
64
+ }
65
+ } finally { handle.closeSync(); }
66
+ if (observation) {
67
+ const names = rows.map(row => row.name).sort();
68
+ observation.lists.set(directory, names); optionalInfo(directory, observation);
69
+ }
70
+ return rows;
71
+ }
72
+ function metadata(file: string, observation: Observation): unknown {
73
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
74
+ try {
75
+ const info = fstatSync(fd);
76
+ if (!info.isFile() || info.nlink !== 1 || info.size > MAX_METADATA_BYTES || observation.bytes + info.size > MAX_INVENTORY_BYTES)
77
+ fail("runtime retention metadata is unreadable or oversized");
78
+ const bytes = Buffer.alloc(MAX_METADATA_BYTES + 1), count = readSync(fd, bytes, 0, bytes.length, 0), after = fstatSync(fd);
79
+ if (count > MAX_METADATA_BYTES || count !== info.size || stamp(after) !== stamp(info)
80
+ || stamp(optionalInfo(file)) !== stamp(after)) fail("runtime retention metadata changed or exceeded its bound");
81
+ observation.bytes += count; remember(observation, file, stamp(after));
82
+ try { return JSON.parse(bytes.subarray(0, count).toString("utf8")); } catch { return fail("runtime retention metadata is invalid"); }
83
+ } finally { closeSync(fd); }
84
+ }
85
+ function canonicalReference(root: string, observation: Observation): string {
86
+ const canonical = realPath(root); // process-backed platform resolution remains outside the registry writer lock
87
+ const info = optionalInfo(root, observation);
88
+ if (canonical !== root || !info?.isDirectory() || info.isSymbolicLink()) fail("runtime package reference is missing, linked or noncanonical");
89
+ return root;
90
+ }
91
+ function observeDatabase<T>(file: string, observation: Observation, read: (db: Database) => T): T {
92
+ const names = [file, `${file}-wal`], before = names.map(name => stamp(optionalInfo(name)));
93
+ let value: T;
94
+ const db = new Database(file, { readonly: true });
95
+ try {
96
+ db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
97
+ value = read(db);
98
+ } finally { db.close(); }
99
+ for (const [index, name] of names.entries()) {
100
+ const after = stamp(optionalInfo(name));
101
+ if (before[index] !== after) fail("runtime ownership database changed during observation");
102
+ remember(observation, name, after);
103
+ }
104
+ return value;
105
+ }
106
+ function managerRuntimeRoot(home: string, observation: Observation): string | undefined {
107
+ const manager = join(home, "manager");
108
+ if (!directory(manager, observation)) return undefined;
109
+ const manifestFile = join(manager, "status.json"), ownerFile = join(manager, "owner.db"), ownerInfo = optionalInfo(ownerFile, observation);
110
+ const manifest = optionalInfo(manifestFile, observation) ? metadata(manifestFile, observation) : undefined;
111
+ if (manifest !== undefined && (!object(manifest) || typeof manifest.instanceId !== "string" || !manifest.instanceId
112
+ || !Number.isSafeInteger(manifest.pid) || (manifest.pid as number) < 1
113
+ || manifest.pidSignature !== undefined && typeof manifest.pidSignature !== "string")) fail("manager manifest runtime identity is invalid");
114
+ let owner: ManagerOwner | undefined;
115
+ if (ownerInfo) {
116
+ if (!ownerInfo.isFile() || ownerInfo.isSymbolicLink()) fail("manager ownership inventory is unreadable");
117
+ owner = observeDatabase(ownerFile, observation, db => {
118
+ const signed = (db.query("PRAGMA table_info(owner)").all() as { name: string }[]).some(column => column.name === "signature");
119
+ const rows = db.query(`SELECT slot, instance_id, pid, ${signed ? "signature" : "NULL AS signature"} FROM owner LIMIT 2`).all() as ManagerOwner[];
120
+ if (rows.length > 1 || rows.some(row => row.slot !== 1 || typeof row.instance_id !== "string" || !row.instance_id
121
+ || !Number.isSafeInteger(row.pid) || row.pid < 1 || row.signature !== null && typeof row.signature !== "string")) fail("manager ownership runtime identity is invalid");
122
+ return rows[0];
123
+ });
124
+ }
125
+ const ownerLive = !!owner && processLiveness(owner.pid, owner.signature) !== "gone";
126
+ const manifestLive = object(manifest) && processLiveness(manifest.pid as number, manifest.pidSignature as string | undefined) !== "gone";
127
+ if (!ownerLive && !manifestLive) return undefined;
128
+ if (!owner || !object(manifest) || !ownerLive || !manifestLive || owner.instance_id !== manifest.instanceId || owner.pid !== manifest.pid
129
+ || owner.signature !== null && owner.signature !== manifest.pidSignature) fail("live or uncertain manager runtime identity is missing or mismatched");
130
+ if (typeof manifest.runtimeRoot !== "string" || !isAbsolute(manifest.runtimeRoot)) fail("manager runtime root is unavailable");
131
+ return canonicalReference(manifest.runtimeRoot, observation);
132
+ }
133
+ /** Expensive process/SQLite/Git-independent reference observations happen without the registry writer lock. */
134
+ function observeReferences(home: string, registry: Registry): Observation {
135
+ const projects = registry.list();
136
+ if (projects.length > MAX_PROJECTS) fail("project runtime inventory exceeds its bound");
137
+ const observation: Observation = { files: new Map(), lists: new Map(), projects: JSON.stringify(projects), roots: new Set(), bytes: 0 };
138
+ for (const project of projects) {
139
+ if (typeof project.id !== "string" || !project.id || !isAbsolute(project.root) || !isAbsolute(project.stateDir)
140
+ || project.instanceId !== null && (typeof project.instanceId !== "string" || !project.instanceId)
141
+ || project.pid !== null && (!Number.isSafeInteger(project.pid) || project.pid < 1)) fail("project registry runtime inventory is invalid");
142
+ const file = projectRuntimeFile(project); optionalInfo(file, observation);
143
+ const runtime = readProjectRuntimeReference(project); optionalInfo(file, observation);
144
+ if (runtime) {
145
+ const root = canonicalReference(runtime.root, observation), named = CAPTURE.exec(basename(root)), version = basename(dirname(root));
146
+ if (named?.[0] === basename(root) && VERSION.exec(version)?.[0] === version && basename(dirname(dirname(root))) === "releases"
147
+ && (named[1] !== runtime.digest || version !== runtime.version)) fail("project runtime capture path identity is inconsistent");
148
+ observation.roots.add(root);
149
+ }
150
+ // Retain uncertain claims conservatively, rather than running one process probe per registry row under a writer lock.
151
+ if (project.instanceId !== null && (!runtime || runtime.instanceId !== project.instanceId)) fail("a live or uncertain project has no matching retained runtime identity");
152
+ if (!project.instanceId && optionalInfo(join(project.stateDir, "status.json"), observation)) {
153
+ const status = metadata(join(project.stateDir, "status.json"), observation);
154
+ if (!object(status) || !Number.isSafeInteger(status.pid) || (status.pid as number) < 1) fail("legacy daemon runtime identity is unknown");
155
+ if (processLiveness(status.pid as number, typeof status.pidSignature === "string" ? status.pidSignature : undefined) !== "gone")
156
+ fail("a live or uncertain legacy daemon has no registry-bound runtime identity");
157
+ }
158
+ }
159
+ observation.managerRoot = managerRuntimeRoot(home, observation);
160
+ if (optionalInfo(join(home, "recovery.lock"), observation)) fail("recovery coordination is present");
161
+ const recovery = join(home, "recovery");
162
+ if (!directory(recovery, observation)) return observation;
163
+ const locks = join(recovery, "project-locks.db"), lockInfo = optionalInfo(locks, observation);
164
+ if (lockInfo) {
165
+ if (!lockInfo.isFile() || lockInfo.isSymbolicLink()) fail("project recovery coordination is unreadable");
166
+ observeDatabase(locks, observation, db => { if (db.query("SELECT 1 FROM project_locks LIMIT 1").get()) fail("project recovery coordination is present"); });
167
+ }
168
+ let receipts = 0;
169
+ for (const entry of entries(recovery, observation)) {
170
+ if (!entry.name.endsWith(".json")) continue;
171
+ if (++receipts > MAX_RECEIPTS) fail("recovery receipt inventory exceeds its bound");
172
+ if (OPERATION.exec(entry.name)?.[0] !== entry.name || !entry.isFile() || entry.isSymbolicLink()) fail("recovery receipt inventory is invalid");
173
+ const record = metadata(join(recovery, entry.name), observation);
174
+ if (!object(record) || ![1, 2, 3].includes(record.schema as number) || `${record.id}.json` !== entry.name
175
+ || !["pending", "running", "blocked", "completed", "cancelled"].includes(record.phase as string)) fail("recovery receipt inventory is invalid");
176
+ if (record.phase !== "completed" && record.phase !== "cancelled") fail("a retained recovery operation is not terminal");
177
+ if (!object(record.plan) || !Array.isArray(record.plan.projects) || !Array.isArray(record.projects)
178
+ || record.plan.projects.length !== record.projects.length || !["restart", "upgrade"].includes(record.plan.kind as string)
179
+ || typeof record.plan.sourceDigest !== "string" || !/^[a-f0-9]{64}$/.test(record.plan.sourceDigest)) fail("retained recovery runtime references are incomplete");
180
+ const references = [record.sourceRoot, record.plan.sourceRoot];
181
+ if (record.targetRoot !== undefined) references.push(record.targetRoot);
182
+ for (const value of references) {
183
+ if (typeof value !== "string" || !isAbsolute(value) || value.includes(String.fromCharCode(0))) fail("retained recovery runtime path is invalid");
184
+ // A retired mutable install may be gone; its lexical path still cannot authorize deletion elsewhere.
185
+ let root: string;
186
+ try { root = realPath(value); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; root = resolve(value); }
187
+ observation.roots.add(root);
188
+ }
189
+ if (record.targetRoot !== undefined && (typeof record.targetDigest !== "string" || !/^[a-f0-9]{64}$/.test(record.targetDigest))
190
+ || record.targetRoot === undefined && (record.phase === "completed" || record.targetDigest !== undefined)) fail("retained recovery target identity is incomplete");
191
+ }
192
+ return observation;
193
+ }
194
+ function validateObservation(registry: Registry, observation: Observation, budget: LockBudget): void {
195
+ tick(budget);
196
+ if (JSON.stringify(registry.list()) !== observation.projects) fail("project runtime registry changed before pruning");
197
+ for (const [file, expected] of observation.files) if (stamp(optionalInfo(file, undefined, budget)) !== expected) fail("runtime reference metadata changed before pruning");
198
+ for (const [directory, expected] of observation.lists) if (JSON.stringify(entries(directory, undefined, budget).map(entry => entry.name).sort()) !== JSON.stringify(expected))
199
+ fail("runtime reference directory changed before pruning");
200
+ tick(budget);
201
+ }
202
+ function scanCaptures(home: string, budget: LockBudget): Capture[] {
203
+ const releases = join(home, "releases"), captures: Capture[] = [];
204
+ const info = optionalInfo(releases, undefined, budget);
205
+ if (!info) return captures;
206
+ if (!info.isDirectory() || info.isSymbolicLink()) fail("runtime release inventory is invalid");
207
+ let count = 0;
208
+ for (const version of entries(releases, undefined, budget)) {
209
+ if (VERSION.exec(version.name)?.[0] !== version.name || !version.isDirectory() || version.isSymbolicLink()) continue;
210
+ for (const entry of entries(join(releases, version.name), undefined, budget)) {
211
+ if (++count > MAX_CAPTURE_ENTRIES) fail("runtime capture inventory exceeds its bound");
212
+ const match = CAPTURE.exec(entry.name);
213
+ if (!match || match[0] !== entry.name || !entry.isDirectory() || entry.isSymbolicLink()) continue;
214
+ const root = join(releases, version.name, entry.name), stat = optionalInfo(root, undefined, budget)!;
215
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail("runtime capture changed during metadata inventory");
216
+ captures.push({ root, version: version.name, digest: match[1]!, dev: stat.dev, ino: stat.ino, modified: stat.mtimeMs });
217
+ }
218
+ }
219
+ return captures;
220
+ }
221
+ const contains = (parent: string, child: string) => { const inside = relative(parent, child); return inside === "" || inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside); };
222
+ function select(captures: Capture[], observation: Observation, keepNewest: number): RuntimeCapturePrunePlan {
223
+ const protectedRoots = new Set<string>(), roots = [...observation.roots], executingRoot = resolve(import.meta.dir, "../..");
224
+ for (const root of [executingRoot, observation.managerRoot]) if (root && captures.some(capture => contains(capture.root, root))) roots.push(root);
225
+ for (const capture of captures) if (roots.some(root => contains(capture.root, root) || contains(root, capture.root))) protectedRoots.add(capture.root);
226
+ for (const version of new Set(captures.map(row => row.version))) for (const capture of captures.filter(row => row.version === version)
227
+ .sort((a, b) => b.modified - a.modified || a.root.localeCompare(b.root)).slice(0, keepNewest)) protectedRoots.add(capture.root);
228
+ const candidates = captures.filter(row => !protectedRoots.has(row.root)).sort((a, b) => a.modified - b.modified || a.root.localeCompare(b.root));
229
+ const remove = candidates.slice(0, RUNTIME_PRUNE_MAX_BATCH).map(row => row.root);
230
+ return { remove, kept: captures.filter(row => !remove.includes(row.root)).map(row => row.root) };
231
+ }
232
+ function lockedPlan(home: string, registry: Registry, observation: Observation, keepNewest: number): { plan: RuntimeCapturePrunePlan; captures: Capture[] } {
233
+ return registry.runtimeCaptureLocked(() => {
234
+ const budget = { deadline: performance.now() + LOCK_METADATA_MS, remaining: 2048 };
235
+ validateObservation(registry, observation, budget);
236
+ const captures = scanCaptures(home, budget), plan = select(captures, observation, keepNewest); tick(budget);
237
+ return { plan, captures };
238
+ });
239
+ }
240
+ function knownLayout(root: string, directories: readonly string[], files: readonly string[], optionalDirectories: readonly string[] = []): void {
241
+ const names = readdirSync(root), allowed = new Set([...directories, ...files, ...optionalDirectories]);
242
+ if ([...directories, ...files].some(name => !names.includes(name))) fail("a runtime capture known asset layout is incomplete");
243
+ for (const name of names) {
244
+ const info = lstatSync(join(root, name));
245
+ if (!allowed.has(name) || info.isSymbolicLink() || (files.includes(name) ? !info.isFile() : !info.isDirectory())) fail("a runtime capture contains an unknown asset layout");
246
+ }
247
+ }
248
+ function captureLayout(root: string): void {
249
+ knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules"]);
250
+ const modules = join(root, "node_modules");
251
+ if (optionalInfo(modules)) {
252
+ knownLayout(modules, ["@staix"], []); const scope = join(modules, "@staix"); knownLayout(scope, ["agent-mem"], []);
253
+ knownLayout(join(scope, "agent-mem"), ["src", "bin"], ["package.json", "README.md", "LICENSE"]);
254
+ }
255
+ }
256
+ function verifyQuarantine(root: string, capture: Capture): void {
257
+ captureLayout(root);
258
+ if (verifyPackage(root, capture.version, { ownDependencies: true }) !== capture.digest) fail("a runtime capture failed identity or digest verification");
259
+ }
260
+ function openRegistry(home: string): { home: string; registry: Registry } {
261
+ if (!directory(home)) fail("runtime retention home is unavailable");
262
+ const canonical = realPath(home), info = optionalInfo(join(canonical, "registry.db"));
263
+ if (!info?.isFile() || info.isSymbolicLink()) fail("runtime retention registry inventory is unavailable");
264
+ return { home: canonical, registry: new Registry(join(canonical, "registry.db")) };
265
+ }
266
+ const deferred = (error: unknown): string => `runtime capture pruning deferred: ${error instanceof Error ? error.message : "inventory unavailable"}`;
267
+ function checkRetention(keepNewest: number): void { if (!Number.isSafeInteger(keepNewest) || keepNewest < 0) fail("runtime capture retention must be a nonnegative integer"); }
268
+
269
+ /** Metadata preview only; execution always refreshes reference observations before quarantining each candidate. */
270
+ export function planRuntimeCapturePrune(home = hubHome(), keepNewest = 3): RuntimeCapturePrunePlan {
271
+ let registry: Registry | undefined;
272
+ try {
273
+ checkRetention(keepNewest); const opened = openRegistry(home); registry = opened.registry;
274
+ return lockedPlan(opened.home, registry, observeReferences(opened.home, registry), keepNewest).plan;
275
+ } catch (error) { return { remove: [], kept: [], deferred: deferred(error) }; }
276
+ finally { registry?.close(); }
277
+ }
278
+ /** Only the atomic rename is locked. Hashing and recursive removal operate on the reserved quarantine outside it. */
279
+ export function pruneRuntimeCaptures(home = hubHome(), keepNewest = 3): RuntimeCapturePruneResult {
280
+ const removed: string[] = [], quarantined: string[] = []; let kept: string[] = [], registry: Registry | undefined;
281
+ try {
282
+ checkRetention(keepNewest); const opened = openRegistry(home); registry = opened.registry; home = opened.home;
283
+ const initial = lockedPlan(home, registry, observeReferences(home, registry), keepNewest); kept = initial.plan.kept;
284
+ // Reject unknown layouts across the small batch before moving any entry; no package contents are hashed here.
285
+ for (const root of initial.plan.remove) captureLayout(root);
286
+ for (const original of initial.plan.remove) {
287
+ const candidate = initial.captures.find(row => row.root === original)!;
288
+ const observation = observeReferences(home, registry);
289
+ const quarantine = registry.runtimeCaptureLocked(() => {
290
+ const budget = { deadline: performance.now() + LOCK_METADATA_MS, remaining: 2048 };
291
+ validateObservation(registry!, observation, budget);
292
+ const fresh = select(scanCaptures(home, budget), observation, keepNewest); tick(budget);
293
+ if (!fresh.remove.includes(candidate.root)) { kept = [...new Set([...kept, candidate.root])]; return undefined; }
294
+ const info = optionalInfo(candidate.root, undefined, budget);
295
+ if (!info?.isDirectory() || info.isSymbolicLink() || info.dev !== candidate.dev || info.ino !== candidate.ino) fail("runtime capture changed before quarantine");
296
+ const root = join(dirname(candidate.root), `.runtime-prune-${randomUUID()}`); tick(budget);
297
+ mkdirSync(root, { mode: 0o700 }); quarantined.push(root);
298
+ const container = lstatSync(root), target = join(root, "capture");
299
+ renameSync(candidate.root, target);
300
+ return { root, target, dev: container.dev, ino: container.ino };
301
+ });
302
+ if (!quarantine) continue;
303
+ const before = lstatSync(quarantine.target);
304
+ if (before.dev !== candidate.dev || before.ino !== candidate.ino || !before.isDirectory() || before.isSymbolicLink()) fail("runtime quarantine identity changed");
305
+ verifyQuarantine(quarantine.target, candidate);
306
+ const container = lstatSync(quarantine.root), after = lstatSync(quarantine.target), names = readdirSync(quarantine.root);
307
+ if (container.dev !== quarantine.dev || container.ino !== quarantine.ino || !container.isDirectory() || container.isSymbolicLink()
308
+ || names.length !== 1 || names[0] !== "capture" || after.dev !== candidate.dev || after.ino !== candidate.ino || !after.isDirectory() || after.isSymbolicLink())
309
+ fail("runtime quarantine identity changed before removal");
310
+ rmSync(quarantine.root, { recursive: true }); quarantined.pop(); removed.push(candidate.root);
311
+ }
312
+ return { removed, kept };
313
+ } catch (error) { return { removed, kept, ...(quarantined.length ? { quarantined } : {}), deferred: deferred(error) }; }
314
+ finally { registry?.close(); }
315
+ }
316
+
317
+ interface Admission { slot: number; id: string; admitted_at: number; phase: "scheduled" | "running" | "complete"; pid: number | null; signature: string | null }
318
+ function admissionFile(home: string): { file: string; fresh: boolean } {
319
+ const file = join(home, "runtime-prune.db");
320
+ if (!directory(home)) fail("runtime maintenance admission state is unavailable");
321
+ let fresh = false;
322
+ try { const fd = openSync(file, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); closeSync(fd); fresh = true; }
323
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
324
+ const info = optionalInfo(file);
325
+ if (!info?.isFile() || info.isSymbolicLink() || info.nlink !== 1 || (info.mode & 0o077) !== 0 || info.size > 1024 * 1024) fail("runtime maintenance admission state is unavailable");
326
+ return { file, fresh };
327
+ }
328
+ function prepareAdmissionDatabase(db: Database, fresh: boolean): void {
329
+ db.transaction(() => {
330
+ if (fresh) db.run("CREATE TABLE admission (slot INTEGER PRIMARY KEY CHECK(slot=1), id TEXT NOT NULL, admitted_at INTEGER NOT NULL, phase TEXT NOT NULL, pid INTEGER, outcome TEXT, signature TEXT)");
331
+ const tables = db.query("SELECT name,type FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' LIMIT 2").all() as { name: string; type: string }[];
332
+ const columns = db.query("PRAGMA table_info(admission)").all() as { name: string }[];
333
+ if (tables.length !== 1 || tables[0]?.name !== "admission" || tables[0]?.type !== "table") fail("runtime maintenance admission schema is invalid");
334
+ const shape = columns.map(column => column.name).join(",");
335
+ if (shape === "slot,id,admitted_at,phase,pid,outcome") db.run("ALTER TABLE admission ADD COLUMN signature TEXT");
336
+ else if (shape !== "slot,id,admitted_at,phase,pid,outcome,signature") fail("runtime maintenance admission schema is invalid");
337
+ }).immediate();
338
+ }
339
+ function startupAdmissionDatabase(home: string): Database {
340
+ const { file, fresh } = admissionFile(home);
341
+ const db = new Database(file, { create: true });
342
+ try {
343
+ db.run(`PRAGMA busy_timeout = ${RUNTIME_PRUNE_ADMISSION_BUSY_TIMEOUT_MS}`);
344
+ prepareAdmissionDatabase(db, fresh); return db;
345
+ } catch (error) { db.close(); throw error; }
346
+ }
347
+ function workerAdmissionDatabase(home: string): Database {
348
+ const { file, fresh } = admissionFile(home);
349
+ const db = new Database(file, { create: true });
350
+ try {
351
+ db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
352
+ prepareAdmissionDatabase(db, fresh); return db;
353
+ } catch (error) { db.close(); throw error; }
354
+ }
355
+ function admissionRow(db: Database): Admission | undefined {
356
+ const rows = db.query("SELECT slot,id,admitted_at,phase,pid,signature FROM admission LIMIT 2").all() as Admission[];
357
+ if (rows.length > 1 || rows.some(row => row.slot !== 1 || typeof row.id !== "string" || !/^[a-f0-9-]{36}$/.exec(row.id)?.[0] || row.id.length !== 36
358
+ || !Number.isSafeInteger(row.admitted_at) || row.admitted_at < 0 || !["scheduled", "running", "complete"].includes(row.phase)
359
+ || row.pid !== null && (!Number.isSafeInteger(row.pid) || row.pid < 1)
360
+ || row.signature !== null && (typeof row.signature !== "string" || !row.signature.trim() || Buffer.byteLength(row.signature) > 1024))) fail("runtime maintenance admission state is invalid");
361
+ return rows[0];
362
+ }
363
+ function admitSchedule(home: string): string | undefined {
364
+ const db = startupAdmissionDatabase(home);
365
+ try {
366
+ return db.transaction(() => {
367
+ const previous = admissionRow(db), now = Date.now();
368
+ const dead = previous?.phase === "running" && previous.pid !== null && previous.signature !== null
369
+ && processLiveness(previous.pid, previous.signature) === "gone";
370
+ if (previous && (previous.phase === "running" && !dead || !dead && now - previous.admitted_at < RUNTIME_PRUNE_INTERVAL_MS)) return undefined;
371
+ const id = randomUUID();
372
+ db.query("INSERT OR REPLACE INTO admission(slot,id,admitted_at,phase,pid,outcome,signature) VALUES(1,?,?,'scheduled',NULL,NULL,NULL)").run(id, now);
373
+ return id;
374
+ }).immediate();
375
+ } finally { db.close(); }
376
+ }
377
+ const pendingSchedules = new Set<string>();
378
+ /** Startup resolves before this O(1) admission attempt; contention defers without Registry's long busy wait. */
379
+ export function scheduleRuntimeCapturePrune(home?: string): void {
380
+ const inheritedHome = process.env.AGENTHUB_HOME, key = home ?? inheritedHome ?? "default";
381
+ if (pendingSchedules.has(key)) return;
382
+ pendingSchedules.add(key);
383
+ setImmediate(() => {
384
+ try {
385
+ const targetHome = home ?? hubHome({ AGENTHUB_HOME: inheritedHome });
386
+ const id = admitSchedule(targetHome); if (!id) return;
387
+ const env = childEnv(process.env);
388
+ for (const key of Object.keys(env)) if (key.startsWith("ORCA_") || ["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR", "AGENTHUB_PEER_ID", "AGENTHUB_CHANNEL", "CLAUDECODE", "CODEX_THREAD_ID"].includes(key)) delete env[key];
389
+ env.AGENTHUB_HOME = targetHome;
390
+ const worker = spawn(process.execPath, [import.meta.path, targetHome, id], { detached: true, stdio: "ignore", env });
391
+ worker.on("error", () => {}); worker.unref();
392
+ } catch { /* Unknown or contended admission does not affect startup and grants no worker. */ }
393
+ finally { pendingSchedules.delete(key); }
394
+ });
395
+ }
396
+ function runScheduledWorker(home: string, id: string): void {
397
+ const signature = processSignature(process.pid);
398
+ if (!signature) fail("runtime maintenance worker process identity is unavailable");
399
+ const db = workerAdmissionDatabase(home);
400
+ try {
401
+ const admitted = db.transaction(() => {
402
+ const row = admissionRow(db); if (row?.id !== id || row.phase !== "scheduled") return false;
403
+ db.query("UPDATE admission SET phase='running',pid=?,signature=? WHERE slot=1 AND id=? AND phase='scheduled'").run(process.pid, signature, id); return true;
404
+ }).immediate();
405
+ if (!admitted) return;
406
+ const result = pruneRuntimeCaptures(home), outcome = JSON.stringify({ removed: result.removed.length, quarantined: (result.quarantined ?? []).map(root => relative(home, root)), ...(result.deferred ? { deferred: result.deferred.slice(0, 512) } : {}) });
407
+ if (Buffer.byteLength(outcome) > 16384) fail("runtime maintenance outcome exceeds its bound");
408
+ db.query("UPDATE admission SET phase='complete',outcome=? WHERE slot=1 AND id=? AND phase='running'").run(outcome, id);
409
+ } finally { db.close(); }
410
+ }
411
+ if (import.meta.main) {
412
+ const [home, id] = process.argv.slice(2);
413
+ try { if (!home || !isAbsolute(home) || !id || !/^[a-f0-9-]{36}$/.test(id)) fail("runtime maintenance worker identity is invalid"); runScheduledWorker(home, id); }
414
+ catch { process.exitCode = 1; }
415
+ }