@staix/agent-hub 0.4.0 → 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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Controlled protocol-8 restart with private queue/session snapshots, manual-pause preservation, instance fencing and verified release (#21).
6
+ - Exact-version upgrade planning, immutable package staging, detached resumable operation receipts and machine lifecycle locking.
7
+ - Orca-first native terminal recovery: original Codex/Claude conversations, new Kimi/local sessions with task context, and explicit manual blockers for unsupported sessions.
8
+ - Running projects transition sequentially; the shared Claude plugin and Claude sessions have a final common phase. Global CLI promotion follows verification.
9
+ - Protocols 5–7 require a manual bootstrap using their matching CLI. No automatic legacy shutdown, uncertain side-effect replay or rollback.
10
+
11
+ ## 0.4.1 (included in 0.5.0)
12
+
13
+ - Synchronize the daemon digest integration fixture without changing batching semantics or weakening digest/FYI assertions (#15).
14
+ - Reconcile completed live smoke and npm publication evidence; infrastructure-dependent checks remain tracked in #12.
15
+
3
16
  ## 0.4.0
4
17
 
5
18
  - Independent repository and worktree hubs with canonical project selection, `--project <path|id>`, `projects`, and `status --all` (#19).
package/README.md CHANGED
@@ -4,9 +4,16 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.4.0. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
7
+ Status: 0.5.0. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
8
8
  says what has and has not been verified against real agents.
9
9
 
10
+ Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
11
+ After bootstrapping a protocol-8 hub, use `ahub restart --dry-run` to inspect one
12
+ project or `ahub upgrade --to <exact-version> --dry-run` to inspect an upgrade of
13
+ the running projects. Remove `--dry-run` to review and schedule the operation;
14
+ `ahub recovery status <operation-id>` reports progress. Existing protocol-5–7
15
+ hubs require their matching CLI and an attended maintenance bootstrap.
16
+
10
17
  ## Start here
11
18
 
12
19
  Requires [Bun](https://bun.sh) >=1.3.0 on PATH; Node alone cannot run the CLI.
@@ -22,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
22
29
  Or install the same version from GitHub:
23
30
 
24
31
  ```bash
25
- bun add -g github:STAIxBWLB/agent-hub#v0.4.0 && ahub setup
32
+ bun add -g github:STAIxBWLB/agent-hub#v0.5.0 && ahub setup
26
33
  ```
27
34
 
28
35
  The installed commands remain `ahub` and `agent-hub`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -15615,7 +15615,7 @@ function projectContext(cwd, env = process.env) {
15615
15615
  function stateDirFor(cwd) {
15616
15616
  return projectContext(cwd).stateDir;
15617
15617
  }
15618
- var PROTOCOL = 7;
15618
+ var PROTOCOL = 8;
15619
15619
  function readControl(stateDir) {
15620
15620
  try {
15621
15621
  const status = JSON.parse(readFileSync2(join2(stateDir, "status.json"), "utf8"));
@@ -15724,7 +15724,7 @@ class ControlClient {
15724
15724
  // package.json
15725
15725
  var package_default = {
15726
15726
  name: "@staix/agent-hub",
15727
- version: "0.4.0",
15727
+ version: "0.5.0",
15728
15728
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code and a local worker as peers in one project",
15729
15729
  license: "MIT",
15730
15730
  type: "module",
@@ -2,7 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
  import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { stopOwnedProcess } from "../hub/child-process.ts";
5
+ import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
6
6
 
7
7
  export interface PermissionOption {
8
8
  optionId: string;
@@ -18,7 +18,11 @@ export interface PermissionRequest {
18
18
  export interface AcpOptions {
19
19
  /** e.g. ["kimi", "acp"]. `opencode acp` fits the same adapter. */
20
20
  cmd: string[];
21
+ /** Coordinator-visible selected model only; contains no prompts or command arguments. */
22
+ launchModel?: string;
21
23
  cwd: string;
24
+ /** Optional launch environment; recovery authority is always removed before spawn. */
25
+ env?: NodeJS.ProcessEnv;
22
26
  watchdogMs?: number;
23
27
  /** ACP stdio MCP servers for the session (the hub's task tools). */
24
28
  mcpServers?: { name: string; command: string; args: string[]; env: { name: string; value: string }[] }[];
@@ -52,9 +56,13 @@ export class AcpPeer extends BasePeer {
52
56
  super(id, opts.watchdogMs);
53
57
  }
54
58
 
59
+ recoveryMetadata(): Record<string, unknown> {
60
+ return { launch: { kind: "acp", ...(this.opts.launchModel ? { model: this.opts.launchModel } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}) };
61
+ }
62
+
55
63
  async start(): Promise<void> {
56
64
  const [bin, ...args] = this.opts.cmd;
57
- const proc = spawn(bin!, args, { cwd: this.opts.cwd, stdio: ["pipe", "pipe", "pipe"] });
65
+ const proc = spawn(bin!, args, { cwd: this.opts.cwd, env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }), stdio: ["pipe", "pipe", "pipe"] });
58
66
  this.proc = proc;
59
67
  proc.on("error", (e) => this.down(`spawn failed: ${e.message}`));
60
68
  proc.on("exit", (code) => this.down(`exited with code ${code}`));
@@ -2,7 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process";
2
2
  import type { Server, ServerWebSocket } from "bun";
3
3
  import { renderDigest, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
4
4
  import { BasePeer } from "../hub/peers.ts";
5
- import { stopOwnedProcess } from "../hub/child-process.ts";
5
+ import { childEnv, stopOwnedProcess } from "../hub/child-process.ts";
6
6
 
7
7
  export interface CodexOptions {
8
8
  /** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
@@ -21,6 +21,8 @@ export interface CodexOptions {
21
21
  /** How often to ask app-server for the rate limits while a TUI is attached. */
22
22
  usagePollMs?: number;
23
23
  cwd: string;
24
+ /** Optional launch environment; recovery authority is always removed before spawn. */
25
+ env?: NodeJS.ProcessEnv;
24
26
  watchdogMs?: number;
25
27
  log?: (line: string) => void;
26
28
  }
@@ -64,6 +66,13 @@ export class CodexPeer extends BasePeer {
64
66
  super(id, opts.watchdogMs);
65
67
  }
66
68
 
69
+ recoveryMetadata(): Record<string, unknown> {
70
+ return {
71
+ launch: { kind: "codex", bin: this.opts.bin ?? "codex", cwd: this.opts.cwd, appPort: this.opts.appPort, proxyPort: this.opts.proxyPort },
72
+ ...(this.threadId ? { threadId: this.threadId } : {}),
73
+ };
74
+ }
75
+
67
76
  get proxyUrl(): string {
68
77
  return `ws://127.0.0.1:${this.server?.port ?? this.opts.proxyPort}`;
69
78
  }
@@ -209,6 +218,7 @@ export class CodexPeer extends BasePeer {
209
218
  let gone = "";
210
219
  this.proc = spawn(this.opts.bin ?? "codex", ["app-server", "--listen", `ws://127.0.0.1:${port}`, ...(this.opts.extraArgs ?? [])], {
211
220
  cwd: this.opts.cwd,
221
+ env: childEnv({ ...process.env, ...(this.opts.env ?? {}) }),
212
222
  stdio: ["ignore", "ignore", "pipe"],
213
223
  });
214
224
  this.proc.stderr?.on("data", (d) => this.opts.log?.(`[${this.id}] ${String(d).trimEnd()}`));
@@ -68,6 +68,10 @@ export class LocalPeer extends BasePeer {
68
68
  this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
69
69
  }
70
70
 
71
+ recoveryMetadata(): Record<string, unknown> {
72
+ return { launch: { kind: "local", cwd: this.opts.cwd, model: this.opts.fixedModel, ...(this.opts.route ? { route: this.opts.route } : {}) }, sessionId: this.sessionId };
73
+ }
74
+
71
75
  async start(): Promise<void> {
72
76
  this.opts.capture?.init(this.sessionId, "agent-hub local worker session");
73
77
  this.setState("idle");
package/src/cli/main.ts CHANGED
@@ -1,5 +1,5 @@
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
4
  import { join } from "node:path";
5
5
  import { ControlClient, readControl } from "../hub/control-client.ts";
@@ -17,6 +17,11 @@ import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplac
17
17
  import { CLASSES } from "../hub/board.ts";
18
18
  import { VERSION } from "../version.ts";
19
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";
20
25
 
21
26
  const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
22
27
 
@@ -29,6 +34,9 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
29
34
  ahub setup [--yes] install or update the Claude Code channel plugin from this package, then run doctor
30
35
  ahub init write .agenthub/config.json and the CLAUDE.md / AGENTS.md marker blocks
31
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
32
40
  ahub claude [args...] launch Claude Code with the hub channel [--unattended]
33
41
  ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
34
42
  ahub kimi [--model <alias>] start Kimi headless under ACP
@@ -65,13 +73,13 @@ const [cmd = "help", ...args] = argv;
65
73
  let selected: { root: string; stateDir: string };
66
74
  try {
67
75
  if (selector) {
68
- const registry = new Registry();
69
- try {
70
- const known = registry.get(selector);
76
+ const projects = registeredProjects();
77
+ {
78
+ const known = projects.find((p) => p.id === selector);
71
79
  const context = known ?? projectContext(selector, {});
72
- const registered = known ?? registry.list().find((p) => p.root === context.root);
80
+ const registered = known ?? projects.find((p) => p.root === context.root);
73
81
  selected = registered ?? context;
74
- } finally { registry.close(); }
82
+ }
75
83
  } else selected = projectContext(process.cwd());
76
84
  } catch (error) { fail((error as Error).message); }
77
85
  const cwd = selected.root;
@@ -95,7 +103,7 @@ async function healthy(): Promise<boolean> {
95
103
  }
96
104
 
97
105
  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 } });
106
+ const res = spawnSync(bin, argv, { cwd, stdio: "inherit", env: { ...childEnv(), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
99
107
  if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
100
108
  process.exit(res.status ?? 1);
101
109
  }
@@ -166,7 +174,56 @@ async function hold(t: "pause" | "resume"): Promise<void> {
166
174
  console.log(`${args[0]} is ${res.state}`);
167
175
  }
168
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
+
169
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
+ },
170
227
  help: () => console.log(USAGE),
171
228
  "--version": () => console.log(VERSION),
172
229
  version: () => console.log(VERSION),
@@ -214,6 +271,7 @@ const commands: Record<string, () => Promise<void> | void> = {
214
271
  },
215
272
 
216
273
  setup: async () => {
274
+ assertLifecycleAvailable();
217
275
  const root = join(import.meta.dir, "..", "..");
218
276
  const state = () => {
219
277
  const out = (argv: string[]) => spawnSync("claude", argv, { encoding: "utf8" }).stdout ?? "";
@@ -239,6 +297,7 @@ const commands: Record<string, () => Promise<void> | void> = {
239
297
  },
240
298
 
241
299
  init: () => {
300
+ assertLifecycleAvailable();
242
301
  const changed = init(cwd);
243
302
  registeredProject();
244
303
  console.log(changed.length ? changed.map((p) => `wrote ${p}`).join("\n") : "already up to date");
@@ -258,7 +317,13 @@ const commands: Record<string, () => Promise<void> | void> = {
258
317
  console.log(`ahub up (ws://127.0.0.1:${status.controlPort}), state in ${stateDir}`);
259
318
  },
260
319
 
261
- 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
+ }
262
327
  // `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
263
328
  // project local, then project, then user.
264
329
  let original: { command?: string; refreshInterval?: number; padding?: number } | undefined;
@@ -275,11 +340,14 @@ const commands: Record<string, () => Promise<void> | void> = {
275
340
  },
276
341
 
277
342
  codex: async () => {
343
+ assertLifecycleAvailable();
278
344
  const launch0 = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: "pending" }); // refuse bad flags before starting anything
279
345
  const hub = await connect();
280
- 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 });
281
347
  hub.close();
282
348
  if (!res.ok) fail(res.error);
349
+ const control = readControl(stateDir);
350
+ if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
283
351
  const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: loadConfig(cwd).codex_bin });
284
352
  if (launch0.warning) console.error(launch0.warning);
285
353
  exec(launch.cmd, launch.args);
@@ -289,7 +357,7 @@ const commands: Record<string, () => Promise<void> | void> = {
289
357
  const i = args.indexOf("--model");
290
358
  const model = i === -1 ? undefined : args[i + 1] ?? fail("--model needs an alias");
291
359
  const hub = await connect();
292
- 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 });
293
361
  hub.close();
294
362
  if (!res.ok) fail(res.error);
295
363
  console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
@@ -298,7 +366,7 @@ const commands: Record<string, () => Promise<void> | void> = {
298
366
  local: async () => {
299
367
  const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
300
368
  const hub = await connect();
301
- 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 });
302
370
  hub.close();
303
371
  if (!res.ok) fail(res.error);
304
372
  console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
@@ -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
  }