@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,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
+ }
@@ -0,0 +1,112 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { lstatSync, opendirSync, type Dirent, type Stats } from "node:fs";
3
+ import { isAbsolute, join, normalize } from "node:path";
4
+ import { processLiveness } from "../pi/process-signature.ts";
5
+ import { hubHome } from "./project.ts";
6
+ import { REGISTRY_DB_BUSY_TIMEOUT_MS } from "./registry.ts";
7
+ import { RUNTIME_PRUNE_INTERVAL_MS } from "./runtime-capture-prune.ts";
8
+
9
+ export interface RuntimePruneHealthCheck { ok: boolean | undefined; name: string; detail: string }
10
+ const MAX_LEDGER_BYTES = 1024 * 1024, MAX_OUTCOME_BYTES = 16 * 1024;
11
+ const MAX_SCAN_ENTRIES = 1024, MAX_QUARANTINES = 64, SCAN_MS = 250;
12
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
13
+ const columns = "slot,id,admitted_at,phase,pid,outcome";
14
+ const issue = (detail: string, ok: boolean | undefined = undefined): RuntimePruneHealthCheck => ({ ok, name: "runtime pruning", detail });
15
+ const info = (file: string): Stats | undefined => {
16
+ try { return lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
17
+ };
18
+ const safeFile = (file: string, stat: Stats, limit: number): void => {
19
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.size > limit) throw new Error(`unsafe metadata: ${file}`);
20
+ };
21
+ interface Admission { slot: number; id: string; admitted_at: number; phase: string; pid: number | null; outcome: string | null; signature: string | null; outcome_ok: number; signature_ok: number }
22
+ interface LedgerHealth { checks: RuntimePruneHealthCheck[]; active: boolean | undefined }
23
+ function admissionHealth(file: string): LedgerHealth {
24
+ let db: Database | undefined;
25
+ try {
26
+ const before = info(file); if (!before) return { checks: [], active: false };
27
+ safeFile(file, before, MAX_LEDGER_BYTES);
28
+ for (const suffix of ["-wal", "-shm", "-journal"]) { const sidecar = info(`${file}${suffix}`); if (sidecar) safeFile(`${file}${suffix}`, sidecar, MAX_LEDGER_BYTES); }
29
+ db = new Database(file, { readonly: true, create: false });
30
+ db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
31
+ const tables = db.query("SELECT name,type FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' LIMIT 2").all() as { name: string; type: string }[];
32
+ const shape = (db.query("PRAGMA table_info(admission)").all() as { name: string }[]).map(column => column.name).join(",");
33
+ if (tables.length !== 1 || tables[0]?.name !== "admission" || tables[0]?.type !== "table" || ![columns, `${columns},signature`].includes(shape)) throw new Error("unknown admission schema");
34
+ const signed = shape.endsWith(",signature");
35
+ const rows = db.query(`SELECT slot,id,admitted_at,phase,pid,CASE WHEN typeof(outcome)='text' AND length(CAST(outcome AS BLOB))<=${MAX_OUTCOME_BYTES} THEN outcome END AS outcome,
36
+ CASE WHEN outcome IS NULL OR (typeof(outcome)='text' AND length(CAST(outcome AS BLOB))<=${MAX_OUTCOME_BYTES}) THEN 1 ELSE 0 END AS outcome_ok,
37
+ ${signed ? "CASE WHEN typeof(signature)='text' AND length(CAST(signature AS BLOB))<=1024 THEN signature END" : "NULL"} AS signature,
38
+ ${signed ? "CASE WHEN signature IS NULL OR (typeof(signature)='text' AND length(CAST(signature AS BLOB))<=1024) THEN 1 ELSE 0 END" : "1"} AS signature_ok FROM admission LIMIT 2`).all() as Admission[];
39
+ const row = rows[0], after = info(file);
40
+ if (!after || after.dev !== before.dev || after.ino !== before.ino || after.size > MAX_LEDGER_BYTES) throw new Error("admission identity changed");
41
+ if (!rows.length) return { checks: [], active: false };
42
+ if (rows.length !== 1 || row?.slot !== 1 || row.outcome_ok !== 1 || row.signature_ok !== 1 || typeof row.id !== "string" || row.id.length !== 36 || !/^[a-f0-9-]{36}$/.test(row.id)
43
+ || !Number.isSafeInteger(row.admitted_at) || row.admitted_at < 0 || !["scheduled", "running", "complete"].includes(row.phase)
44
+ || row.pid !== null && (!Number.isSafeInteger(row.pid) || row.pid < 1)
45
+ || row.signature !== null && (!row.signature.trim() || Buffer.byteLength(row.signature) > 1024)) throw new Error("invalid admission row");
46
+ if (row.phase === "running") {
47
+ if (row.pid === null) throw new Error("running admission has no pid");
48
+ const liveness = processLiveness(row.pid, row.signature);
49
+ if (row.signature === null) return { active: undefined, checks: [issue(`legacy running admission has no signed worker identity${liveness === "gone" ? "; its PID is gone" : "; ownership is unknown"}; inspect ${file}`, liveness === "gone" ? false : undefined)] };
50
+ if (liveness === "live") return { active: true, checks: [issue(`signed maintenance worker ${row.pid} is running; ledger ${file}`, true)] };
51
+ return { active: liveness === "gone" ? false : undefined, checks: [issue(`maintenance worker is ${liveness === "gone" ? "gone; admission needs recovery" : "uncertain; ownership needs inspection"}; inspect ${file}`, liveness === "gone" ? false : undefined)] };
52
+ }
53
+ if (row.phase === "scheduled") {
54
+ if (row.pid !== null || row.signature !== null || row.outcome !== null) throw new Error("scheduled admission identity is inconsistent");
55
+ const age = Date.now() - row.admitted_at;
56
+ return { active: false, checks: age < 0 ? [issue(`scheduled admission timestamp is in the future; inspect ${file}`)]
57
+ : age >= RUNTIME_PRUNE_INTERVAL_MS ? [issue(`scheduled maintenance has not started within its admission interval; inspect ${file}`, false)] : [] };
58
+ }
59
+ if (typeof row.outcome !== "string") throw new Error("completed admission has no bounded outcome");
60
+ const outcome = JSON.parse(row.outcome) as { removed?: unknown; quarantined?: unknown; deferred?: unknown };
61
+ if (!outcome || typeof outcome !== "object" || Array.isArray(outcome) || Object.keys(outcome).some(key => !["removed", "quarantined", "deferred"].includes(key))
62
+ || !Number.isSafeInteger(outcome.removed) || (outcome.removed as number) < 0 || !Array.isArray(outcome.quarantined) || outcome.quarantined.length > MAX_QUARANTINES
63
+ || outcome.quarantined.some(value => typeof value !== "string" || value.length > 4096 || isAbsolute(value) || normalize(value) !== value || value.split(/[\\/]/).some(part => part === ".."))
64
+ || outcome.deferred !== undefined && (typeof outcome.deferred !== "string" || Buffer.byteLength(outcome.deferred) > 512)) throw new Error("invalid completed outcome");
65
+ if (outcome.deferred !== undefined || outcome.quarantined.length) {
66
+ const detail = typeof outcome.deferred === "string" ? `maintenance deferred: ${outcome.deferred.replace(/[\x00-\x1f\x7f]/g, " ")}; inspect ${file}` : `maintenance retained quarantine paths; inspect ${file}`;
67
+ return { active: false, checks: [issue(detail, false)] };
68
+ }
69
+ return { checks: [], active: false };
70
+ } catch { return { active: undefined, checks: [issue(`runtime maintenance admission is unreadable, unsafe or unknown; inspect ${file}`)] }; }
71
+ finally { try { db?.close(); } catch { /* Inspection never repairs or alters admission state. */ } }
72
+ }
73
+
74
+ /** Scan only the two release directory levels, never capture contents or a referenced/symlinked directory. */
75
+ function quarantineHealth(home: string, active: boolean | undefined): RuntimePruneHealthCheck[] {
76
+ const releases = join(home, "releases"), checks: RuntimePruneHealthCheck[] = [];
77
+ let remaining = MAX_SCAN_ENTRIES, found = 0;
78
+ const deadline = performance.now() + SCAN_MS;
79
+ const scan = (directory: string, visit: (entry: Dirent) => void) => {
80
+ const stat = info(directory); if (!stat) return;
81
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe release directory");
82
+ const stream = opendirSync(directory);
83
+ try { for (;;) { const entry = stream.readSync(); if (!entry) break; if (--remaining < 0 || performance.now() > deadline) throw new Error("release inventory bound exceeded"); visit(entry); } }
84
+ finally { stream.closeSync(); }
85
+ };
86
+ try {
87
+ scan(releases, version => {
88
+ if (VERSION.exec(version.name)?.[0] !== version.name) return;
89
+ const directory = join(releases, version.name);
90
+ if (!version.isDirectory() || version.isSymbolicLink()) throw new Error(`unsafe version directory: ${directory}`);
91
+ scan(directory, entry => {
92
+ if (!entry.name.startsWith(".runtime-prune-")) return;
93
+ if (++found > MAX_QUARANTINES) throw new Error("quarantine result bound exceeded");
94
+ const path = join(directory, entry.name), stat = info(path); if (!stat) return;
95
+ const unsafe = stat.isSymbolicLink() || !stat.isDirectory();
96
+ checks.push({ ok: undefined, name: "runtime quarantine", detail: `${unsafe ? "unsafe reserved quarantine path" : active === true ? "maintenance quarantine is in use" : "runtime quarantine remains"}; inspect ${path}` });
97
+ });
98
+ });
99
+ } catch { checks.push(issue(`runtime quarantine inventory is unreadable, unsafe or exceeds its bound; inspect ${releases}`)); }
100
+ return checks;
101
+ }
102
+
103
+ /** Read-only doctor diagnostics. An absent ledger and quarantine inventory preserves the previous empty result. */
104
+ export function runtimePruneHealth(home?: string): RuntimePruneHealthCheck[] {
105
+ try {
106
+ const target = home ?? hubHome(), stat = info(target);
107
+ if (!stat) return [];
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return [issue(`runtime maintenance home is unsafe; inspect ${target}`)];
109
+ const ledger = admissionHealth(join(target, "runtime-prune.db"));
110
+ return [...ledger.checks, ...quarantineHealth(target, ledger.active)];
111
+ } catch { return [issue(`runtime maintenance diagnostics are unavailable; inspect ${home ?? "the agent-hub home"}`)]; }
112
+ }