@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/CHANGELOG.md +22 -0
- package/README.md +58 -3
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +148 -20
- package/src/adapters/acp.ts +15 -3
- package/src/adapters/claude-channel.ts +9 -3
- package/src/adapters/codex-appserver.ts +47 -9
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/main.ts +188 -68
- package/src/cli/recovery-package.ts +99 -0
- package/src/cli/statusline-tee.ts +11 -2
- package/src/cli/terminal-recovery.ts +580 -0
- package/src/cli/upgrade-runtime.ts +299 -0
- package/src/cli/upgrade.ts +295 -0
- package/src/hub/budget.ts +32 -1
- package/src/hub/bus.ts +79 -4
- package/src/hub/child-process.ts +55 -0
- package/src/hub/control-client.ts +50 -9
- package/src/hub/daemon.ts +326 -45
- package/src/hub/lifecycle.ts +189 -0
- package/src/hub/manager.ts +232 -0
- package/src/hub/peers.ts +2 -0
- package/src/hub/ports.ts +40 -11
- package/src/hub/project.ts +93 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/registry.ts +204 -0
- package/src/hub/restart.ts +97 -0
- package/src/hub/ui.ts +29 -4
- package/src/memory/recall.ts +26 -3
- package/src/switchyard/sidecar.ts +39 -5
- package/src/ui/index.html +103 -18
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,
|
|
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
|
|
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
|
|
49
|
-
|
|
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
|
|
89
|
+
const lifecycle = { inspectProject, startProject, stopProject };
|
|
90
|
+
const connect = () => ControlClient.connect(stateDir, { role: "console", projectRoot: cwd });
|
|
52
91
|
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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: { ...
|
|
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) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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, [
|
|
130
|
-
if (opened.error || opened.status !== 0) console.log(`Open this one-time link within 60 seconds:\n${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
404
|
-
console.log("hub
|
|
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
|
|
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
|
}
|