@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/CHANGELOG.md +21 -0
- package/README.md +53 -3
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10 -8
- package/src/adapters/acp.ts +10 -2
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/local-worker.ts +4 -0
- package/src/adapters/pi.ts +332 -0
- package/src/cli/main.ts +154 -12
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +588 -0
- package/src/cli/upgrade-runtime.ts +329 -0
- package/src/cli/upgrade.ts +298 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +11 -0
- package/src/hub/control-client.ts +8 -6
- package/src/hub/daemon.ts +355 -11
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +9 -1
- package/src/hub/manager.ts +15 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/restart.ts +97 -0
- package/src/hub/routing.ts +25 -3
- package/src/hub/tasks.ts +5 -4
- package/src/models/mlx.ts +419 -0
- package/src/models/relay.ts +334 -0
- package/src/pi/extension.ts +120 -0
- package/src/pi/tool-receipts.ts +52 -0
- package/src/ui/index.html +4 -0
- package/templates/config.json +3 -1
- package/templates/routing.toml +19 -8
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 {
|
|
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
|
|
69
|
-
|
|
70
|
-
const known =
|
|
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 ??
|
|
84
|
+
const registered = known ?? projects.find((p) => p.root === context.root);
|
|
73
85
|
selected = registered ?? context;
|
|
74
|
-
}
|
|
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: { ...
|
|
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
|
|
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
|
}
|