@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 +13 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- 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/cli/main.ts +79 -11
- 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 +11 -0
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +227 -3
- package/src/hub/lifecycle.ts +9 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +89 -0
- package/src/hub/restart.ts +97 -0
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.
|
|
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.
|
|
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": "agent-hub",
|
|
3
|
-
"version": "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 =
|
|
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.
|
|
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",
|
package/src/adapters/acp.ts
CHANGED
|
@@ -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
|
|
69
|
-
|
|
70
|
-
const known =
|
|
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 ??
|
|
80
|
+
const registered = known ?? projects.find((p) => p.root === context.root);
|
|
73
81
|
selected = registered ?? context;
|
|
74
|
-
}
|
|
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: { ...
|
|
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
|
|
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
|
}
|