@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.
package/src/cli/main.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env bun
2
- import { spawn, spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { ControlClient, readControl } from "../hub/control-client.ts";
6
- import { loadConfig, startDaemon, stateDirFor } from "../hub/daemon.ts";
6
+ import { loadConfig } from "../hub/daemon.ts";
7
+ import { projectContext } from "../hub/project.ts";
8
+ import { Registry, type Project } from "../hub/registry.ts";
9
+ import { inspectProject, startProject, stopProject, runProjectDaemon } from "../hub/lifecycle.ts";
10
+ import { openManager, startManager, stopManager } from "../hub/manager.ts";
7
11
  import type { BusEvent } from "../hub/bus.ts";
8
- import { allocatePorts, CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "../hub/ports.ts";
9
12
  import { OmniRoute } from "../omniroute/client.ts";
10
13
  import { MemoryClient } from "../memory/client.ts";
11
14
  import { init } from "./init.ts";
@@ -17,6 +20,12 @@ import { createInterface } from "node:readline/promises";
17
20
 
18
21
  const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
19
22
 
23
+ ahub --project <path|id> <command> select a repository or worktree explicitly
24
+ ahub projects [--json] list registered projects and live status
25
+ ahub projects remove <id> forget a stopped registration (keeps project files)
26
+ ahub status --all show every registered project
27
+ ahub ui --all [--no-open] open the unified project dashboard
28
+ ahub ui --all --stop stop only the dashboard manager
20
29
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
21
30
  ahub init write .agenthub/config.json and the CLAUDE.md / AGENTS.md marker blocks
22
31
  ahub up [--unattended] start the daemon for this directory
@@ -45,25 +54,69 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
45
54
  ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
46
55
  ahub status | logs [-f] | doctor | kill`;
47
56
 
48
- const cwd = process.cwd();
49
- const stateDir = stateDirFor(cwd);
57
+ const argv = process.argv.slice(2);
58
+ let selector: string | undefined;
59
+ if (argv[0] === "--project") {
60
+ argv.shift();
61
+ selector = argv.shift();
62
+ if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
63
+ }
64
+ const [cmd = "help", ...args] = argv;
65
+ let selected: { root: string; stateDir: string };
66
+ try {
67
+ if (selector) {
68
+ const registry = new Registry();
69
+ try {
70
+ const known = registry.get(selector);
71
+ const context = known ?? projectContext(selector, {});
72
+ const registered = known ?? registry.list().find((p) => p.root === context.root);
73
+ selected = registered ?? context;
74
+ } finally { registry.close(); }
75
+ } else selected = projectContext(process.cwd());
76
+ } catch (error) { fail((error as Error).message); }
77
+ const cwd = selected.root;
78
+ const stateDir = selected.stateDir;
79
+ try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
50
80
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
51
- const [cmd = "help", ...args] = process.argv.slice(2);
81
+ const lifecycle = { inspectProject, startProject, stopProject };
82
+ const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
52
83
 
53
- const connect = () => ControlClient.connect(stateDir, { role: "console" });
84
+ function registeredProject(): Project {
85
+ const registry = new Registry();
86
+ try { return registry.register(cwd, stateDir); }
87
+ finally { registry.close(); }
88
+ }
54
89
 
55
90
  async function healthy(): Promise<boolean> {
56
- const control = readControl(stateDir);
57
- if (!control) return false;
58
- return fetch(control.url.replace("ws:", "http:") + "/healthz").then((r) => r.ok, () => false);
91
+ let hub: ControlClient | undefined;
92
+ try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
93
+ catch { return false; }
94
+ finally { hub?.close(); }
59
95
  }
60
96
 
61
97
  function exec(bin: string, argv: string[]): never {
62
- const res = spawnSync(bin, argv, { stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir } });
98
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
63
99
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
64
100
  process.exit(res.status ?? 1);
65
101
  }
66
102
 
103
+ async function projectRows() {
104
+ const registry = new Registry();
105
+ try { return await Promise.all(registry.list().map(async (project) => ({ ...project, ...await inspectProject(project) }))); }
106
+ finally { registry.close(); }
107
+ }
108
+
109
+ async function printProjects(json = false) {
110
+ const rows = await projectRows();
111
+ if (json) return console.log(JSON.stringify(rows, null, 2));
112
+ for (const row of rows) {
113
+ console.log(`${row.id} ${row.state.padEnd(12)} ${row.root}`);
114
+ if (row.status) console.log(` peers ${Object.keys(row.status.peers ?? {}).length}, control ${row.status.controlPort}, tasks ${JSON.stringify(row.status.tasks ?? {})}`);
115
+ if (row.error) console.log(` ${row.error}`);
116
+ }
117
+ if (!rows.length) console.log("No registered projects. Run ahub init in a project directory.");
118
+ }
119
+
67
120
  function fail(message: string): never {
68
121
  console.error(`ahub: ${message}`);
69
122
  process.exit(1);
@@ -118,19 +171,48 @@ const commands: Record<string, () => Promise<void> | void> = {
118
171
  "--version": () => console.log(VERSION),
119
172
  version: () => console.log(VERSION),
120
173
 
174
+ projects: async () => {
175
+ if (args[0] === "remove") {
176
+ if (args.length !== 2) fail("usage: ahub projects remove <id>");
177
+ const registry = new Registry();
178
+ try { registry.remove(args[1]!); } finally { registry.close(); }
179
+ console.log("registration removed; project files were kept");
180
+ return;
181
+ }
182
+ if (args.some((arg) => arg !== "--json")) fail("usage: ahub projects [--json]");
183
+ await printProjects(args.includes("--json"));
184
+ },
185
+
121
186
  ui: async () => {
122
- if (args.some((arg) => arg !== "--no-open")) fail("usage: ahub ui [--no-open]");
123
- const hub = await connect();
124
- const res = await hub.request({ t: "ui" }, 10_000);
125
- hub.close();
126
- if (!res.ok) fail(res.error);
127
- if (args.includes("--no-open")) return console.log(res.url);
187
+ if (args.some((arg) => !["--no-open", "--all", "--stop"].includes(arg))) fail("usage: ahub ui [--all] [--no-open] | --all --stop");
188
+ if (args.includes("--stop")) {
189
+ if (!args.includes("--all") || args.includes("--no-open")) fail("usage: ahub ui --all --stop");
190
+ await stopManager();
191
+ return console.log("dashboard manager stopped; project hubs were kept running");
192
+ }
193
+ let url: string;
194
+ if (args.includes("--all")) url = await openManager();
195
+ else {
196
+ const hub = await connect();
197
+ try {
198
+ const res = await hub.request({ t: "ui" }, 10_000);
199
+ if (!res.ok) fail(res.error);
200
+ url = res.url;
201
+ } finally { hub.close(); }
202
+ }
203
+ if (args.includes("--no-open")) return console.log(url);
128
204
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
129
- const opened = spawnSync(opener, [res.url], { stdio: "ignore", timeout: 10_000 });
130
- if (opened.error || opened.status !== 0) console.log(`Open this one-time link within 60 seconds:\n${res.url}`);
205
+ const opened = spawnSync(opener, [url], { stdio: "ignore", timeout: 10_000 });
206
+ if (opened.error || opened.status !== 0) console.log(`Open this one-time link within 60 seconds:\n${url}`);
131
207
  else console.log("Dashboard opened. The session expires in one hour; run ahub ui to reopen it.");
132
208
  },
133
209
 
210
+ manager: async () => {
211
+ const manager = await startManager({ lifecycle });
212
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => void manager.stop());
213
+ await manager.stopped;
214
+ },
215
+
134
216
  setup: async () => {
135
217
  const root = join(import.meta.dir, "..", "..");
136
218
  const state = () => {
@@ -158,46 +240,22 @@ const commands: Record<string, () => Promise<void> | void> = {
158
240
 
159
241
  init: () => {
160
242
  const changed = init(cwd);
243
+ registeredProject();
161
244
  console.log(changed.length ? changed.map((p) => `wrote ${p}`).join("\n") : "already up to date");
162
245
  },
163
246
 
164
- // Internal: the detached daemon process started by `ahub up`.
247
+ // Internal: the detached daemon process started by ahub up or the manager.
165
248
  daemon: async () => {
166
- // Long-lived process: one bad adapter callback must not take every peer down. stderr is hub.log.
167
249
  process.on("unhandledRejection", (e) => console.error(`${new Date().toISOString()} unhandled rejection:`, e));
168
250
  process.on("uncaughtException", (e) => console.error(`${new Date().toISOString()} uncaught exception:`, e));
169
- const base = allocatePorts(cwd);
170
- const daemon = await startDaemon({
171
- cwd,
172
- stateDir,
173
- controlPort: base + CONTROL,
174
- codexAppPort: base + CODEX_APP,
175
- codexProxyPort: base + CODEX_PROXY,
176
- switchyardPort: base + SWITCHYARD,
177
- unattended: unattendedEnv || args.includes("--unattended"),
178
- });
179
- for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => void daemon.stop());
180
- await daemon.stopped;
181
- process.exit(0);
251
+ await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
182
252
  },
183
253
 
184
254
  up: async () => {
185
- if (await healthy()) return console.log("hub is already running");
186
255
  const unattended = unattendedEnv || args.includes("--unattended");
187
256
  if (unattended) console.error(UNATTENDED_WARNING);
188
- mkdirSync(stateDir, { recursive: true });
189
- const log = openSync(join(stateDir, "hub.log"), "a");
190
- spawn(process.execPath, [import.meta.path, "daemon", ...(unattended ? ["--unattended"] : [])], {
191
- cwd,
192
- detached: true,
193
- stdio: ["ignore", log, log],
194
- env: { ...process.env, AGENTHUB_STATE_DIR: stateDir },
195
- }).unref();
196
- for (let i = 0; i < 50; i++) {
197
- if (await healthy()) return console.log(`ahub up (${readControl(stateDir)!.url}), state in ${stateDir}`);
198
- await Bun.sleep(100);
199
- }
200
- fail(`daemon did not start; see ${join(stateDir, "hub.log")}`);
257
+ const status = await startProject(registeredProject(), { unattended });
258
+ console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
201
259
  },
202
260
 
203
261
  claude: () => {
@@ -370,6 +428,7 @@ const commands: Record<string, () => Promise<void> | void> = {
370
428
  },
371
429
 
372
430
  status: async () => {
431
+ if (args.includes("--all")) return printProjects(args.includes("--json"));
373
432
  const hub = await connect();
374
433
  const { status } = await hub.request({ t: "status" });
375
434
  hub.close();
@@ -385,23 +444,16 @@ const commands: Record<string, () => Promise<void> | void> = {
385
444
  logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
386
445
 
387
446
  kill: async () => {
388
- const hub = await connect().catch(() => undefined);
389
- if (hub) {
390
- hub.send({ t: "kill" });
391
- await new Promise<void>((r) => (hub.onClose = r));
392
- return console.log("hub stopped");
393
- }
394
- const pidFile = join(stateDir, "hub.pid");
395
- if (!existsSync(pidFile)) return console.log("hub is not running");
396
- // The pid file may be stale and the pid reused: signal it only if it still is a hub daemon.
397
- const pid = Number(readFileSync(pidFile, "utf8"));
398
- const owner = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout ?? "";
399
- if (/main\.ts daemon|ahub daemon/.test(owner)) {
400
- process.kill(pid, "SIGTERM");
401
- return console.log("hub signalled");
447
+ const registry = new Registry();
448
+ let project: Project | undefined;
449
+ try { project = registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
450
+ finally { registry.close(); }
451
+ if (!project) {
452
+ if (!readControl(stateDir) && !existsSync(join(stateDir, "hub.pid"))) return console.log("hub is not running");
453
+ fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
402
454
  }
403
- for (const f of ["hub.pid", "status.json", "control-token"]) rmSync(join(stateDir, f), { force: true });
404
- console.log("hub is not running (removed stale state files)");
455
+ await stopProject(project);
456
+ console.log("hub stopped");
405
457
  },
406
458
 
407
459
  doctor: async () => {
@@ -0,0 +1,44 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+
3
+ /**
4
+ * Stop a child owned by this hub and confirm that it exited. A timeout is an
5
+ * incomplete shutdown, even after SIGKILL, because callers must not reuse a
6
+ * port or claim ownership while the old process may still be alive.
7
+ */
8
+ export async function stopOwnedProcess(proc: ChildProcess, termMs = 1_000, killMs = 2_000): Promise<void> {
9
+ if (proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) return;
10
+
11
+ const waitForExit = (timeoutMs: number): Promise<boolean> =>
12
+ new Promise((resolve) => {
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ const done = (exited: boolean) => {
15
+ if (timer) clearTimeout(timer);
16
+ proc.off("exit", onExit);
17
+ proc.off("error", onError);
18
+ resolve(exited);
19
+ };
20
+ const onExit = () => done(true);
21
+ const onError = () => done(true); // spawn errors have no owned process to wait for
22
+ proc.once("exit", onExit);
23
+ proc.once("error", onError);
24
+ timer = setTimeout(() => done(false), timeoutMs);
25
+ timer.unref?.();
26
+ });
27
+
28
+ try {
29
+ proc.kill("SIGTERM");
30
+ } catch {
31
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
32
+ throw new Error(`owned child ${proc.pid} could not be terminated`);
33
+ }
34
+ if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(termMs))) return;
35
+
36
+ try {
37
+ proc.kill("SIGKILL");
38
+ } catch {
39
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
40
+ throw new Error(`owned child ${proc.pid} could not be killed`);
41
+ }
42
+ if (proc.exitCode !== null || proc.signalCode !== null || (await waitForExit(killMs))) return;
43
+ throw new Error(`owned child ${proc.pid} shutdown incomplete after SIGKILL`);
44
+ }
@@ -1,25 +1,41 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { projectContext } from "./project.ts";
3
4
 
4
5
  export function stateDirFor(cwd: string): string {
5
- return process.env.AGENTHUB_STATE_DIR ?? join(cwd, ".agenthub", "state");
6
+ return projectContext(cwd).stateDir;
6
7
  }
7
8
 
8
9
  /** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap. The plugin is installed apart from the daemon, so they can drift. */
9
- export const PROTOCOL = 6;
10
+ export const PROTOCOL = 7; // project/instance identity and console-only dashboard forwarding
10
11
 
11
12
  export interface Hello {
12
13
  /** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
13
14
  role: "peer" | "console" | "tools";
14
15
  peer?: string;
16
+ projectId?: string;
17
+ instanceId?: string;
18
+ projectRoot?: string;
19
+ }
20
+
21
+ export interface ControlDescriptor {
22
+ url: string;
23
+ token: string;
24
+ projectId?: string;
25
+ instanceId?: string;
26
+ cwd?: string;
27
+ protocol?: number;
28
+ pid?: number;
15
29
  }
16
30
 
17
31
  /** Where the daemon of this state dir listens. Re-read before every connect: port and token change per run. */
18
- export function readControl(stateDir: string): { url: string; token: string } | undefined {
32
+ export function readControl(stateDir: string): ControlDescriptor | undefined {
19
33
  try {
20
34
  const status = JSON.parse(readFileSync(join(stateDir, "status.json"), "utf8"));
21
35
  const token = readFileSync(join(stateDir, "control-token"), "utf8").trim();
22
- return { url: `ws://127.0.0.1:${status.controlPort}`, token };
36
+ if (!Number.isInteger(status.controlPort) || status.controlPort < 1 || status.controlPort > 65535 || !token) return undefined;
37
+ return { url: `ws://127.0.0.1:${status.controlPort}`, token, projectId: status.projectId,
38
+ instanceId: status.instanceId, cwd: status.cwd, protocol: status.protocol, pid: status.pid };
23
39
  } catch {
24
40
  return undefined;
25
41
  }
@@ -30,36 +46,63 @@ export class ControlClient {
30
46
  private nextRid = 1;
31
47
  private readonly pending = new Map<number, (msg: any) => void>();
32
48
  onPush: (msg: any) => void = () => {};
33
- onClose: () => void = () => {};
49
+ /** `code`/`reason`: what the hub closed with, e.g. 4000 when another client attached as the same peer. */
50
+ onClose: (code: number, reason: string) => void = () => {};
34
51
 
35
52
  private constructor(private readonly ws: WebSocket) {}
36
53
 
37
- static connect(stateDir: string, hello: Hello): Promise<ControlClient> {
54
+ static connect(stateDir: string, hello: Hello, timeoutMs = 3000): Promise<ControlClient> {
38
55
  const control = readControl(stateDir);
39
56
  if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
57
+ if (control.protocol !== undefined && control.protocol !== PROTOCOL) {
58
+ return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${PROTOCOL}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
59
+ }
60
+ if ((hello.projectId && hello.projectId !== control.projectId) ||
61
+ (hello.instanceId && hello.instanceId !== control.instanceId) ||
62
+ (hello.projectRoot && hello.projectRoot !== control.cwd)) {
63
+ return Promise.reject(Object.assign(new Error("hub project or instance does not match the selected project"), { code: 4404 }));
64
+ }
65
+ const expected = { projectId: hello.projectId ?? control.projectId, instanceId: hello.instanceId ?? control.instanceId,
66
+ projectRoot: hello.projectRoot ?? control.cwd };
40
67
  return new Promise((resolve, reject) => {
41
68
  const ws = new WebSocket(control.url);
42
69
  const client = new ControlClient(ws);
43
- ws.onerror = () => reject(new Error(`cannot reach hub at ${control.url}`));
44
- ws.onclose = () => {
45
- reject(new Error("hub closed the connection (stale token?)"));
70
+ const timer = setTimeout(() => refuse(new Error(`hub connection timed out at ${control.url}`)), timeoutMs);
71
+ const refuse = (error: Error) => { clearTimeout(timer); reject(error); ws.close(); };
72
+ ws.onerror = () => refuse(new Error(`cannot reach hub at ${control.url}`));
73
+ ws.onclose = (ev) => {
74
+ clearTimeout(timer);
75
+ // A refused hello carries its reason (bad token, wire version, peer id); say that, not a guess.
76
+ reject(Object.assign(new Error(`hub closed the connection: ${ev.reason || "stale token?"}`), { code: ev.code }));
46
77
  for (const done of client.pending.values()) done({ ok: false, error: "hub connection closed" });
47
78
  client.pending.clear();
48
- client.onClose();
79
+ client.onClose(ev.code, ev.reason);
49
80
  };
50
81
  ws.onmessage = (ev) => {
51
- const msg = JSON.parse(String(ev.data));
82
+ let msg: any;
83
+ try { msg = JSON.parse(String(ev.data)); } catch { return refuse(new Error("invalid hub response")); }
84
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return refuse(new Error("invalid hub response"));
52
85
  const done = msg.rid !== undefined ? client.pending.get(msg.rid) : undefined;
53
86
  if (!done) return client.onPush(msg);
54
87
  client.pending.delete(msg.rid);
55
88
  done(msg);
56
89
  };
57
- ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello }).then(() => resolve(client));
90
+ ws.onopen = () => void client.request({ t: "hello", v: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
91
+ if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
92
+ if ((expected.projectId && reply.projectId !== expected.projectId) ||
93
+ (expected.instanceId && reply.instanceId !== expected.instanceId) ||
94
+ (expected.projectRoot && reply.cwd !== expected.projectRoot)) {
95
+ return refuse(Object.assign(new Error("connected hub has a different project or instance"), { code: 4404 }));
96
+ }
97
+ clearTimeout(timer);
98
+ resolve(client);
99
+ });
58
100
  });
59
101
  }
60
102
 
61
103
  /** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
62
- request(msg: Record<string, unknown>, timeoutMs?: number): Promise<any> {
104
+ request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
105
+ if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
63
106
  const rid = this.nextRid++;
64
107
  return new Promise((resolve) => {
65
108
  const timer = timeoutMs ? setTimeout(() => (this.pending.delete(rid), resolve({ ok: false, error: `no answer from the hub within ${Math.round(timeoutMs / 1000)} s` })), timeoutMs) : undefined;