@staix/agent-hub 0.4.0 → 0.6.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,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
5
6
  import { ControlClient, readControl } from "../hub/control-client.ts";
6
7
  import { loadConfig } from "../hub/daemon.ts";
7
8
  import { projectContext } from "../hub/project.ts";
@@ -17,6 +18,12 @@ import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplac
17
18
  import { CLASSES } from "../hub/board.ts";
18
19
  import { VERSION } from "../version.ts";
19
20
  import { createInterface } from "node:readline/promises";
21
+ import { assertLifecycleAvailable, readOperation } from "../hub/recovery-store.ts";
22
+ import { childEnv } from "../hub/child-process.ts";
23
+ import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
24
+ import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
25
+ import { recordTerminalLaunch } from "./terminal-recovery.ts";
26
+ import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
20
27
 
21
28
  const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
22
29
 
@@ -29,9 +36,14 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
29
36
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
30
37
  ahub init write .agenthub/config.json and the CLAUDE.md / AGENTS.md marker blocks
31
38
  ahub up [--unattended] start the daemon for this directory
39
+ ahub upgrade --to <version> [--dry-run] [--yes] review and upgrade running projects
40
+ ahub restart [--dry-run] [--yes] recover this project's runtime
41
+ ahub recovery status|resume|abort <operation-id> inspect, resume or cancel a preflight
32
42
  ahub claude [args...] launch Claude Code with the hub channel [--unattended]
33
43
  ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
34
44
  ahub kimi [--model <alias>] start Kimi headless under ACP
45
+ ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>] start Pi
46
+ ahub models setup|status|start|stop manage the pinned local MLX runtime
35
47
  ahub local [--route <id> | --model <id>]
36
48
  start the hub-native worker on the self-hosted models (routing.toml)
37
49
  ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
@@ -65,13 +77,13 @@ const [cmd = "help", ...args] = argv;
65
77
  let selected: { root: string; stateDir: string };
66
78
  try {
67
79
  if (selector) {
68
- const registry = new Registry();
69
- try {
70
- const known = registry.get(selector);
80
+ const projects = registeredProjects();
81
+ {
82
+ const known = projects.find((p) => p.id === selector);
71
83
  const context = known ?? projectContext(selector, {});
72
- const registered = known ?? registry.list().find((p) => p.root === context.root);
84
+ const registered = known ?? projects.find((p) => p.root === context.root);
73
85
  selected = registered ?? context;
74
- } finally { registry.close(); }
86
+ }
75
87
  } else selected = projectContext(process.cwd());
76
88
  } catch (error) { fail((error as Error).message); }
77
89
  const cwd = selected.root;
@@ -95,11 +107,39 @@ async function healthy(): Promise<boolean> {
95
107
  }
96
108
 
97
109
  function exec(bin: string, argv: string[]): never {
98
- const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
110
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...childEnv(), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
111
+ if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
112
+ process.exit(res.status ?? 1);
113
+ }
114
+
115
+ function execWithEnv(bin: string, argv: string[], extra: NodeJS.ProcessEnv): never {
116
+ const env: NodeJS.ProcessEnv = { ...extra, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd };
117
+ delete env.AGENTHUB_RECOVERY_OPERATION;
118
+ delete env.AGENTHUB_UNATTENDED;
119
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env });
99
120
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
100
121
  process.exit(res.status ?? 1);
101
122
  }
102
123
 
124
+ function piFlags(): { mode: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; model?: string; sessionId?: string; sessionFile?: string } {
125
+ for (const flag of ["--mode", "--backend", "--model", "--session-id", "--session-file"]) {
126
+ const index = args.indexOf(flag);
127
+ if (index >= 0 && (!args[index + 1] || args[index + 1]!.startsWith("--"))) fail(`${flag} needs a value`);
128
+ }
129
+ const mode = (args.includes("--mode") ? args[args.indexOf("--mode") + 1] : "headless") as string;
130
+ const backend = (args.includes("--backend") ? args[args.indexOf("--backend") + 1] : undefined) as string | undefined;
131
+ const model = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined;
132
+ const sessionId = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : undefined;
133
+ const sessionFile = args.includes("--session-file") ? args[args.indexOf("--session-file") + 1] : undefined;
134
+ const valueFlags = new Set(["--mode", "--backend", "--model", "--session-id", "--session-file"]);
135
+ for (let i = 0; i < args.length; i++) {
136
+ if (!valueFlags.has(args[i]!)) fail(`unknown Pi option: ${args[i]}`);
137
+ i++;
138
+ }
139
+ if (!["headless", "tui"].includes(mode) || (backend !== undefined && !["auto", "dgx", "mlx"].includes(backend)) || (sessionId && sessionFile)) fail("usage: ahub pi [--mode headless|tui] [--backend auto|dgx|mlx] [--model <alias>] [--session-id <id> | --session-file <path>]");
140
+ return { mode: mode as "headless" | "tui", ...(backend ? { backend: backend as "auto" | "dgx" | "mlx" } : {}), ...(model ? { model } : {}), ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}) };
141
+ }
142
+
103
143
  async function projectRows() {
104
144
  const registry = new Registry();
105
145
  try { return await Promise.all(registry.list().map(async (project) => ({ ...project, ...await inspectProject(project) }))); }
@@ -166,7 +206,56 @@ async function hold(t: "pause" | "resume"): Promise<void> {
166
206
  console.log(`${args[0]} is ${res.state}`);
167
207
  }
168
208
 
209
+ function spawnRecovery(operation: RecoveryOperation): void {
210
+ const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
211
+ cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
212
+ });
213
+ child.on("error", () => console.error(`runner launch failed; use ahub recovery resume ${operation.id}`));
214
+ child.unref();
215
+ console.log(`Recovery operation ${operation.id} scheduled.\nahub recovery status ${operation.id}`);
216
+ }
217
+
218
+ async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
219
+ const { one, rest } = takeFlags(args, ["--to"], []);
220
+ 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]");
221
+ if (kind === "upgrade" && !one["--to"]) fail("upgrade requires --to <exact-version>");
222
+ if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
223
+ const plan = await makeUpgradePlan(kind, one["--to"] ?? VERSION, kind === "restart" ? cwd : undefined);
224
+ console.log(JSON.stringify(plan, null, 2));
225
+ if (args.includes("--dry-run")) return;
226
+ if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) fail("plan has blockers; no runtime was changed");
227
+ assertLifecycleAvailable();
228
+ if (!args.includes("--yes")) {
229
+ if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
230
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
231
+ try {
232
+ if (!/^(y|yes)$/i.test((await rl.question("Apply this project list and restore its sessions? [y/N] ")).trim())) return;
233
+ } finally { rl.close(); }
234
+ }
235
+ const current = await makeUpgradePlan(kind, plan.version, kind === "restart" ? cwd : undefined);
236
+ if (current.fingerprint !== plan.fingerprint) fail("plan changed during review; run the command again");
237
+ const sourceRoot = preserveSource(plan);
238
+ const operation = createOperation(plan, sourceRoot);
239
+ spawnRecovery(operation);
240
+ }
241
+
169
242
  const commands: Record<string, () => Promise<void> | void> = {
243
+ upgrade: () => upgrade("upgrade"),
244
+ restart: () => upgrade("restart"),
245
+ recovery: async () => {
246
+ const [action, id] = args;
247
+ if (args.length !== 2 || !id || !["status", "resume", "abort"].includes(action ?? "")) fail("usage: ahub recovery status|resume|abort <operation-id>");
248
+ const operation = readOperation<RecoveryOperation>(id);
249
+ if (action === "status") console.log(JSON.stringify(publicOperation(operation), null, 2));
250
+ else if (action === "abort") { await abortRecovery(id, makeRecoveryDriver()); console.log("preflight cancelled; no committed transition was rolled back"); }
251
+ else if (["completed", "cancelled"].includes(operation.phase)) console.log(`recovery is already ${operation.phase}`);
252
+ else spawnRecovery(operation);
253
+ },
254
+ "recovery-run": async () => {
255
+ if (args.length !== 1 || process.env.AGENTHUB_RECOVERY_OPERATION !== args[0]) fail("recovery-run is an internal command");
256
+ const result = await runRecovery(args[0]!, makeRecoveryDriver());
257
+ if (result.phase !== "completed") process.exitCode = 1;
258
+ },
170
259
  help: () => console.log(USAGE),
171
260
  "--version": () => console.log(VERSION),
172
261
  version: () => console.log(VERSION),
@@ -214,6 +303,7 @@ const commands: Record<string, () => Promise<void> | void> = {
214
303
  },
215
304
 
216
305
  setup: async () => {
306
+ assertLifecycleAvailable();
217
307
  const root = join(import.meta.dir, "..", "..");
218
308
  const state = () => {
219
309
  const out = (argv: string[]) => spawnSync("claude", argv, { encoding: "utf8" }).stdout ?? "";
@@ -239,6 +329,7 @@ const commands: Record<string, () => Promise<void> | void> = {
239
329
  },
240
330
 
241
331
  init: () => {
332
+ assertLifecycleAvailable();
242
333
  const changed = init(cwd);
243
334
  registeredProject();
244
335
  console.log(changed.length ? changed.map((p) => `wrote ${p}`).join("\n") : "already up to date");
@@ -258,7 +349,13 @@ const commands: Record<string, () => Promise<void> | void> = {
258
349
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
259
350
  },
260
351
 
261
- claude: () => {
352
+ claude: async () => {
353
+ assertLifecycleAvailable();
354
+ const control = readControl(stateDir);
355
+ if (control?.instanceId) {
356
+ process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
357
+ await recordTerminalLaunch("claude", cwd, stateDir, control.instanceId);
358
+ }
262
359
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
263
360
  // project local, then project, then user.
264
361
  let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
@@ -275,11 +372,14 @@ const commands: Record<string, () => Promise<void> | void> = {
275
372
  },
276
373
 
277
374
  codex: async () => {
375
+ assertLifecycleAvailable();
278
376
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
279
377
  const hub = await connect();
280
- const res = await hub.request({ t: "start", peer: "codex" });
378
+ const res = await hub.request({ t: "start", peer: "codex", operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
281
379
  hub.close();
282
380
  if (!res.ok) fail(res.error);
381
+ const control = readControl(stateDir);
382
+ if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
283
383
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: loadConfig(cwd).codex_bin });
284
384
  if (launch0.warning) console.error(launch0.warning);
285
385
  exec(launch.cmd, launch.args);
@@ -289,21 +389,58 @@ const commands: Record<string, () => Promise<void> | void> = {
289
389
  const i = args.indexOf("--model");
290
390
  const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
291
391
  const hub = await connect();
292
- const res = await hub.request({ t: "start", peer: "kimi", args: { model } });
392
+ const res = await hub.request({ t: "start", peer: "kimi", args: { model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
293
393
  hub.close();
294
394
  if (!res.ok) fail(res.error);
295
395
  console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
296
396
  },
297
397
 
398
+ pi: async () => {
399
+ const options = piFlags();
400
+ const hub = await connect();
401
+ const res = await hub.request({ t: "start", peer: "pi", args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
402
+ hub.close();
403
+ if (!res.ok) fail(res.error);
404
+ if (options.mode === "tui") {
405
+ const launch = res.launch;
406
+ if (!launch || typeof launch.cmd !== "string" || !Array.isArray(launch.args) || !launch.args.every((a: unknown) => typeof a === "string") || !launch.env || typeof launch.env !== "object") fail("Pi TUI launch metadata was not verified");
407
+ const launchEnv = { ...(launch.env as NodeJS.ProcessEnv) };
408
+ delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
409
+ delete launchEnv.AGENTHUB_UNATTENDED;
410
+ const control = readControl(stateDir);
411
+ if (control?.instanceId) await recordTerminalLaunch("pi", cwd, stateDir, control.instanceId);
412
+ return execWithEnv(launch.cmd, launch.args, launchEnv);
413
+ }
414
+ console.log(res.already ? "pi is already attached" : 'pi attached (headless). Talk to it with: ahub say @pi "..."');
415
+ },
416
+
298
417
  local: async () => {
299
418
  const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
300
419
  const hub = await connect();
301
- const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") } });
420
+ const res = await hub.request({ t: "start", peer: "local", args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
302
421
  hub.close();
303
422
  if (!res.ok) fail(res.error);
304
423
  console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
305
424
  },
306
425
 
426
+ models: async () => {
427
+ const action = args[0] ?? "status";
428
+ const configured = loadConfig(cwd).mlx;
429
+ const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
430
+ const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
431
+ const mlxOptions = { ...configured, runtimeDir, modelPath };
432
+ if (action === "status") return console.log(JSON.stringify(await inspectMlx(mlxOptions), null, 2));
433
+ if (action === "start") { const handle = await ensureMlx(mlxOptions); return console.log(JSON.stringify(handle.status(), null, 2)); }
434
+ if (action === "stop") { await stopMlx(mlxOptions); return console.log("MLX stopped"); }
435
+ if (action !== "setup") fail("usage: ahub models setup|status|start|stop");
436
+ const python = join(runtimeDir, "bin", "python");
437
+ const run = (argv: string[]) => { const result = spawnSync(argv[0]!, argv.slice(1), { cwd, stdio: "inherit" }); if (result.status !== 0) fail(`models setup failed: ${argv.join(" ")}`); };
438
+ if (!existsSync(python)) run(["uv", "venv", "--python", "3.12", runtimeDir]);
439
+ run(["uv", "pip", "install", "--python", python, "mlx-lm==0.31.3"]);
440
+ run([python, "-c", `from huggingface_hub import snapshot_download; snapshot_download(repo_id='Qwen/Qwen3-8B-MLX-4bit', revision='383413e909f3bc5303ce195ebbdf0339c5a1a2a3', local_dir=${JSON.stringify(modelPath)}, token=False)`]);
441
+ console.log(`MLX runtime and pinned model prepared at ${runtimeDir}`);
442
+ },
443
+
307
444
  say: async () => {
308
445
  const lead = args.findIndex((a) => !/^@[a-z][a-z0-9-]*$/.test(a)); // only leading @tokens are recipients
309
446
  const to = args.slice(0, lead === -1 ? args.length : lead).map((a) => a.slice(1));
@@ -435,6 +572,8 @@ const commands: Record<string, () => Promise<void> | void> = {
435
572
  console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
436
573
  const peers = Object.entries(status.peers as Record<string, { state: string; queued: number }>);
437
574
  for (const [id, p] of peers) console.log(` ${id.padEnd(8)} ${p.state.padEnd(8)} queued ${p.queued}${(p as any).paused ? ` (${(p as any).paused})` : ""}${(p as any).servedBy ? ` last call: ${(p as any).servedBy}` : ""}`);
575
+ const models = (status as any).models?.backends as any[] | undefined;
576
+ if (models?.length) for (const backend of models) console.log(` model ${backend.kind ?? "unknown"}/${backend.alias ?? "unknown"} ${backend.state ?? "unknown"} active ${backend.active ?? 0}${backend.requestedModel ? ` requested ${backend.requestedModel}` : ""}${backend.actualModel ? ` actual ${backend.actualModel}` : ""}${backend.provider ? ` provider ${backend.provider}` : ""}`);
438
577
  if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
439
578
  const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
440
579
  if (counts) console.log(` tasks: ${counts} (ahub board)`);
@@ -480,6 +619,9 @@ const commands: Record<string, () => Promise<void> | void> = {
480
619
  row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.json");
481
620
  const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
482
621
  row(sy.status === 0 ? true : undefined, "switchyard", sy.status === 0 ? sy.stdout.trim() : "not installed: ahub local uses fixed_model on OmniRoute (cargo install --locked switchyard-server)");
622
+ const mlxConfig = loadConfig(cwd).mlx;
623
+ const mlx = await inspectMlx({ ...mlxConfig, runtimeDir: mlxConfig.runtimeDir ? resolve(cwd, mlxConfig.runtimeDir) : undefined, modelPath: mlxConfig.modelPath ? resolve(cwd, mlxConfig.modelPath) : undefined });
624
+ row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
483
625
 
484
626
  const memory = new MemoryClient();
485
627
  const mem = await memory.health();
@@ -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
  }