@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/src/cli/main.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
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";
@@ -14,12 +17,26 @@ import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplac
14
17
  import { CLASSES } from "../hub/board.ts";
15
18
  import { VERSION } from "../version.ts";
16
19
  import { createInterface } from "node:readline/promises";
20
+ import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
21
+ import { childEnv } from "../hub/child-process.ts";
22
+ import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
23
+ import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
24
+ import { recordTerminalLaunch } from "./terminal-recovery.ts";
17
25
 
18
26
  const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
19
27
 
28
+ ahub --project <path|id> <command> select a repository or worktree explicitly
29
+ ahub projects [--json] list registered projects and live status
30
+ ahub projects remove <id> forget a stopped registration (keeps project files)
31
+ ahub status --all show every registered project
32
+ ahub ui --all [--no-open] open the unified project dashboard
33
+ ahub ui --all --stop stop only the dashboard manager
20
34
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
21
35
  ahub init write .agenthub/config.json and the CLAUDE.md / AGENTS.md marker blocks
22
36
  ahub up [--unattended] start the daemon for this directory
37
+ ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
38
+ ahub restart [--dry-run] [--yes] recover this project's runtime
39
+ ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
23
40
  ahub claude [args...] launch Claude Code with the hub channel [--unattended]
24
41
  ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
25
42
  ahub kimi [--model <alias>] start Kimi headless under ACP
@@ -45,25 +62,69 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
45
62
  ahub permit <id> <option> answer a permission request shown by tail ("deny" cancels)
46
63
  ahub status | logs [-f] | doctor | kill`;
47
64
 
48
- const cwd = process.cwd();
49
- const stateDir = stateDirFor(cwd);
65
+ const argv = process.argv.slice(2);
66
+ let selector: string | undefined;
67
+ if (argv[0] === "--project") {
68
+ argv.shift();
69
+ selector = argv.shift();
70
+ if (!selector || selector.startsWith("--")) fail("--project needs a path or project ID");
71
+ }
72
+ const [cmd = "help", ...args] = argv;
73
+ let selected: { root: string; stateDir: string };
74
+ try {
75
+ if (selector) {
76
+ const projects = registeredProjects();
77
+ {
78
+ const known = projects.find((p) => p.id === selector);
79
+ const context = known ?? projectContext(selector, {});
80
+ const registered = known ?? projects.find((p) => p.root === context.root);
81
+ selected = registered ?? context;
82
+ }
83
+ } else selected = projectContext(process.cwd());
84
+ } catch (error) { fail((error as Error).message); }
85
+ const cwd = selected.root;
86
+ const stateDir = selected.stateDir;
87
+ try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
50
88
  const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
51
- const [cmd = "help", ...args] = process.argv.slice(2);
89
+ const lifecycle = { inspectProject, startProject, stopProject };
90
+ const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
52
91
 
53
- const connect = () => ControlClient.connect(stateDir, { role: "console" });
92
+ function registeredProject(): Project {
93
+ const registry = new Registry();
94
+ try { return registry.register(cwd, stateDir); }
95
+ finally { registry.close(); }
96
+ }
54
97
 
55
98
  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);
99
+ let hub: ControlClient | undefined;
100
+ try { hub = await connect(); const reply = await hub.request({ t: "status" }, 3000); return reply.status?.cwd === cwd; }
101
+ catch { return false; }
102
+ finally { hub?.close(); }
59
103
  }
60
104
 
61
105
  function exec(bin: string, argv: string[]): never {
62
- const res = spawnSync(bin, argv, { stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir } });
106
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...childEnv(), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
63
107
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
64
108
  process.exit(res.status ?? 1);
65
109
  }
66
110
 
111
+ async function projectRows() {
112
+ const registry = new Registry();
113
+ try { return await Promise.all(registry.list().map(async (project) => ({ ...project, ...await inspectProject(project) }))); }
114
+ finally { registry.close(); }
115
+ }
116
+
117
+ async function printProjects(json = false) {
118
+ const rows = await projectRows();
119
+ if (json) return console.log(JSON.stringify(rows, null, 2));
120
+ for (const row of rows) {
121
+ console.log(`${row.id} ${row.state.padEnd(12)} ${row.root}`);
122
+ if (row.status) console.log(` peers ${Object.keys(row.status.peers ?? {}).length}, control ${row.status.controlPort}, tasks ${JSON.stringify(row.status.tasks ?? {})}`);
123
+ if (row.error) console.log(` ${row.error}`);
124
+ }
125
+ if (!rows.length) console.log("No registered projects. Run ahub init in a project directory.");
126
+ }
127
+
67
128
  function fail(message: string): never {
68
129
  console.error(`ahub: ${message}`);
69
130
  process.exit(1);
@@ -113,25 +174,104 @@ async function hold(t: "pause" | "resume"): Promise<void> {
113
174
  console.log(`${args[0]} is ${res.state}`);
114
175
  }
115
176
 
177
+ function spawnRecovery(operation: RecoveryOperation): void {
178
+ const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
179
+ cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
180
+ });
181
+ child.on("error", () => console.error(`runner launch failed; use ahub recovery resume ${operation.id}`));
182
+ child.unref();
183
+ console.log(`Recovery operation ${operation.id} scheduled.\nahub recovery status ${operation.id}`);
184
+ }
185
+
186
+ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
187
+ const { one, rest } = takeFlags(args, ["--to"], []);
188
+ if (rest.some((a) => !["--dry-run", "--yes"].includes(a)) || (kind === "restart" && one["--to"])) fail("usage: ahub upgrade --to <version> [--dry-run] [--yes] | ahub restart [--dry-run] [--yes]");
189
+ if (kind === "upgrade" && !one["--to"]) fail("upgrade requires --to <exact-version>");
190
+ if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
191
+ const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
192
+ console.log(JSON.stringify(plan, null, 2));
193
+ if (args.includes("--dry-run")) return;
194
+ if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) fail("plan has blockers; no runtime was changed");
195
+ assertLifecycleAvailable();
196
+ if (!args.includes("--yes")) {
197
+ if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
198
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
199
+ try {
200
+ if (!/^(y|yes)$/i.test((await rl.question("Apply this project list and restore its sessions? [y/N] ")).trim())) return;
201
+ } finally { rl.close(); }
202
+ }
203
+ const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined);
204
+ if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
205
+ const sourceRoot = preserveSource(plan);
206
+ const operation = createOperation(plan, sourceRoot);
207
+ spawnRecovery(operation);
208
+ }
209
+
116
210
  const commands: Record<string, () => Promise<void> | void> = {
211
+ upgrade: () => upgrade("upgrade"),
212
+ restart: () => upgrade("restart"),
213
+ recovery: async () => {
214
+ const [action, id] = args;
215
+ if (args.length !== 2 || !id || !["status", "resume", "abort"].includes(action ?? "")) fail("usage: ahub recovery status|resume|abort <operation-id>");
216
+ const operation = readOperation<RecoveryOperation>(id);
217
+ if (action === "status") console.log(JSON.stringify(publicOperation(operation), null, 2));
218
+ else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
219
+ else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
220
+ else spawnRecovery(operation);
221
+ },
222
+ "recovery-run": async () => {
223
+ if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
224
+ const result = await runRecovery(args[0]!, makeRecoveryDriver());
225
+ if (result.phase !== "completed") process.exitCode = 1;
226
+ },
117
227
  help: () => console.log(USAGE),
118
228
  "--version": () => console.log(VERSION),
119
229
  version: () => console.log(VERSION),
120
230
 
231
+ projects: async () => {
232
+ if (args[0] === "remove") {
233
+ if (args.length !== 2) fail("usage: ahub projects remove <id>");
234
+ const registry = new Registry();
235
+ try { registry.remove(args[1]!); } finally { registry.close(); }
236
+ console.log("registration removed; project files were kept");
237
+ return;
238
+ }
239
+ if (args.some((arg) => arg !== "--json")) fail("usage: ahub projects [--json]");
240
+ await printProjects(args.includes("--json"));
241
+ },
242
+
121
243
  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);
244
+ if (args.some((arg) => !["--no-open", "--all", "--stop"].includes(arg))) fail("usage: ahub ui [--all] [--no-open] | --all --stop");
245
+ if (args.includes("--stop")) {
246
+ if (!args.includes("--all") || args.includes("--no-open")) fail("usage: ahub ui --all --stop");
247
+ await stopManager();
248
+ return console.log("dashboard manager stopped; project hubs were kept running");
249
+ }
250
+ let url: string;
251
+ if (args.includes("--all")) url = await openManager();
252
+ else {
253
+ const hub = await connect();
254
+ try {
255
+ const res = await hub.request({ t: "ui" }, 10_000);
256
+ if (!res.ok) fail(res.error);
257
+ url = res.url;
258
+ } finally { hub.close(); }
259
+ }
260
+ if (args.includes("--no-open")) return console.log(url);
128
261
  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}`);
262
+ const opened = spawnSync(opener, [url], { stdio: "ignore", timeout: 10_000 });
263
+ if (opened.error || opened.status !== 0) console.log(`Open this one-time link within 60 seconds:\n${url}`);
131
264
  else console.log("Dashboard opened. The session expires in one hour; run ahub ui to reopen it.");
132
265
  },
133
266
 
267
+ manager: async () => {
268
+ const manager = await startManager({ lifecycle });
269
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => void manager.stop());
270
+ await manager.stopped;
271
+ },
272
+
134
273
  setup: async () => {
274
+ assertLifecycleAvailable();
135
275
  const root = join(import.meta.dir, "..", "..");
136
276
  const state = () => {
137
277
  const out = (argv: string[]) => spawnSync("claude", argv, { encoding: "utf8" }).stdout ?? "";
@@ -157,50 +297,33 @@ const commands: Record<string, () => Promise<void> | void> = {
157
297
  },
158
298
 
159
299
  init: () => {
300
+ assertLifecycleAvailable();
160
301
  const changed = init(cwd);
302
+ registeredProject();
161
303
  console.log(changed.length ? changed.map((p) => `wrote ${p}`).join("\n") : "already up to date");
162
304
  },
163
305
 
164
- // Internal: the detached daemon process started by `ahub up`.
306
+ // Internal: the detached daemon process started by ahub up or the manager.
165
307
  daemon: async () => {
166
- // Long-lived process: one bad adapter callback must not take every peer down. stderr is hub.log.
167
308
  process.on("unhandledRejection", (e) => console.error(`${new Date().toISOString()} unhandled rejection:`, e));
168
309
  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);
310
+ await runProjectDaemon(registeredProject(), unattendedEnv || args.includes("--unattended"));
182
311
  },
183
312
 
184
313
  up: async () => {
185
- if (await healthy()) return console.log("hub is already running");
186
314
  const unattended = unattendedEnv || args.includes("--unattended");
187
315
  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")}`);
316
+ const status = await startProject(registeredProject(), { unattended });
317
+ console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
201
318
  },
202
319
 
203
- claude: () => {
320
+ claude: async () => {
321
+ assertLifecycleAvailable();
322
+ const control = readControl(stateDir);
323
+ if (control?.instanceId) {
324
+ process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
325
+ await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
326
+ }
204
327
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
205
328
  // project local, then project, then user.
206
329
  let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
@@ -217,11 +340,14 @@ const commands: Record<string, () => Promise<void> | void> = {
217
340
  },
218
341
 
219
342
  codex: async () => {
343
+ assertLifecycleAvailable();
220
344
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
221
345
  const hub = await connect();
222
- const res = await hub.request({ t: "start", peer: "codex" });
346
+ const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
223
347
  hub.close();
224
348
  if (!res.ok) fail(res.error);
349
+ const control = readControl(stateDir);
350
+ if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
225
351
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: loadConfig(cwd).codex_bin });
226
352
  if (launch0.warning) console.error(launch0.warning);
227
353
  exec(launch.cmd, launch.args);
@@ -231,7 +357,7 @@ const commands: Record<string, () => Promise<void> | void> = {
231
357
  const i = args.indexOf("--model");
232
358
  const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
233
359
  const hub = await connect();
234
- const res = await hub.request({ t: "start", peer: "kimi", args: { model } });
360
+ const res = await hub.request({ t: "start", peer: "kimi", args: { model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
235
361
  hub.close();
236
362
  if (!res.ok) fail(res.error);
237
363
  console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
@@ -240,7 +366,7 @@ const commands: Record<string, () => Promise<void> | void> = {
240
366
  local: async () => {
241
367
  const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
242
368
  const hub = await connect();
243
- const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") } });
369
+ const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
244
370
  hub.close();
245
371
  if (!res.ok) fail(res.error);
246
372
  console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
@@ -370,6 +496,7 @@ const commands: Record<string, () => Promise<void> | void> = {
370
496
  },
371
497
 
372
498
  status: async () => {
499
+ if (args.includes("--all")) return printProjects(args.includes("--json"));
373
500
  const hub = await connect();
374
501
  const { status } = await hub.request({ t: "status" });
375
502
  hub.close();
@@ -385,23 +512,16 @@ const commands: Record<string, () => Promise<void> | void> = {
385
512
  logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
386
513
 
387
514
  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");
515
+ const registry = new Registry();
516
+ let project: Project | undefined;
517
+ try { project = registry.list().find((p) => p.root === cwd && p.stateDir === stateDir); }
518
+ finally { registry.close(); }
519
+ if (!project) {
520
+ if (!readControl(stateDir) && !existsSync(join(stateDir, "hub.pid"))) return console.log("hub is not running");
521
+ fail("hub has no matching registration; use its matching CLI to stop it before upgrading");
402
522
  }
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)");
523
+ await stopProject(project);
524
+ console.log("hub stopped");
405
525
  },
406
526
 
407
527
  doctor: async () => {
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { hubHome } from "../hub/project.ts";
6
+
7
+ export type CommandResult = { code: number; stdout: string; stderr: string };
8
+ export type RunCommand = (args: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }) => Promise<CommandResult>;
9
+ export const runCommand: RunCommand = (args, options = {}) => new Promise((resolve, reject) => {
10
+ const child = spawn(args[0]!, args.slice(1), { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
11
+ let stdout = "", stderr = "", exceeded = false;
12
+ const append = (old: string, value: unknown) => {
13
+ const next = old + String(value);
14
+ if (next.length > 2_000_000) { exceeded = true; child.kill("SIGTERM"); }
15
+ return next.slice(-2_000_000);
16
+ };
17
+ child.stdout.on("data", (v) => { stdout = append(stdout, v); });
18
+ child.stderr.on("data", (v) => { stderr = append(stderr, v); });
19
+ let timedOut = false;
20
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, options.timeoutMs ?? 60_000);
21
+ const hardTimer = setTimeout(() => child.kill("SIGKILL"), (options.timeoutMs ?? 60_000) + 3000);
22
+ child.on("error", (e) => { clearTimeout(timer); clearTimeout(hardTimer); reject(e); });
23
+ child.on("close", (code) => {
24
+ clearTimeout(timer); clearTimeout(hardTimer);
25
+ if (timedOut || exceeded) reject(new Error(`command ${args[0]} ${timedOut ? "timed out" : "exceeded output limit"}; inspect state before retrying`));
26
+ else resolve({ code: code ?? 1, stdout, stderr });
27
+ });
28
+ });
29
+
30
+ export function exactVersion(version: string): string {
31
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error("--to requires an exact package version");
32
+ return version;
33
+ }
34
+
35
+ export function packageDigest(root: string): string {
36
+ const hash = createHash("sha256");
37
+ const visit = (directory: string, prefix = "") => {
38
+ for (const item of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
39
+ if (item.isSymbolicLink()) throw new Error("runtime package must not contain symlinks");
40
+ const name = `${prefix}${item.name}`;
41
+ if (item.isDirectory()) visit(join(directory, item.name), `${name}/`);
42
+ else { hash.update(name); hash.update("\0"); hash.update(readFileSync(join(directory, item.name))); hash.update("\0"); }
43
+ }
44
+ };
45
+ // Only runtime assets; never copy or hash a checkout's private state or dependencies.
46
+ for (const name of ["src", "plugins", "templates", ".claude-plugin"]) visit(join(root, name), `${name}/`);
47
+ hash.update(readFileSync(join(root, "package.json")));
48
+ return hash.digest("hex");
49
+ }
50
+
51
+ export function verifyPackage(root: string, version: string): string {
52
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
53
+ const plugin = JSON.parse(readFileSync(join(root, "plugins/agent-hub/.claude-plugin/plugin.json"), "utf8"));
54
+ if (pkg.name !== "@staix/agent-hub" || pkg.version !== version || plugin.version !== version ||
55
+ !existsSync(join(root, "src/cli/main.js"))) throw new Error("staged package identity/version mismatch");
56
+ return packageDigest(root);
57
+ }
58
+
59
+ export async function registryRelease(version: string, run: RunCommand = runCommand): Promise<{ version: string; integrity: string }> {
60
+ exactVersion(version);
61
+ const result = await run(["npm", "view", `@staix/agent-hub@${version}`, "version", "dist.integrity", "--json"], { timeoutMs: 30_000 });
62
+ if (result.code !== 0) throw new Error(`registry metadata unavailable for @staix/agent-hub@${version}`);
63
+ const data = JSON.parse(result.stdout);
64
+ if (data.version !== version || typeof data["dist.integrity"] !== "string" || !data["dist.integrity"].startsWith("sha512-")) {
65
+ throw new Error("registry returned an unexpected release identity");
66
+ }
67
+ return { version, integrity: data["dist.integrity"] };
68
+ }
69
+
70
+ /** Stage outside the mutable global install; retain successful versions for recovery. */
71
+ export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
72
+ exactVersion(version);
73
+ const base = join(home, "releases");
74
+ const destination = join(base, version);
75
+ const root = join(destination, "node_modules/@staix/agent-hub");
76
+ if (existsSync(destination)) {
77
+ const saved = JSON.parse(readFileSync(join(destination, "verified.json"), "utf8"));
78
+ const digest = verifyPackage(root, version);
79
+ if (saved.integrity !== integrity || saved.digest !== digest) throw new Error("cached release changed; refusing it");
80
+ return { root, digest };
81
+ }
82
+ mkdirSync(base, { recursive: true, mode: 0o700 });
83
+ const temporary = join(base, `.stage-${randomUUID()}`);
84
+ mkdirSync(temporary, { mode: 0o700 });
85
+ try {
86
+ writeFileSync(join(temporary, "package.json"), JSON.stringify({ private: true, dependencies: { "@staix/agent-hub": version } }));
87
+ const install = await run([process.execPath, "install", "--ignore-scripts", "--cwd", temporary], { timeoutMs: 180_000 });
88
+ if (install.code !== 0) throw new Error("package staging failed; installed runtime was not changed");
89
+ const staged = join(temporary, "node_modules/@staix/agent-hub");
90
+ const digest = verifyPackage(staged, version);
91
+ // Bun's lock records the registry integrity actually used, not a later metadata query.
92
+ const lock = JSON.parse(readFileSync(join(temporary, "bun.lock"), "utf8").replace(/,\s*([}\]])/g, "$1"));
93
+ const row = lock.packages?.["@staix/agent-hub"];
94
+ if (!Array.isArray(row) || !row.includes(integrity)) throw new Error("staged package integrity differs from the reviewed release");
95
+ writeFileSync(join(temporary, "verified.json"), JSON.stringify({ integrity, digest }), { mode: 0o600 });
96
+ renameSync(temporary, destination);
97
+ return { root, digest };
98
+ } finally { rmSync(temporary, { recursive: true, force: true }); }
99
+ }
@@ -4,13 +4,14 @@
4
4
  // user's own status line command: it records the limits for the hub's budget coordinator and then runs the original
5
5
  // command with the same input, so the status line looks exactly as before. It must never fail the render.
6
6
  import { spawnSync } from "node:child_process";
7
- import { mkdirSync, renameSync, writeFileSync } from "node:fs";
7
+ import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
 
10
10
  const input = await Bun.stdin.text();
11
11
  let own = "";
12
12
  try {
13
- const limits = JSON.parse(input).rate_limits;
13
+ const parsed = JSON.parse(input);
14
+ const limits = parsed.rate_limits;
14
15
  const pct = (w: any) => (typeof w?.used_percentage === "number" ? `${Math.round(w.used_percentage)}%` : "-");
15
16
  if (limits) own = `agent-hub 5h ${pct(limits.five_hour)} wk ${pct(limits.seven_day)}`;
16
17
  const dir = process.env.AGENTHUB_STATE_DIR;
@@ -20,6 +21,14 @@ try {
20
21
  writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), rate_limits: limits }));
21
22
  renameSync(`${file}.tmp`, file);
22
23
  }
24
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : typeof parsed.sessionId === "string" ? parsed.sessionId : "";
25
+ if (sessionId && dir) {
26
+ mkdirSync(dir, { recursive: true });
27
+ const file = join(dir, "claude-session.json");
28
+ writeFileSync(`${file}.tmp`, JSON.stringify({ at: Date.now(), sessionId, ...(process.env.AGENTHUB_INSTANCE_ID ? { instanceId: process.env.AGENTHUB_INSTANCE_ID } : {}), ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}) }), { mode: 0o600 });
29
+ chmodSync(`${file}.tmp`, 0o600);
30
+ renameSync(`${file}.tmp`, file);
31
+ }
23
32
  } catch {
24
33
  // not JSON, or the state dir is gone: the status line still has to render
25
34
  }