@staix/agent-hub 0.3.1 → 0.4.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.
@@ -0,0 +1,232 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { ControlClient, PROTOCOL } from "./control-client.ts";
7
+ import { Registry, type Project } from "./registry.ts";
8
+ import { hubHome } from "./project.ts";
9
+ import { startDashboard } from "./ui.ts";
10
+ import { projectChain } from "../memory/recall.ts";
11
+ import type { ProjectInspection } from "./lifecycle.ts";
12
+
13
+ export type ProjectRecord = Project;
14
+ export type Lifecycle = {
15
+ inspectProject(project: Project): Promise<ProjectInspection>;
16
+ startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
17
+ stopProject(project: Project, expectedInstance?: string): Promise<void>;
18
+ };
19
+ export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string };
20
+ type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
21
+ type Manifest = { port: number; protocol: number; instanceId: string; pid: number };
22
+ const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
23
+ const CONTROL_TIMEOUT = 3000;
24
+ const safeError = (error: unknown) => error instanceof Error ? error.message.slice(0, 300) : "operation failed";
25
+
26
+ function files(home: string) {
27
+ const dir = join(home, "manager");
28
+ return { dir, token: join(dir, "control-token"), status: join(dir, "status.json"), owner: join(dir, "owner.db") };
29
+ }
30
+ function scrubEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
31
+ const out = { ...env };
32
+ for (const key of ["AGENTHUB_STATE_DIR", "AGENTHUB_PROJECT_DIR", "AGENTHUB_UNATTENDED", "AGENTHUB_OMNIROUTE_URL", "OMNIROUTE_API_KEY"]) delete out[key];
33
+ return out;
34
+ }
35
+ async function bounded<T>(promise: Promise<T>, ms: number): Promise<T> {
36
+ let timer: ReturnType<typeof setTimeout> | undefined;
37
+ try { return await Promise.race([promise, new Promise<T>((_, reject) => {
38
+ timer = setTimeout(() => reject(new Error("completion was not confirmed; refresh before retrying")), ms);
39
+ })]); } finally { clearTimeout(timer); }
40
+ }
41
+ function alive(pid: number): boolean | undefined {
42
+ if (!Number.isSafeInteger(pid) || pid < 1) return undefined;
43
+ try { process.kill(pid, 0); return true; }
44
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined; }
45
+ }
46
+ function claimOwner(path: string, instanceId: string): Database {
47
+ const db = new Database(path, { create: true });
48
+ try {
49
+ db.run("PRAGMA busy_timeout = 5000");
50
+ db.run("CREATE TABLE IF NOT EXISTS owner (slot INTEGER PRIMARY KEY CHECK (slot = 1), instance_id TEXT NOT NULL, pid INTEGER NOT NULL)");
51
+ db.transaction(() => {
52
+ const row = db.query("SELECT pid FROM owner WHERE slot = 1").get() as { pid: number } | null;
53
+ if (row && alive(row.pid) !== false) throw new Error("manager has a live or uncertain owner");
54
+ db.query("INSERT OR REPLACE INTO owner (slot, instance_id, pid) VALUES (1, ?, ?)").run(instanceId, process.pid);
55
+ }).immediate();
56
+ return db;
57
+ } catch (error) { db.close(); throw error; }
58
+ }
59
+ function readManifest(home: string): Manifest | undefined {
60
+ try {
61
+ const status = JSON.parse(readFileSync(files(home).status, "utf8"));
62
+ if (!Number.isInteger(status.port) || status.port < 1 || status.port > 65535 || typeof status.instanceId !== "string") return undefined;
63
+ return status;
64
+ } catch { return undefined; }
65
+ }
66
+ function removeManifest(home: string, instanceId: string) {
67
+ if (readManifest(home)?.instanceId !== instanceId) return;
68
+ rmSync(files(home).status, { force: true });
69
+ rmSync(files(home).token, { force: true });
70
+ }
71
+
72
+ export async function startManager(options: ManagerOptions = {}): Promise<ManagerHandle> {
73
+ const home = options.home ?? hubHome();
74
+ const existing = active.get(home);
75
+ if (existing) return existing.handle;
76
+ if (!options.lifecycle) throw new Error("manager lifecycle is not configured");
77
+ const lifecycle = options.lifecycle;
78
+ const registry = options.registry ?? new Registry(join(home, "registry.db"));
79
+ const ownsRegistry = !options.registry;
80
+ const f = files(home);
81
+ mkdirSync(f.dir, { recursive: true, mode: 0o700 });
82
+ const token = randomBytes(32).toString("hex"), instanceId = randomUUID();
83
+ let owner: Database | undefined;
84
+ let server: ReturnType<typeof Bun.serve> | undefined;
85
+ let dashboard: ReturnType<typeof startDashboard> | undefined;
86
+ let stopping = false;
87
+ let resolveStopped!: () => void;
88
+ const stopped = new Promise<void>((resolve) => { resolveStopped = resolve; });
89
+ const cleanup = () => {
90
+ dashboard?.stop(); server?.stop(true);
91
+ active.delete(home);
92
+ removeManifest(home, instanceId);
93
+ if (owner) {
94
+ owner.query("DELETE FROM owner WHERE slot = 1 AND instance_id = ? AND pid = ?").run(instanceId, process.pid);
95
+ owner.close(); owner = undefined;
96
+ }
97
+ if (ownsRegistry) registry.close();
98
+ };
99
+ const stop = async () => {
100
+ if (stopping) return;
101
+ stopping = true;
102
+ cleanup(); resolveStopped();
103
+ };
104
+ try {
105
+ owner = claimOwner(f.owner, instanceId);
106
+ const aliases = new Map<string, { at: number; names: string[] }>();
107
+ const list = async () => {
108
+ const projects = registry.list();
109
+ for (const p of projects) if (!aliases.has(p.id) || Date.now() - aliases.get(p.id)!.at > 60_000) {
110
+ aliases.set(p.id, { at: Date.now(), names: existsSync(p.root) ? projectChain(p.root) : [] });
111
+ }
112
+ const rows = await Promise.all(projects.map(async (project) => {
113
+ let result: ProjectInspection;
114
+ try { result = await bounded(lifecycle.inspectProject(project), 7000); }
115
+ catch (error) { result = { state: "unavailable", error: safeError(error) }; }
116
+ const names = aliases.get(project.id)!.names;
117
+ const shared = projects.some((other) => other.id !== project.id && aliases.get(other.id)!.names.some((name) => names.includes(name)));
118
+ return { id: project.id, root: project.root, state: result.state,
119
+ instanceId: result.state === "running" ? result.status?.instanceId ?? null : null,
120
+ peers: result.status?.peers ?? {}, tasks: result.status?.tasks ?? {},
121
+ ...(shared ? { warning: "Native memory aliases are shared with another registered project." } : {}),
122
+ ...(result.error ? { error: result.error } : {}) };
123
+ }));
124
+ return { ok: true, mode: "all", projects: rows };
125
+ };
126
+ const act = async (input: Record<string, unknown>) => {
127
+ if (stopping) return { ok: false, error: "manager is stopping" };
128
+ const project = typeof input.projectId === "string" ? registry.get(input.projectId) : undefined;
129
+ if (!project) return { ok: false, error: "unknown project" };
130
+ let client: ControlClient | undefined;
131
+ try {
132
+ if (input.action === "start") {
133
+ const status = await bounded(lifecycle.startProject(project, { unattended: false, env: { ...scrubEnv(process.env), AGENTHUB_HOME: home } }), 20_000);
134
+ return { ok: true, projectId: project.id, instanceId: status.instanceId };
135
+ }
136
+ if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
137
+ if (input.action === "stop") {
138
+ await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
139
+ return { ok: true };
140
+ }
141
+ if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
142
+ client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
143
+ return await client.request(input.action === "snapshot"
144
+ ? { t: "ui_snapshot", after: input.after ?? 0 }
145
+ : { t: "ui_action", action: input, instanceId: input.instanceId }, 10_000);
146
+ } catch (error) { return { ok: false, error: safeError(error) }; }
147
+ finally { client?.close(); }
148
+ };
149
+ dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
150
+ server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
151
+ fetch(req) {
152
+ if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
153
+ if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
154
+ if (req.method !== "POST") return new Response("POST required", { status: 405 });
155
+ const expected = req.headers.get("x-agenthub-instance");
156
+ if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
157
+ if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
158
+ const path = new URL(req.url).pathname;
159
+ if (path === "/open") return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue() });
160
+ if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
161
+ return new Response("not found", { status: 404 });
162
+ },
163
+ });
164
+ writeFileSync(f.token, token, { mode: 0o600 });
165
+ chmodSync(f.token, 0o600);
166
+ const temp = `${f.status}.${instanceId}.tmp`;
167
+ writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL, instanceId, pid: process.pid }));
168
+ renameSync(temp, f.status);
169
+ const handle = { stop, stopped };
170
+ active.set(home, { handle, issue: () => dashboard!.issue() });
171
+ return handle;
172
+ } catch (error) { cleanup(); throw error; }
173
+ }
174
+
175
+ async function managerRequest(home: string, path: "/open" | "/stop"): Promise<{ status: Manifest; body: any }> {
176
+ const status = readManifest(home);
177
+ if (!status) throw new Error("manager manifest is unavailable");
178
+ if (status.protocol !== PROTOCOL) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
179
+ const token = readFileSync(files(home).token, "utf8").trim();
180
+ if (!token) throw new Error("manager token is unavailable");
181
+ const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
182
+ method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, signal: AbortSignal.timeout(CONTROL_TIMEOUT),
183
+ });
184
+ if (!response.ok) throw new Error(`manager refused request (${response.status}); ownership was not verified`);
185
+ const body = await response.json() as { ok?: boolean; instanceId?: string; url?: string };
186
+ if (!body.ok || body.instanceId !== status.instanceId) throw new Error("manager identity changed; refresh before retrying");
187
+ return { status, body };
188
+ }
189
+
190
+ export async function openManager(options: ManagerOptions = {}): Promise<string> {
191
+ const home = options.home ?? hubHome();
192
+ const local = active.get(home);
193
+ if (local) return local.issue();
194
+ try { return (await managerRequest(home, "/open")).body.url; }
195
+ catch (error) {
196
+ const old = readManifest(home);
197
+ if (old && alive(old.pid) !== false) throw error;
198
+ // A definitely dead manager can be recovered by its ownership claim.
199
+ }
200
+ const f = files(home);
201
+ mkdirSync(f.dir, { recursive: true, mode: 0o700 });
202
+ const log = openSync(join(f.dir, "manager.log"), "a");
203
+ try {
204
+ const proc = spawn(process.execPath, [options.cli ?? join(import.meta.dir, "../cli/main.ts"), "manager"], {
205
+ cwd: home, detached: true, stdio: ["ignore", log, log], env: { ...scrubEnv(process.env), AGENTHUB_HOME: home },
206
+ });
207
+ proc.on("error", () => {}); proc.unref();
208
+ } finally { closeSync(log); }
209
+ const deadline = Date.now() + 8000;
210
+ let error: unknown;
211
+ while (Date.now() < deadline) {
212
+ try { return (await managerRequest(home, "/open")).body.url; }
213
+ catch (caught) { error = caught; await Bun.sleep(100); }
214
+ }
215
+ throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
216
+ }
217
+
218
+ export async function stopManager(options: ManagerOptions = {}): Promise<void> {
219
+ const home = options.home ?? hubHome();
220
+ const local = active.get(home);
221
+ if (local) return local.handle.stop();
222
+ if (!existsSync(files(home).status)) return;
223
+ const { status } = await managerRequest(home, "/stop");
224
+ const deadline = Date.now() + CONTROL_TIMEOUT;
225
+ while (Date.now() < deadline) {
226
+ const current = readManifest(home);
227
+ if (!current) return;
228
+ if (current.instanceId !== status.instanceId) throw new Error("a new manager instance started; it was not stopped");
229
+ await Bun.sleep(25);
230
+ }
231
+ throw new Error("manager shutdown is still pending; project hubs were not changed");
232
+ }
package/src/hub/ports.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { dirname, join } from "node:path";
3
+ import { Registry } from "./registry.ts";
4
+ import { hubHome } from "./project.ts";
4
5
 
5
6
  export const BASE_PORT = 4600;
6
7
  export const STRIDE = 10;
@@ -10,20 +11,48 @@ export const CODEX_APP = 1;
10
11
  export const CODEX_PROXY = 2;
11
12
  export const SWITCHYARD = 3;
12
13
 
13
- /** One stride of loopback ports per project directory, remembered in ~/.agenthub/ports.json. */
14
- export function allocatePorts(projectDir: string, registry = join(homedir(), ".agenthub", "ports.json")): number {
15
- let map: Record<string, number> = {};
14
+ /** SQLite-backed project allocation; an explicit JSON path retains the legacy utility API under a lock. */
15
+ export function allocatePorts(projectDir: string, registry?: string): number {
16
+ if (registry === undefined) {
17
+ const db = new Registry(join(hubHome(), "registry.db"));
18
+ try {
19
+ return db.allocate(projectDir);
20
+ } finally {
21
+ db.close();
22
+ }
23
+ }
24
+ mkdirSync(dirname(registry), { recursive: true });
25
+ const lock = `${registry}.lock`;
26
+ for (let attempt = 0; ; attempt++) {
27
+ try { mkdirSync(lock); break; }
28
+ catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST" || attempt >= 500) throw new Error(`cannot lock legacy port registry ${registry}`);
30
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
31
+ }
32
+ }
16
33
  try {
17
- map = JSON.parse(readFileSync(registry, "utf8"));
18
- } catch {
19
- // first use
34
+ let map: Record<string, number> = {};
35
+ if (existsSync(registry)) {
36
+ try {
37
+ map = JSON.parse(readFileSync(registry, "utf8"));
38
+ } catch (error) {
39
+ throw new Error(`cannot read legacy port registry ${registry}: ${(error as Error).message}`);
40
+ }
41
+ }
42
+ if (!map || Array.isArray(map) || typeof map !== "object") throw new Error(`cannot read legacy port registry ${registry}: expected an object`);
43
+ const seen = new Set<number>();
44
+ for (const [root, port] of Object.entries(map)) {
45
+ if (typeof port !== "number" || !Number.isInteger(port) || port < BASE_PORT || port % STRIDE !== 0) throw new Error(`cannot read legacy port registry ${registry}: invalid port for ${root}`);
46
+ if (seen.has(port)) throw new Error(`cannot read legacy port registry ${registry}: conflicting port ${port}`);
47
+ seen.add(port);
20
48
  }
21
49
  const known = map[projectDir];
22
50
  if (known) return known;
23
51
  const base = Math.max(BASE_PORT - STRIDE, ...Object.values(map)) + STRIDE;
24
52
  map[projectDir] = base;
25
- mkdirSync(dirname(registry), { recursive: true });
26
- // ponytail: no file lock; two first-time `ahub up` in different projects at the same instant could collide.
27
- writeFileSync(registry, `${JSON.stringify(map, null, 2)}\n`);
53
+ const temp = `${registry}.tmp-${process.pid}-${Date.now()}`;
54
+ writeFileSync(temp, `${JSON.stringify(map, null, 2)}\n`);
55
+ renameSync(temp, registry);
28
56
  return base;
57
+ } finally { rmSync(lock, { recursive: true, force: true }); }
29
58
  }
@@ -0,0 +1,93 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ const canonical = (path: string): string => {
7
+ const absolute = resolve(path);
8
+ try {
9
+ return realpathSync(absolute);
10
+ } catch {
11
+ const parent = dirname(absolute);
12
+ return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
13
+ }
14
+ };
15
+
16
+ export function canonicalPath(path: string): string {
17
+ return canonical(path);
18
+ }
19
+
20
+ function gitRoot(dir: string): string | undefined {
21
+ const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
22
+ if (result.status !== 0) return undefined;
23
+ const root = result.stdout.trim();
24
+ return root ? canonical(root) : undefined;
25
+ }
26
+
27
+ function hasConfig(dir: string): boolean {
28
+ return existsSync(join(dir, ".agenthub", "config.json"));
29
+ }
30
+
31
+ /** Resolve the project root without crossing the nearest Git working-tree boundary. */
32
+ export function projectRoot(cwd: string): string {
33
+ const input = isAbsolute(cwd) ? cwd : resolve(cwd);
34
+ let dir: string;
35
+ try {
36
+ dir = canonical(input);
37
+ if (!lstatSync(dir).isDirectory()) throw new Error(`${cwd} is not a directory`);
38
+ } catch (error) {
39
+ throw new Error(`cannot resolve project directory ${cwd}: ${(error as Error).message}`);
40
+ }
41
+
42
+ const boundary = gitRoot(dir);
43
+ const stop = boundary ?? parseRoot(dir);
44
+ for (let current = dir; ; current = dirname(current)) {
45
+ if (hasConfig(current)) return canonical(current);
46
+ if (current === stop || current === dirname(current)) break;
47
+ }
48
+ return boundary ?? dir;
49
+ }
50
+
51
+ function parseRoot(dir: string): string {
52
+ let current = dir;
53
+ while (dirname(current) !== current) current = dirname(current);
54
+ return current;
55
+ }
56
+
57
+ function stateMarker(stateDir: string): string | undefined {
58
+ for (const file of [join(stateDir, "project.json"), join(stateDir, "status.json")]) {
59
+ try {
60
+ const value = JSON.parse(readFileSync(file, "utf8")) as { root?: string; cwd?: string };
61
+ const root = value.root ?? value.cwd;
62
+ if (typeof root === "string") return canonical(root);
63
+ } catch {
64
+ // A missing or incomplete marker is not a valid proof of ownership.
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ export interface ProjectContext {
71
+ root: string;
72
+ stateDir: string;
73
+ }
74
+
75
+ /** Resolve a project and accept an inherited state directory only with an ownership proof. */
76
+ export function projectContext(cwd: string, env: NodeJS.ProcessEnv = process.env): ProjectContext {
77
+ const root = projectRoot(cwd);
78
+ const override = env.AGENTHUB_STATE_DIR?.trim();
79
+ if (!override) return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
80
+
81
+ const declared = env.AGENTHUB_PROJECT_DIR?.trim();
82
+ const stateDir = canonical(override);
83
+ const recorded = stateMarker(stateDir);
84
+ if (recorded === root) return { root, stateDir };
85
+ // A marker for another project is authoritative; an inherited mismatch must not
86
+ // redirect this cwd. The explicit pair is only for a freshly initialized state.
87
+ if (!recorded && declared && canonical(declared) === root) return { root, stateDir };
88
+ return { root, stateDir: canonical(join(root, ".agenthub", "state")) };
89
+ }
90
+
91
+ export function hubHome(env: NodeJS.ProcessEnv = process.env): string {
92
+ return canonical(env.AGENTHUB_HOME?.trim() || join(homedir(), ".agenthub"));
93
+ }
@@ -0,0 +1,204 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { canonicalPath, hubHome, projectRoot } from "./project.ts";
6
+
7
+ const BASE_PORT = 4600;
8
+ const STRIDE = 10;
9
+ const MAX_PORT = 65535 - 3;
10
+ const LEGACY_DONE = "legacy_ports_imported";
11
+ type Tx = { immediate(): void };
12
+ // SQLite's busy_timeout bounds contention; never multiply it through unbounded retries.
13
+ const runTx = (tx: Tx): void => { tx.immediate(); };
14
+
15
+ export interface Project {
16
+ id: string;
17
+ root: string;
18
+ stateDir: string;
19
+ basePort: number;
20
+ instanceId: string | null;
21
+ pid: number | null;
22
+ }
23
+
24
+ const idFor = (root: string) => `p_${createHash("sha256").update(root).digest("hex").slice(0, 24)}`;
25
+ // Preserve the original identifier of missing legacy checkouts as a reservation.
26
+ const legacyRoot = (path: string) => { try { return realpathSync(path); } catch { return resolve(path); } };
27
+ const canonicalState = canonicalPath;
28
+ const alive = (pid: number): boolean | undefined => {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (error) {
33
+ const code = (error as NodeJS.ErrnoException).code;
34
+ if (code === "ESRCH") return false;
35
+ return undefined;
36
+ }
37
+ };
38
+
39
+ export class Registry {
40
+ readonly path: string;
41
+ private readonly db: Database;
42
+
43
+ constructor(path = join(hubHome(), "registry.db")) {
44
+ this.path = path;
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ this.db = new Database(path, { create: true });
47
+ this.db.run("PRAGMA busy_timeout = 5000");
48
+ try {
49
+ this.db.run("PRAGMA journal_mode = WAL");
50
+ } catch (error) {
51
+ const code = (error as NodeJS.ErrnoException).code;
52
+ if (code !== "SQLITE_BUSY" && code !== "SQLITE_LOCKED") throw error;
53
+ }
54
+ this.db.run(`CREATE TABLE IF NOT EXISTS projects (
55
+ id TEXT PRIMARY KEY, root TEXT NOT NULL UNIQUE, state_dir TEXT NOT NULL UNIQUE,
56
+ base_port INTEGER NOT NULL UNIQUE, instance_id TEXT, pid INTEGER, claimed_at INTEGER
57
+ )`);
58
+ this.db.run("CREATE TABLE IF NOT EXISTS registry_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
59
+ this.importLegacy();
60
+ }
61
+
62
+ register(root: string, stateDir?: string): Project {
63
+ const canonicalRoot = projectRoot(root);
64
+ const explicitState = stateDir !== undefined;
65
+ const stateInput = stateDir ? (stateDir.startsWith("/") ? stateDir : join(canonicalRoot, stateDir)) : join(canonicalRoot, ".agenthub", "state");
66
+ const state = canonicalState(stateInput);
67
+ const id = idFor(canonicalRoot);
68
+ const tx = this.db.transaction(() => {
69
+ const existing = this.row(id);
70
+ if (existing) {
71
+ if (existing.root !== canonicalRoot) throw new Error(`registry id collision for ${canonicalRoot}`);
72
+ if (explicitState && existing.stateDir !== state) {
73
+ if (existing.instanceId && (existing.pid === null || alive(existing.pid) !== false)) throw new Error(`cannot relocate live or uncertain project ${id}`);
74
+ this.db.query("UPDATE projects SET state_dir = ? WHERE id = ?").run(state, id);
75
+ }
76
+ return;
77
+ }
78
+ const basePort = this.nextBase();
79
+ try {
80
+ this.db.query("INSERT INTO projects (id, root, state_dir, base_port) VALUES (?, ?, ?, ?)").run(id, canonicalRoot, state, basePort);
81
+ } catch (error) {
82
+ throw new Error(`cannot register ${canonicalRoot}: ${(error as Error).message}`);
83
+ }
84
+ });
85
+ runTx(tx as Tx);
86
+ return this.get(id)!;
87
+ }
88
+
89
+ get(id: string): Project | undefined {
90
+ return this.row(id) ?? undefined;
91
+ }
92
+
93
+ list(): Project[] {
94
+ return this.db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects ORDER BY root").all() as Project[];
95
+ }
96
+
97
+ allocate(root: string): number {
98
+ return this.register(root).basePort;
99
+ }
100
+
101
+ reallocate(id: string, instanceId: string): number {
102
+ let base = 0;
103
+ const tx = this.db.transaction(() => {
104
+ const project = this.row(id);
105
+ if (!project || project.instanceId !== instanceId) throw new Error(`project ${id} is not owned by instance ${instanceId}`);
106
+ base = this.nextBase(project.basePort);
107
+ this.db.query("UPDATE projects SET base_port = ? WHERE id = ?").run(base, id);
108
+ });
109
+ runTx(tx as Tx);
110
+ return base;
111
+ }
112
+
113
+ claim(id: string, instanceId: string, pid: number): boolean {
114
+ let claimed = false;
115
+ const tx = this.db.transaction(() => {
116
+ const project = this.row(id);
117
+ if (!project) return;
118
+ if (project.instanceId && project.instanceId !== instanceId) {
119
+ if (project.pid === null) return;
120
+ const state = alive(project.pid);
121
+ if (state !== false) return;
122
+ }
123
+ this.db.query("UPDATE projects SET instance_id = ?, pid = ?, claimed_at = ? WHERE id = ?").run(instanceId, pid, Date.now(), id);
124
+ claimed = true;
125
+ });
126
+ runTx(tx as Tx);
127
+ return claimed;
128
+ }
129
+
130
+ release(id: string, instanceId: string): void {
131
+ this.db.query("UPDATE projects SET instance_id = NULL, pid = NULL, claimed_at = NULL WHERE id = ? AND instance_id = ?").run(id, instanceId);
132
+ }
133
+
134
+ remove(id: string): void {
135
+ const tx = this.db.transaction(() => {
136
+ const project = this.row(id);
137
+ if (!project) return;
138
+ if (project.instanceId) {
139
+ if (project.pid === null || alive(project.pid) !== false) throw new Error(`project ${id} has a live or uncertain claim`);
140
+ } else if (existsSync(join(project.stateDir, "status.json")) || existsSync(join(project.stateDir, "control-token"))) {
141
+ let pid: number | undefined;
142
+ try {
143
+ const status = JSON.parse(readFileSync(join(project.stateDir, "status.json"), "utf8")) as { pid?: number };
144
+ pid = typeof status.pid === "number" ? status.pid : undefined;
145
+ } catch {
146
+ // A state directory without a readable owner marker is uncertain.
147
+ }
148
+ if (pid === undefined || alive(pid) !== false) throw new Error(`project ${id} has a live or uncertain legacy state`);
149
+ }
150
+ this.db.query("DELETE FROM projects WHERE id = ?").run(id);
151
+ });
152
+ runTx(tx as Tx);
153
+ }
154
+
155
+ close(): void {
156
+ this.db.close();
157
+ }
158
+
159
+ private row(id: string): Project | null {
160
+ return this.db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects WHERE id = ?").get(id) as Project | null;
161
+ }
162
+
163
+ private nextBase(after = BASE_PORT - STRIDE): number {
164
+ const used = new Set((this.db.query("SELECT base_port as basePort FROM projects").all() as { basePort: number }[]).map((r) => r.basePort));
165
+ let base = Math.max(BASE_PORT - STRIDE, after);
166
+ do {
167
+ base += STRIDE;
168
+ if (base > MAX_PORT) throw new Error(`no available project port range below ${MAX_PORT}`);
169
+ } while (used.has(base));
170
+ return base;
171
+ }
172
+
173
+ private importLegacy(): void {
174
+ const legacy = join(dirname(this.path), "ports.json");
175
+ const tx = this.db.transaction(() => {
176
+ if (this.db.query("SELECT 1 FROM registry_meta WHERE key = ?").get(LEGACY_DONE)) return;
177
+ if (existsSync(legacy)) {
178
+ let parsed: unknown;
179
+ try {
180
+ parsed = JSON.parse(readFileSync(legacy, "utf8"));
181
+ } catch (error) {
182
+ throw new Error(`cannot import legacy port registry ${legacy}: ${(error as Error).message}`);
183
+ }
184
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`cannot import legacy port registry ${legacy}: expected an object`);
185
+ const seen = new Map<string, number>();
186
+ for (const [rawRoot, rawPort] of Object.entries(parsed as Record<string, unknown>)) {
187
+ if (typeof rawPort !== "number" || !Number.isInteger(rawPort) || rawPort < BASE_PORT || rawPort > MAX_PORT || rawPort % STRIDE !== 0) throw new Error(`cannot import legacy port registry ${legacy}: invalid port for ${rawRoot}`);
188
+ const root = legacyRoot(rawRoot);
189
+ const alias = seen.get(root);
190
+ if (alias !== undefined && alias !== rawPort) throw new Error(`cannot import legacy port registry ${legacy}: aliases for ${root} have conflicting ports`);
191
+ if (alias !== undefined) continue;
192
+ if ([...seen.values()].includes(rawPort)) throw new Error(`cannot import legacy port registry ${legacy}: conflicting port ${rawPort}`);
193
+ seen.set(root, rawPort);
194
+ const id = idFor(root);
195
+ const existing = this.row(id);
196
+ if (existing && existing.basePort !== rawPort) throw new Error(`legacy port for ${root} conflicts with registry`);
197
+ if (!existing) this.db.query("INSERT INTO projects (id, root, state_dir, base_port) VALUES (?, ?, ?, ?)").run(id, root, join(root, ".agenthub", "state"), rawPort);
198
+ }
199
+ }
200
+ this.db.query("INSERT INTO registry_meta (key, value) VALUES (?, ?)").run(LEGACY_DONE, String(Date.now()));
201
+ });
202
+ runTx(tx as Tx);
203
+ }
204
+ }
package/src/hub/ui.ts CHANGED
@@ -2,8 +2,10 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
 
4
4
  export interface DashboardOptions {
5
- snapshot: (after: number) => unknown;
6
- action: (input: Record<string, unknown>) => Promise<unknown>;
5
+ snapshot: (after: number, input?: Record<string, unknown>) => unknown | Promise<unknown>;
6
+ action: (input: Record<string, unknown>) => unknown | Promise<unknown>;
7
+ projects?: () => unknown | Promise<unknown>;
8
+ selectedProjectId?: string;
7
9
  /** Injectable clock for expiry tests; production uses wall time. */
8
10
  now?: () => number;
9
11
  }
@@ -49,7 +51,7 @@ export function startDashboard(options: DashboardOptions) {
49
51
  if (req.method !== "POST") return reject(405, "POST required");
50
52
  if (requestOrigin !== origin) return reject(403, "origin required");
51
53
  if (req.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return reject(415, "JSON required");
52
- if (!["/session", "/snapshot", "/action"].includes(path)) return reject(404, "not found");
54
+ if (!["/session", "/projects", "/snapshot", "/action"].includes(path)) return reject(404, "not found");
53
55
  prune(tickets);
54
56
  prune(sessions);
55
57
  let cookie: string | undefined;
@@ -76,10 +78,33 @@ export function startDashboard(options: DashboardOptions) {
76
78
  sessions.set(session, now() + SESSION_MS);
77
79
  return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
78
80
  }
81
+ if (path === "/projects") {
82
+ if (!options.projects) {
83
+ const result: Record<string, unknown> = { ok: true, mode: "project" };
84
+ if (options.selectedProjectId) result.selectedProjectId = options.selectedProjectId;
85
+ return json(result);
86
+ }
87
+ try {
88
+ return json(await options.projects());
89
+ } catch {
90
+ return reject(400, "project listing failed; check its inputs and the terminal");
91
+ }
92
+ }
79
93
  if (path === "/snapshot") {
80
94
  const after = input.after ?? 0;
81
95
  if (typeof after !== "number" || !Number.isSafeInteger(after) || after < 0) return reject(400, "invalid cursor");
82
- return json(options.snapshot(after));
96
+ try {
97
+ const result = await options.snapshot(after, input);
98
+ if (!result || typeof result !== "object" || Array.isArray(result)) return json(result);
99
+ const response = { ...(result as Record<string, unknown>) };
100
+ if (typeof response.projectId === "string" && typeof input.projectId === "string" && response.projectId !== input.projectId) return reject(409, "snapshot project changed; refresh the dashboard");
101
+ if (typeof response.instanceId === "string" && typeof input.instanceId === "string" && response.instanceId !== input.instanceId) return reject(409, "snapshot instance changed; refresh the dashboard");
102
+ if (typeof input.projectId === "string" && response.projectId === undefined) response.projectId = input.projectId;
103
+ if (typeof input.instanceId === "string" && response.instanceId === undefined) response.instanceId = input.instanceId;
104
+ return json(response);
105
+ } catch {
106
+ return reject(400, "snapshot failed; check its inputs and the terminal");
107
+ }
83
108
  }
84
109
  try {
85
110
  return json(await options.action(input));