@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.
- package/CHANGELOG.md +22 -0
- package/README.md +58 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +148 -20
- package/src/adapters/acp.ts +15 -3
- package/src/adapters/claude-channel.ts +9 -3
- package/src/adapters/codex-appserver.ts +47 -9
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/main.ts +188 -68
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +580 -0
- package/src/cli/upgrade-runtime.ts +299 -0
- package/src/cli/upgrade.ts +295 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +55 -0
- package/src/hub/control-client.ts +50 -9
- package/src/hub/daemon.ts +326 -45
- package/src/hub/lifecycle.ts +189 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/restart.ts +97 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createServer, type Server } from "node:net";
|
|
6
|
+
import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
7
|
+
import { startDaemon } from "./daemon.ts";
|
|
8
|
+
import { Registry, type Project } from "./registry.ts";
|
|
9
|
+
import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
|
|
10
|
+
import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
|
|
11
|
+
|
|
12
|
+
export type ProjectInspection = {
|
|
13
|
+
state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
|
|
14
|
+
status?: any;
|
|
15
|
+
error?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function processAlive(pid: unknown): boolean | undefined {
|
|
19
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
20
|
+
try { process.kill(pid, 0); return true; }
|
|
21
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ESRCH" ? false : undefined; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A port or PID alone is never evidence that the selected project's hub is alive. */
|
|
25
|
+
export async function inspectProject(project: Project): Promise<ProjectInspection> {
|
|
26
|
+
if (!existsSync(project.root)) return { state: "missing", error: "project directory is missing" };
|
|
27
|
+
const control = readControl(project.stateDir);
|
|
28
|
+
if (control) {
|
|
29
|
+
if (processAlive(control.pid) === false) return { state: "stopped" };
|
|
30
|
+
if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
|
|
31
|
+
return { state: "incompatible", error: `hub protocol ${control.protocol}; stop with its matching CLI before upgrading` };
|
|
32
|
+
}
|
|
33
|
+
if ((control.projectId && control.projectId !== project.id) || control.cwd !== project.root) {
|
|
34
|
+
return { state: "unavailable", error: "state directory belongs to a different project" };
|
|
35
|
+
}
|
|
36
|
+
let client: ControlClient | undefined;
|
|
37
|
+
try {
|
|
38
|
+
// Older manifests omit identity. Authenticate first, then require the full current contract.
|
|
39
|
+
client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root,
|
|
40
|
+
...(control.projectId ? { projectId: project.id } : {}), ...(control.instanceId ? { instanceId: control.instanceId } : {}) });
|
|
41
|
+
const response = await client.request({ t: "status" }, 3000);
|
|
42
|
+
if (!response.status || response.status.projectId !== project.id || !response.status.instanceId || response.status.cwd !== project.root) {
|
|
43
|
+
return { state: "unavailable", error: "authenticated hub identity does not match registration" };
|
|
44
|
+
}
|
|
45
|
+
if (project.instanceId && project.instanceId !== response.status.instanceId && processAlive(project.pid) !== false) {
|
|
46
|
+
return { state: "unavailable", error: "daemon instance differs from the registry claim" };
|
|
47
|
+
}
|
|
48
|
+
return response.status.stopping
|
|
49
|
+
? { state: "stopping", status: response.status, error: "hub shutdown is pending; wait before starting it again" }
|
|
50
|
+
: { state: "running", status: response.status };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { state: (error as { code?: number }).code === 4426 ? "incompatible" : "unavailable", error: (error as Error).message };
|
|
53
|
+
} finally { client?.close(); }
|
|
54
|
+
}
|
|
55
|
+
if (project.instanceId && processAlive(project.pid) !== false) return { state: "starting", error: "daemon owns a startup or shutdown claim" };
|
|
56
|
+
try {
|
|
57
|
+
const pid = Number(readFileSync(join(project.stateDir, "hub.pid"), "utf8").trim());
|
|
58
|
+
if (processAlive(pid) !== false) return { state: "unavailable", error: "state has a live or uncertain owner but no usable control connection" };
|
|
59
|
+
} catch { /* no legacy PID file */ }
|
|
60
|
+
return { state: "stopped" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Probe the full stride's used ports before publishing a daemon; close only our own probes. */
|
|
64
|
+
async function available(base: number): Promise<boolean> {
|
|
65
|
+
const servers: Server[] = [];
|
|
66
|
+
try {
|
|
67
|
+
for (const offset of [CONTROL, CODEX_APP, CODEX_PROXY, SWITCHYARD]) {
|
|
68
|
+
const server = createServer();
|
|
69
|
+
servers.push(server);
|
|
70
|
+
await new Promise<void>((resolve, reject) => {
|
|
71
|
+
server.once("error", reject);
|
|
72
|
+
server.listen(base + offset, "127.0.0.1", () => resolve());
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") return false;
|
|
78
|
+
throw error;
|
|
79
|
+
} finally {
|
|
80
|
+
await Promise.all(servers.map((server) => new Promise<void>((resolve) => {
|
|
81
|
+
if (!server.listening) return resolve();
|
|
82
|
+
server.close(() => resolve());
|
|
83
|
+
})));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Called only in the detached daemon process. A competing starter exits without touching state. */
|
|
88
|
+
export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
|
|
89
|
+
assertLifecycleAvailable();
|
|
90
|
+
const registry = new Registry();
|
|
91
|
+
const instanceId = randomUUID();
|
|
92
|
+
let claimed = false;
|
|
93
|
+
let daemon: Awaited<ReturnType<typeof startDaemon>> | undefined;
|
|
94
|
+
try {
|
|
95
|
+
const before = await inspectProject(registry.get(project.id) ?? project);
|
|
96
|
+
if (before.state === "running" || before.state === "starting") return;
|
|
97
|
+
if (before.state !== "stopped") throw new Error(before.error ?? before.state);
|
|
98
|
+
claimed = registry.claim(project.id, instanceId, process.pid);
|
|
99
|
+
if (!claimed) return;
|
|
100
|
+
let base = registry.get(project.id)!.basePort;
|
|
101
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
102
|
+
if (await available(base)) {
|
|
103
|
+
try {
|
|
104
|
+
daemon = await startDaemon({ cwd: project.root, stateDir: project.stateDir, projectId: project.id, instanceId,
|
|
105
|
+
controlPort: base + CONTROL, codexAppPort: base + CODEX_APP, codexProxyPort: base + CODEX_PROXY,
|
|
106
|
+
switchyardPort: base + SWITCHYARD, unattended });
|
|
107
|
+
break;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
base = registry.reallocate(project.id, instanceId);
|
|
113
|
+
}
|
|
114
|
+
if (!daemon) throw new Error("no free hub port range after 100 attempts");
|
|
115
|
+
const stop = () => { void daemon!.stop().catch((error) => console.error(`shutdown incomplete: ${(error as Error).message}`)); };
|
|
116
|
+
process.on("SIGINT", stop);
|
|
117
|
+
process.on("SIGTERM", stop);
|
|
118
|
+
try { await daemon.stopped; }
|
|
119
|
+
finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); }
|
|
120
|
+
} finally {
|
|
121
|
+
if (claimed) registry.release(project.id, instanceId);
|
|
122
|
+
registry.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
|
|
127
|
+
assertLifecycleAvailable();
|
|
128
|
+
const registry = new Registry();
|
|
129
|
+
try {
|
|
130
|
+
const current = registry.get(project.id);
|
|
131
|
+
if (!current || current.root !== project.root || current.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
132
|
+
const before = await inspectProject(current);
|
|
133
|
+
if (before.state === "running") return before.status;
|
|
134
|
+
if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
|
|
135
|
+
if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
|
|
136
|
+
if (before.state === "stopped") {
|
|
137
|
+
mkdirSync(project.stateDir, { recursive: true });
|
|
138
|
+
const marker = join(project.stateDir, "project.json");
|
|
139
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
140
|
+
writeFileSync(temporary, JSON.stringify({ root: project.root, projectId: project.id }), { mode: 0o600 });
|
|
141
|
+
renameSync(temporary, marker);
|
|
142
|
+
const log = openSync(join(project.stateDir, "hub.log"), "a");
|
|
143
|
+
try {
|
|
144
|
+
const launchEnv = { ...(options.env ?? process.env) };
|
|
145
|
+
// Native sessions can outlive the operation which launched them. An expired
|
|
146
|
+
// operation ID must not make a later ordinary startup load a retired snapshot.
|
|
147
|
+
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
148
|
+
const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
|
|
149
|
+
...(options.unattended ? ["--unattended"] : [])], {
|
|
150
|
+
cwd: project.root, detached: true, stdio: ["ignore", log, log],
|
|
151
|
+
env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
|
152
|
+
AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
|
|
153
|
+
});
|
|
154
|
+
child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
|
|
155
|
+
child.unref();
|
|
156
|
+
} finally { closeSync(log); }
|
|
157
|
+
}
|
|
158
|
+
const deadline = Date.now() + 12_000;
|
|
159
|
+
while (Date.now() < deadline) {
|
|
160
|
+
const observed = await inspectProject(registry.get(project.id) ?? project);
|
|
161
|
+
if (observed.state === "running") return observed.status;
|
|
162
|
+
if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
|
|
163
|
+
await Bun.sleep(100);
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`hub readiness not confirmed; inspect ${join(project.stateDir, "hub.log")} before retrying`);
|
|
166
|
+
} finally { registry.close(); }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
|
|
170
|
+
assertLifecycleAvailable();
|
|
171
|
+
const inspection = await inspectProject(project);
|
|
172
|
+
if (inspection.state === "stopped") return;
|
|
173
|
+
if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
|
|
174
|
+
const instanceId = inspection.status.instanceId as string;
|
|
175
|
+
if (expectedInstance && expectedInstance !== instanceId) throw new Error("hub restarted; refresh before stopping");
|
|
176
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId });
|
|
177
|
+
try {
|
|
178
|
+
const result = await client.request({ t: "kill", instanceId }, 3000);
|
|
179
|
+
if (!result.ok) throw new Error(result.error ?? "hub did not acknowledge shutdown");
|
|
180
|
+
} finally { client.close(); }
|
|
181
|
+
const deadline = Date.now() + 10_000;
|
|
182
|
+
while (Date.now() < deadline) {
|
|
183
|
+
const current = readControl(project.stateDir);
|
|
184
|
+
if (!current) return; // daemon removes its manifest only after owned children stop
|
|
185
|
+
if (current.instanceId !== instanceId) throw new Error("a new hub instance started; it was not stopped");
|
|
186
|
+
await Bun.sleep(100);
|
|
187
|
+
}
|
|
188
|
+
throw new Error("shutdown is still pending; state and ownership were retained");
|
|
189
|
+
}
|
|
@@ -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/peers.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface PeerAdapter {
|
|
|
15
15
|
onState?: (state: PeerState) => void;
|
|
16
16
|
/** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
|
|
17
17
|
onFailed?: (envs: Envelope[]) => void;
|
|
18
|
+
/** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
|
|
19
|
+
recoveryMetadata?(): Record<string, unknown>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export const DEFAULT_WATCHDOG_MS = 300_000;
|
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
|
-
/**
|
|
14
|
-
export function allocatePorts(projectDir: string, registry
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|