@staix/agent-hub 0.3.2 → 0.5.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,89 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Database } from "bun:sqlite";
5
+ import { hubHome } from "./project.ts";
6
+
7
+ export const operationIdPattern = /^[a-f0-9-]{36}$/;
8
+ export function operationPath(id: string, home = hubHome()): string {
9
+ if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
10
+ return join(home, "recovery", `${id}.json`);
11
+ }
12
+
13
+ /** Private state only. Never put tokens or transcript/message bodies in an operation receipt. */
14
+ export function atomicPrivateJSON(file: string, value: unknown): void {
15
+ const temporary = `${file}.${randomUUID()}.tmp`;
16
+ try {
17
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
18
+ chmodSync(temporary, 0o600);
19
+ renameSync(temporary, file);
20
+ } finally { rmSync(temporary, { force: true }); }
21
+ }
22
+
23
+ export function readOperation<T>(id: string, home = hubHome()): T {
24
+ return JSON.parse(readFileSync(operationPath(id, home), "utf8")) as T;
25
+ }
26
+
27
+ export function writeOperation(id: string, value: unknown, home = hubHome()): void {
28
+ mkdirSync(join(home, "recovery"), { recursive: true, mode: 0o700 });
29
+ atomicPrivateJSON(operationPath(id, home), value);
30
+ }
31
+
32
+ export function recoveryLock(home = hubHome()): string | undefined {
33
+ const file = join(home, "recovery.lock");
34
+ try {
35
+ const data = JSON.parse(readFileSync(file, "utf8"));
36
+ if (!operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
37
+ return data.operationId;
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw new Error("recovery lock is unreadable; inspect it before changing runtimes", { cause: error });
41
+ }
42
+ }
43
+
44
+ export function acquireRecoveryLock(id: string, home = hubHome()): void {
45
+ operationPath(id, home);
46
+ mkdirSync(home, { recursive: true, mode: 0o700 });
47
+ const existing = recoveryLock(home);
48
+ if (existing === id) return;
49
+ try { writeFileSync(join(home, "recovery.lock"), JSON.stringify({ operationId: id }), { mode: 0o600, flag: "wx" }); }
50
+ catch { throw new Error(`another recovery operation owns this machine: ${recoveryLock(home) ?? "unknown"}`); }
51
+ }
52
+
53
+ export function releaseRecoveryLock(id: string, home = hubHome()): void {
54
+ if (recoveryLock(home) !== id) throw new Error("recovery lock ownership changed");
55
+ rmSync(join(home, "recovery.lock"));
56
+ }
57
+
58
+ /** Used by CLI and manager lifecycle paths. A detached operation carries its own ID. */
59
+ export function assertLifecycleAvailable(): void {
60
+ const owner = recoveryLock();
61
+ if (owner && process.env.AGENTHUB_RECOVERY_OPERATION !== owner) {
62
+ throw new Error(`recovery operation ${owner} is active; use ahub recovery status|resume ${owner}`);
63
+ }
64
+ }
65
+
66
+ /** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
67
+ export function claimRunner(id: string, home = hubHome()): () => void {
68
+ const path = `${operationPath(id, home)}.runner.db`;
69
+ const db = new Database(path, { create: true });
70
+ chmodSync(path, 0o600);
71
+ const nonce = randomUUID();
72
+ try {
73
+ db.run("PRAGMA busy_timeout = 3000");
74
+ db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL)");
75
+ db.transaction(() => {
76
+ const row = db.query("SELECT pid FROM runner WHERE slot = 1").get() as { pid: number } | null;
77
+ if (row) {
78
+ if (!Number.isSafeInteger(row.pid) || row.pid < 1) throw new Error("invalid recovery runner ownership");
79
+ try { process.kill(row.pid, 0); throw new Error(`recovery runner ${row.pid} is still alive`); }
80
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
81
+ }
82
+ db.query("INSERT OR REPLACE INTO runner (slot, pid, nonce) VALUES (1, ?, ?)").run(process.pid, nonce);
83
+ }).immediate();
84
+ } catch (error) { db.close(); throw error; }
85
+ return () => {
86
+ db.query("DELETE FROM runner WHERE slot = 1 AND nonce = ?").run(nonce);
87
+ db.close();
88
+ };
89
+ }
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import type { BusSnapshot } from "./bus.ts";
5
+ import type { PeerId, PeerState } from "./envelope.ts";
6
+ import { MAX_HOP } from "./envelope.ts";
7
+
8
+ export const RESTART_SCHEMA_VERSION = 1;
9
+
10
+ export type RecoveryPhase = "preparing" | "prepared" | "restored" | "released";
11
+
12
+ export interface RestartPeerSnapshot {
13
+ id: PeerId;
14
+ state: PeerState;
15
+ queueIds: string[];
16
+ launch?: Record<string, unknown>;
17
+ /** Safe native correlation metadata. Never include prompt/message text here. */
18
+ threadId?: string;
19
+ sessionId?: string;
20
+ }
21
+
22
+ export interface RestartSnapshot {
23
+ schemaVersion: 1;
24
+ projectRoot: string;
25
+ projectId: string;
26
+ sourceInstanceId: string;
27
+ operationId: string;
28
+ committedAt: number;
29
+ bus: BusSnapshot;
30
+ manualPaused: PeerId[];
31
+ peers: RestartPeerSnapshot[];
32
+ integrity?: {
33
+ queues: Record<string, string[]>;
34
+ manualPaused: PeerId[];
35
+ boardDigest: string;
36
+ budgetDigest: string;
37
+ };
38
+ }
39
+
40
+ export interface RestartReadExpectation {
41
+ projectRoot: string;
42
+ projectId: string;
43
+ operationId?: string;
44
+ }
45
+
46
+ export function restartPath(stateDir: string): string {
47
+ return join(stateDir, "restart.json");
48
+ }
49
+
50
+ export function releasedRestartPath(stateDir: string, operationId: string): string {
51
+ const safe = createHash("sha256").update(operationId).digest("hex");
52
+ return join(stateDir, `restart.released.${safe}.json`);
53
+ }
54
+
55
+ /** Read only a matching controlled-restart snapshot. Unknown or malformed state is ignored and must block recovery upstream. */
56
+ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpectation): RestartSnapshot | undefined {
57
+ let parsed: unknown;
58
+ try { parsed = JSON.parse(readFileSync(restartPath(stateDir), "utf8")); } catch { return undefined; }
59
+ if (!parsed || typeof parsed !== "object") return undefined;
60
+ const s = parsed as Partial<RestartSnapshot>;
61
+ if (s.schemaVersion !== RESTART_SCHEMA_VERSION || s.projectRoot !== expected.projectRoot || s.projectId !== expected.projectId || typeof s.operationId !== "string" || !s.operationId || typeof s.sourceInstanceId !== "string" || !s.sourceInstanceId || !s.bus) return undefined;
62
+ if (expected.operationId !== undefined && s.operationId !== expected.operationId) return undefined;
63
+ const peerId = (value: unknown): value is string => typeof value === "string" && /^(?:hub|user|[a-z][a-z0-9-]{0,31})$/.test(value);
64
+ const isEnvelope = (value: unknown, seen = false): boolean => {
65
+ if (!value || typeof value !== "object") return false;
66
+ const e = value as Record<string, unknown>;
67
+ return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) && ["chat", "task", "review", "status", "budget", "presence"].includes(e.kind as string) && ["important", "status", "fyi"].includes(e.priority as string) && (e.to === undefined || (Array.isArray(e.to) && e.to.every(peerId)));
68
+ };
69
+ const bus = s.bus as Partial<BusSnapshot>;
70
+ if (bus.schemaVersion !== 1 || !bus.queues || typeof bus.queues !== "object" || !bus.prefaces || typeof bus.prefaces !== "object" || !Array.isArray(bus.seen) || !bus.attempts || typeof bus.attempts !== "object" || !Array.isArray(bus.withdrawn)) return undefined;
71
+ if (!Object.keys(bus.queues).every(peerId) || !Object.keys(bus.prefaces).every(peerId) || !Object.values(bus.queues).every((queue) => Array.isArray(queue) && queue.every((e) => isEnvelope(e))) || !Object.values(bus.prefaces).every((e) => isEnvelope(e)) || !bus.seen.every((e) => isEnvelope(e, true)) || !bus.withdrawn.every((id) => typeof id === "string" && id.length > 0) || !Object.values(bus.attempts).every((n) => Number.isSafeInteger(n) && n > 0)) return undefined;
72
+ 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;
73
+ 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;
74
+ return s as RestartSnapshot;
75
+ }
76
+
77
+ /** Atomic, owner-readable recovery state. The file never carries delivery bodies outside this 0600 path. */
78
+ export function writeRestartSnapshot(stateDir: string, snapshot: RestartSnapshot): void {
79
+ mkdirSync(stateDir, { recursive: true });
80
+ const file = restartPath(stateDir);
81
+ const tmp = `${file}.${snapshot.sourceInstanceId}.tmp`;
82
+ writeFileSync(tmp, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
83
+ chmodSync(tmp, 0o600);
84
+ renameSync(tmp, file);
85
+ chmodSync(file, 0o600);
86
+ }
87
+
88
+ export function removeRestartSnapshot(stateDir: string): void {
89
+ try { unlinkSync(restartPath(stateDir)); }
90
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
91
+ }
92
+
93
+ /** Preserve an uncertain release outcome for manual reconciliation without making it authoritative on startup. */
94
+ export function archiveRestartSnapshot(stateDir: string, operationId: string): void {
95
+ renameSync(restartPath(stateDir), releasedRestartPath(stateDir, operationId));
96
+ chmodSync(releasedRestartPath(stateDir, operationId), 0o600);
97
+ }
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));
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { basename } from "node:path";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { readFileSync } from "node:fs";
3
4
  import type { PeerId } from "../hub/envelope.ts";
4
5
  import type { MemoryClient } from "./client.ts";
5
6
 
@@ -11,11 +12,33 @@ const HEADER =
11
12
  "Shared project memory (claude-mem): recent work by the agents in this project. Reference only, not a request. " +
12
13
  "Fetch details by id with your memory tools if you need them.";
13
14
 
14
- /** Project names from the outermost superproject down to this repo: claude-mem treats the last one as primary. */
15
+ /** Project names from the outermost superproject down to this repo. */
15
16
  export function projectChain(cwd: string): string[] {
16
17
  const git = (dir: string, arg: string) => spawnSync("git", ["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
18
+ const top = git(cwd, "--show-toplevel");
19
+ if (!top) return [basename(cwd)];
20
+
21
+ // claude-mem's native identity for a worktree is [parent, parent/worktree].
22
+ // The worktree's .git file points into <parent>/.git/worktrees/<name>; the
23
+ // ordinary superproject walk below remains unchanged for repos and submodules.
24
+ const gitFile = resolve(top, ".git");
25
+ try {
26
+ const match = readFileSync(gitFile, "utf8").trim().match(/^gitdir:\s*(.+)$/i);
27
+ if (match) {
28
+ const gitDir = resolve(top, match[1]!);
29
+ if (/[/\\]\.git[/\\]worktrees[/\\][^/\\]+$/.test(gitDir)) {
30
+ const parentRoot = dirname(dirname(dirname(gitDir)));
31
+ const parent = projectChain(parentRoot);
32
+ const parentName = basename(parentRoot);
33
+ return [...parent, `${parentName}/${basename(top)}`];
34
+ }
35
+ }
36
+ } catch {
37
+ // A missing or unreadable .git file falls through to git's normal chain.
38
+ }
39
+
17
40
  const chain: string[] = [];
18
- for (let dir = git(cwd, "--show-toplevel"); dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
41
+ for (let dir = top; dir && chain.length < 8; dir = git(dir, "--show-superproject-working-tree")) {
19
42
  chain.unshift(basename(dir));
20
43
  }
21
44
  return chain.length ? chain : [basename(cwd)];
@@ -4,9 +4,11 @@ import { join } from "node:path";
4
4
  import type { Routing } from "../hub/routing.ts";
5
5
  import type { OmniRoute } from "../omniroute/client.ts";
6
6
  import { KEY_ENV, switchyardToml } from "./config.ts";
7
+ import { stopOwnedProcess } from "../hub/child-process.ts";
7
8
 
8
9
  class GatewayDown extends Error {}
9
10
 
11
+
10
12
  export interface SidecarOptions {
11
13
  routing: Routing;
12
14
  omni: OmniRoute;
@@ -25,6 +27,9 @@ export interface SidecarOptions {
25
27
  export class Sidecar {
26
28
  private proc: ChildProcess | undefined;
27
29
  private starting: Promise<string | undefined> | undefined;
30
+ private generation = 0;
31
+ private stopped = false;
32
+ private stopping: Promise<void> | undefined;
28
33
  private off = "";
29
34
  /** The gateway URL the running sidecar was generated with; calls through it go there whatever the client probes later. */
30
35
  upstream: string | undefined;
@@ -37,7 +42,7 @@ export class Sidecar {
37
42
 
38
43
  /** Base URL to send chat calls to, or undefined when the hub should call OmniRoute directly. */
39
44
  endpoint(): Promise<string | undefined> {
40
- if (this.off) return Promise.resolve(undefined);
45
+ if (this.off || this.stopped) return Promise.resolve(undefined);
41
46
  return (this.starting ??= this.start().catch((e: Error) => {
42
47
  // An unreachable gateway says nothing about Switchyard: this call goes direct, the next one tries the sidecar again.
43
48
  if (e instanceof GatewayDown) this.starting = undefined;
@@ -51,22 +56,34 @@ export class Sidecar {
51
56
  if (this.off) return;
52
57
  this.off = reason;
53
58
  this.opts.log(`switchyard: off, falling back to fixed_model on OmniRoute (${reason})`);
54
- this.stop();
59
+ void this.stop().catch((e: Error) => this.opts.log(`switchyard: shutdown incomplete (${e.message})`));
55
60
  }
56
61
 
57
- stop(): void {
58
- this.proc?.kill();
59
- this.proc = undefined;
62
+ async stop(): Promise<void> {
63
+ if (this.stopping) return this.stopping;
64
+ this.stopped = true;
65
+ ++this.generation;
66
+ const proc = this.proc;
67
+ // Preserve the historical synchronous file cleanup; the returned promise
68
+ // still represents the child process shutdown and must be awaited by the hub.
60
69
  rmSync(join(this.opts.stateDir, "switchyard.toml"), { force: true });
70
+ this.stopping = (async () => {
71
+ if (proc) await stopOwnedProcess(proc);
72
+ if (this.proc === proc) this.proc = undefined;
73
+ })();
74
+ return this.stopping;
61
75
  }
62
76
 
63
77
  private async start(): Promise<string | undefined> {
78
+ const generation = this.generation;
79
+ if (this.stopped) return undefined;
64
80
  const { routing, omni, stateDir, port, log } = this.opts;
65
81
  if (!Object.keys(routing.routes).length) throw new Error("routing.toml defines no routes");
66
82
  const bin = this.opts.bin ?? process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server";
67
83
  const baseUrl = await omni.base();
68
84
  const key = omni.apiKey();
69
85
  if (!baseUrl || !key) throw new GatewayDown("no OmniRoute endpoint or key");
86
+ if (this.stopped || generation !== this.generation) return undefined;
70
87
  this.upstream = baseUrl;
71
88
 
72
89
  const file = join(stateDir, "switchyard.toml");
@@ -79,6 +96,11 @@ export class Sidecar {
79
96
 
80
97
  // Switchyard's default host is 0.0.0.0; the hub binds loopback only.
81
98
  const proc = spawn(bin, ["--config", file, "--host", "127.0.0.1", "--port", String(port)], { env, stdio: ["ignore", "ignore", "pipe"] });
99
+ if (this.stopped || generation !== this.generation) {
100
+ proc.kill("SIGTERM");
101
+ await stopOwnedProcess(proc);
102
+ return undefined;
103
+ }
82
104
  this.proc = proc;
83
105
  let exited = false;
84
106
  // Switchyard logs every request at INFO on stderr; only problems belong in hub.log.
@@ -91,12 +113,24 @@ export class Sidecar {
91
113
  if (this.proc === proc) this.disable(`exited with code ${code}`);
92
114
  });
93
115
  for (let i = 0; i < 100 && !exited; i++) {
116
+ if (this.stopped || generation !== this.generation) {
117
+ await stopOwnedProcess(proc);
118
+ if (this.proc === proc) this.proc = undefined;
119
+ return undefined;
120
+ }
94
121
  if (await fetch(`http://127.0.0.1:${port}/health`).then((r) => r.ok, () => false)) {
122
+ if (this.stopped || generation !== this.generation) {
123
+ await stopOwnedProcess(proc);
124
+ if (this.proc === proc) this.proc = undefined;
125
+ return undefined;
126
+ }
95
127
  log(`switchyard: up on 127.0.0.1:${port}, routes ${Object.keys(routing.routes).join(", ")}`);
96
128
  return `http://127.0.0.1:${port}/v1`;
97
129
  }
98
130
  await Bun.sleep(100);
99
131
  }
132
+ await stopOwnedProcess(proc);
133
+ if (this.proc === proc) this.proc = undefined;
100
134
  throw new Error(exited ? "exited during startup" : "not healthy within 10 s");
101
135
  }
102
136
  }