@staix/agent-hub 0.5.0 → 0.6.1
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 +48 -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/pi.ts +385 -0
- package/src/cli/main.ts +75 -1
- package/src/cli/terminal-recovery.ts +17 -9
- package/src/cli/upgrade-runtime.ts +46 -16
- package/src/cli/upgrade.ts +3 -0
- package/src/hub/control-client.ts +8 -6
- package/src/hub/daemon.ts +140 -11
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/manager.ts +15 -1
- 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 +129 -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
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";
|
|
@@ -22,6 +23,7 @@ import { childEnv } from "../hub/child-process.ts";
|
|
|
22
23
|
import { abortRecovery, createOperation, publicOperation, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
23
24
|
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
24
25
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
26
|
+
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
25
27
|
|
|
26
28
|
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
27
29
|
|
|
@@ -40,6 +42,8 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
40
42
|
ahub claude [args...] launch Claude Code with the hub channel [--unattended]
|
|
41
43
|
ahub codex [args...] start the Codex adapter and attach the TUI [--unattended]
|
|
42
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
|
|
43
47
|
ahub local [--route <id> | --model <id>]
|
|
44
48
|
start the hub-native worker on the self-hosted models (routing.toml)
|
|
45
49
|
ahub say [@peer ...] <text> send as the console user (no @peer = broadcast); delivered at once,
|
|
@@ -108,6 +112,34 @@ function exec(bin: string, argv: string[]): never {
|
|
|
108
112
|
process.exit(res.status ?? 1);
|
|
109
113
|
}
|
|
110
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 });
|
|
120
|
+
if (res.error) fail(`cannot run ${bin}: ${res.error.message}`);
|
|
121
|
+
process.exit(res.status ?? 1);
|
|
122
|
+
}
|
|
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
|
+
|
|
111
143
|
async function projectRows() {
|
|
112
144
|
const registry = new Registry();
|
|
113
145
|
try { return await Promise.all(registry.list().map(async (project) => ({ ...project, ...await inspectProject(project) }))); }
|
|
@@ -363,6 +395,25 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
363
395
|
console.log(res.already ? "kimi is already attached" : 'kimi attached (headless). Talk to it with: ahub say @kimi "..."');
|
|
364
396
|
},
|
|
365
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
|
+
|
|
366
417
|
local: async () => {
|
|
367
418
|
const opt = (flag: string) => (args.includes(flag) ? args[args.indexOf(flag) + 1] ?? fail(`${flag} needs a value`) : undefined);
|
|
368
419
|
const hub = await connect();
|
|
@@ -372,6 +423,24 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
372
423
|
console.log(res.already ? "local is already attached" : `local attached on ${res.model}. Give it work with: ahub say @local "..."`);
|
|
373
424
|
},
|
|
374
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
|
+
|
|
375
444
|
say: async () => {
|
|
376
445
|
const lead = args.findIndex((a) => !/^@[a-z][a-z0-9-]*$/.test(a)); // only leading @tokens are recipients
|
|
377
446
|
const to = args.slice(0, lead === -1 ? args.length : lead).map((a) => a.slice(1));
|
|
@@ -503,6 +572,8 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
503
572
|
console.log(`hub pid ${status.pid}, control 127.0.0.1:${status.controlPort}, ${status.cwd}`);
|
|
504
573
|
const peers = Object.entries(status.peers as Record<string, { state: string; queued: number }>);
|
|
505
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}` : ""}`);
|
|
506
577
|
if (status.switchyard) console.log(` switchyard: ${status.switchyard}`);
|
|
507
578
|
const counts = Object.entries(status.tasks ?? {}).map(([s, n]) => `${n} ${s}`).join(", ");
|
|
508
579
|
if (counts) console.log(` tasks: ${counts} (ahub board)`);
|
|
@@ -548,6 +619,9 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
548
619
|
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.json");
|
|
549
620
|
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
550
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}` : ""}`);
|
|
551
625
|
|
|
552
626
|
const memory = new MemoryClient();
|
|
553
627
|
const mem = await memory.health();
|
|
@@ -23,7 +23,8 @@ export type CommandRunner = (argv: readonly string[]) => Promise<CommandResult>;
|
|
|
23
23
|
/** Stable process identity hook. It returns a digest, never raw process metadata. */
|
|
24
24
|
export type ProcessIdentity = (pid: number) => string | undefined | Promise<string | undefined>;
|
|
25
25
|
|
|
26
|
-
export type TerminalPeer = "codex" | "claude";
|
|
26
|
+
export type TerminalPeer = "codex" | "claude" | "pi";
|
|
27
|
+
export interface SessionRef { sessionId: string; sessionFile?: string; backend?: string; model?: string; }
|
|
27
28
|
|
|
28
29
|
export interface LaunchMetadata {
|
|
29
30
|
/** Absolute entrypoint of the package which owns the resumed session. */
|
|
@@ -43,6 +44,9 @@ export interface TerminalBinding {
|
|
|
43
44
|
worktreeId: string;
|
|
44
45
|
projectRoot: string;
|
|
45
46
|
sessionId: string;
|
|
47
|
+
sessionFile?: string;
|
|
48
|
+
backend?: string;
|
|
49
|
+
model?: string;
|
|
46
50
|
launch: LaunchMetadata;
|
|
47
51
|
/** Alias retained for callers that use the wire name. */
|
|
48
52
|
launchMetadata: LaunchMetadata;
|
|
@@ -353,7 +357,7 @@ function identityFrom(terminal: Record<string, unknown>): TerminalPeer | undefin
|
|
|
353
357
|
const session = object(terminal.session);
|
|
354
358
|
const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
|
|
355
359
|
for (const candidate of candidates) {
|
|
356
|
-
if (candidate === "codex" || candidate === "claude") return candidate;
|
|
360
|
+
if (candidate === "codex" || candidate === "claude" || candidate === "pi") return candidate;
|
|
357
361
|
}
|
|
358
362
|
return undefined;
|
|
359
363
|
}
|
|
@@ -381,11 +385,13 @@ function requireAbsoluteEntrypoint(value: string): string {
|
|
|
381
385
|
return value;
|
|
382
386
|
}
|
|
383
387
|
|
|
384
|
-
function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, options: Required<TerminalRecoveryOptions>, source: Record<string, unknown>, recorded?: RecordedTerminalLaunch): LaunchMetadata {
|
|
388
|
+
function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, options: Required<TerminalRecoveryOptions>, source: Record<string, unknown>, recorded?: RecordedTerminalLaunch, ref?: SessionRef): LaunchMetadata {
|
|
385
389
|
const packageEntrypoint = requireAbsoluteEntrypoint(options.packageEntrypoint);
|
|
386
390
|
const argv = peer === "codex"
|
|
387
391
|
? ["bun", packageEntrypoint, "--project", projectRoot, "codex", "resume", sessionId]
|
|
388
|
-
:
|
|
392
|
+
: peer === "claude"
|
|
393
|
+
? ["bun", packageEntrypoint, "--project", projectRoot, "claude", "--resume", sessionId]
|
|
394
|
+
: ["bun", packageEntrypoint, "--project", projectRoot, "pi", "--mode", "tui", ...(ref?.backend ? ["--backend", ref.backend] : []), ...(ref?.model ? ["--model", ref.model] : []), ...(ref?.sessionFile ? ["--session-file", ref.sessionFile] : ["--session-id", sessionId])];
|
|
389
395
|
const env = { ...allowedEnv(source), ...(recorded?.env ?? {}) };
|
|
390
396
|
const prefix = ALLOWED_ENV.filter((name) => env[name] !== undefined).map((name) => `${name}=${shellQuote(env[name]!)}`);
|
|
391
397
|
const command = [...prefix, ...argv.map(shellQuote)].join(" ");
|
|
@@ -418,13 +424,13 @@ async function showBinding(binding: TerminalBinding, options: Required<TerminalR
|
|
|
418
424
|
* Read Orca's authoritative terminal inventory and bind only exact agent/session identities.
|
|
419
425
|
* Titles, previews, pty ids and process names are deliberately never used as identity.
|
|
420
426
|
*/
|
|
421
|
-
export async function inspectTerminals(projectRoot: string, sessions: Partial<Record<TerminalPeer, string>>, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalInspection> {
|
|
427
|
+
export async function inspectTerminals(projectRoot: string, sessions: Partial<Record<TerminalPeer, string | SessionRef>>, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalInspection> {
|
|
422
428
|
const config = normalizeOptions(options);
|
|
423
429
|
const records = await liveRecords(config.stateDir, projectRoot, config.instanceId, config.processIdentity);
|
|
424
430
|
const blockers: RecoveryBlocker[] = [];
|
|
425
431
|
const bindings: TerminalBinding[] = [];
|
|
426
432
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
427
|
-
const requested = (Object.entries(sessions) as [TerminalPeer, string | undefined][]).
|
|
433
|
+
const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
|
|
428
434
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
429
435
|
|
|
430
436
|
let listed: Record<string, unknown>[];
|
|
@@ -435,7 +441,8 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
435
441
|
return { bindings, byPeer, blockers: [commandBlocker], manualRequired: true };
|
|
436
442
|
}
|
|
437
443
|
|
|
438
|
-
for (const [peer,
|
|
444
|
+
for (const [peer, ref] of requested) {
|
|
445
|
+
const sessionId = ref.sessionId;
|
|
439
446
|
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
|
|
440
447
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
441
448
|
for (const listedTerminal of candidates) {
|
|
@@ -481,8 +488,9 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
481
488
|
continue;
|
|
482
489
|
}
|
|
483
490
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
484
|
-
const
|
|
485
|
-
const
|
|
491
|
+
const sessionFile = peer === "pi" ? nestedString(terminal, ["sessionFile", "sessionPath"]) ?? nestedString(object(terminal.session), ["file", "path"]) : undefined;
|
|
492
|
+
const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
|
|
493
|
+
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
486
494
|
bindings.push(binding);
|
|
487
495
|
byPeer[peer] = binding;
|
|
488
496
|
}
|
|
@@ -6,8 +6,9 @@ import { inspectProject } from "../hub/lifecycle.ts";
|
|
|
6
6
|
import type { Project } from "../hub/registry.ts";
|
|
7
7
|
import { hubHome } from "../hub/project.ts";
|
|
8
8
|
import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
|
|
9
|
-
import { inspectTerminals, closeTerminal, createTerminal, waitForIdle, shellQuote, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
9
|
+
import { inspectTerminals, closeTerminal, createTerminal, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
|
|
10
10
|
import { planFingerprint, registeredProjects, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
|
|
11
|
+
import { refreshManager } from "../hub/manager.ts";
|
|
11
12
|
|
|
12
13
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
13
14
|
const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
|
|
@@ -19,8 +20,16 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
|
|
|
19
20
|
} };
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding): string[] {
|
|
24
|
+
if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", "resume", binding.sessionId];
|
|
25
|
+
if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", "--resume", binding.sessionId];
|
|
26
|
+
return [process.execPath, entrypoint, "--project", projectRoot, "pi", "--mode", "tui",
|
|
27
|
+
...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
|
|
28
|
+
...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
|
|
32
|
+
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, 30_000, protocol);
|
|
24
33
|
try {
|
|
25
34
|
const reply = await client.request(message, 30_000);
|
|
26
35
|
if (reply.ok === false) throw new Error(reply.error ?? "recovery control request failed");
|
|
@@ -31,13 +40,20 @@ async function rpc(project: Project, message: Record<string, unknown>): Promise<
|
|
|
31
40
|
export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
32
41
|
const base = await inspectProject(project);
|
|
33
42
|
const control = readControl(project.stateDir);
|
|
34
|
-
|
|
43
|
+
const sourceProtocol = control?.protocol;
|
|
44
|
+
const legacySupported = sourceProtocol === 8;
|
|
45
|
+
if (base.state !== "running" && !legacySupported) return {
|
|
35
46
|
state: base.state, peers: [], blockers: base.state === "stopped" ? [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
|
|
36
47
|
...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}),
|
|
37
48
|
};
|
|
38
|
-
|
|
49
|
+
let status = base.status;
|
|
50
|
+
if (!status && legacySupported) {
|
|
51
|
+
const readback = await rpc(project, { t: "status" }, sourceProtocol);
|
|
52
|
+
status = readback.status;
|
|
53
|
+
}
|
|
54
|
+
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
39
55
|
let response: any;
|
|
40
|
-
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }); }
|
|
56
|
+
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
|
|
41
57
|
catch {
|
|
42
58
|
// A stop can complete between authenticated status and the second read. Never infer stopped
|
|
43
59
|
// from a connection failure: the coordinator will inspect the ownership manifest again.
|
|
@@ -48,7 +64,8 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
|
|
|
48
64
|
return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
|
|
49
65
|
recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
|
|
50
66
|
...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
|
|
51
|
-
...(peer.
|
|
67
|
+
...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
|
|
68
|
+
...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand): Promise<UpgradePlan> {
|
|
@@ -67,7 +84,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
67
84
|
if (existsSync(managerFile)) {
|
|
68
85
|
try {
|
|
69
86
|
const manager = JSON.parse(readFileSync(managerFile, "utf8"));
|
|
70
|
-
if (manager.protocol
|
|
87
|
+
if (![8, PROTOCOL].includes(manager.protocol)) body.blockers.push("manager requires manual bootstrap with its matching CLI before protocol-9 recovery");
|
|
71
88
|
} catch { body.blockers.push("manager ownership manifest is unreadable"); }
|
|
72
89
|
}
|
|
73
90
|
for (const project of projects) {
|
|
@@ -77,16 +94,19 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
77
94
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
78
95
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
79
96
|
const blockers = [...source.blockers];
|
|
80
|
-
if (source.protocol
|
|
97
|
+
if (![8, PROTOCOL].includes(source.protocol ?? 0) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated recovery-capable source is required");
|
|
81
98
|
if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
|
|
82
|
-
const sessions: { codex?: string; claude?: string } = {};
|
|
99
|
+
const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
|
|
83
100
|
for (const peer of source.peers) {
|
|
84
101
|
if (peer.state === "offline") continue;
|
|
85
102
|
if (peer.id === "codex" || peer.id === "claude") {
|
|
86
103
|
const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
|
|
87
104
|
if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required`);
|
|
88
105
|
else sessions[peer.id] = session;
|
|
89
|
-
} else if (peer.id
|
|
106
|
+
} else if (peer.id === "pi" && peer.args?.mode === "tui") {
|
|
107
|
+
if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required");
|
|
108
|
+
else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
|
|
109
|
+
} else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
|
|
90
110
|
}
|
|
91
111
|
const terminals = await inspectTerminals(project.root, sessions, {
|
|
92
112
|
...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
|
|
@@ -147,7 +167,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
147
167
|
if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
|
|
148
168
|
return result;
|
|
149
169
|
};
|
|
150
|
-
const control = (project: Project, op: string, id: string, instance: string) =>
|
|
170
|
+
const control = (project: Project, op: string, id: string, instance: string) => {
|
|
171
|
+
const sourceProtocol = readControl(project.stateDir)?.protocol;
|
|
172
|
+
const protocol = sourceProtocol === 8 ? 8 : PROTOCOL;
|
|
173
|
+
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance }, protocol).then(() => {});
|
|
174
|
+
};
|
|
151
175
|
const revalidateTerminal = async (planned: PlannedProject, progress: ProjectProgress, saved: TerminalBinding, exact: boolean): Promise<TerminalBinding> => {
|
|
152
176
|
const options = { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId };
|
|
153
177
|
const found = await inspectTerminals(planned.project.root, { [saved.peer]: saved.sessionId }, options);
|
|
@@ -218,11 +242,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
218
242
|
const live = await inspectRecovery(planned.project);
|
|
219
243
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
220
244
|
if (group === "native") {
|
|
221
|
-
for (const peer of planned.source.peers.filter((p) => ["kimi", "local"].includes(p.id) && p.state !== "offline")) {
|
|
245
|
+
for (const peer of planned.source.peers.filter((p) => ["kimi", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
|
|
222
246
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
223
247
|
if (current && current.state !== "offline") continue;
|
|
224
248
|
const args = [peer.id];
|
|
225
|
-
for (const key of ["model", "route"]) if (typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
|
|
249
|
+
for (const key of ["mode", "backend", "model", "route"]) if (typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
|
|
250
|
+
if (peer.id === "pi") {
|
|
251
|
+
const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
|
|
252
|
+
if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
|
|
253
|
+
else if (peer.sessionId) args.push("--session-id", peer.sessionId);
|
|
254
|
+
}
|
|
226
255
|
await command(op, args, planned.project);
|
|
227
256
|
}
|
|
228
257
|
}
|
|
@@ -243,8 +272,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
243
272
|
progress.terminals[key] = existing; save(); continue;
|
|
244
273
|
}
|
|
245
274
|
const entrypoint = join(op.targetRoot!, "src/cli/main.js");
|
|
246
|
-
const argv =
|
|
247
|
-
...(original.peer === "codex" ? ["resume", original.sessionId] : ["--resume", original.sessionId])];
|
|
275
|
+
const argv = restoredTerminalArgv(entrypoint, planned.project.root, original);
|
|
248
276
|
const assignments = { ...original.launch.env, AGENTHUB_HOME: hubHome(), AGENTHUB_RECOVERY_OPERATION: op.id };
|
|
249
277
|
const launch = { ...original.launch, packageEntrypoint: entrypoint, argv,
|
|
250
278
|
command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
|
|
@@ -273,6 +301,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
273
301
|
const readback = await run(["ahub", "--version"], { env: env(op) });
|
|
274
302
|
if (readback.code !== 0 || readback.stdout.trim() !== op.plan.version) throw new Error("global CLI version was not verified");
|
|
275
303
|
},
|
|
304
|
+
refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
|
|
276
305
|
verify: async (planned, progress, op) => {
|
|
277
306
|
const live = await inspectRecovery(planned.project);
|
|
278
307
|
if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");
|
|
@@ -281,6 +310,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
|
|
|
281
310
|
if (!peer || !["idle", "busy", "paused"].includes(peer.state)) throw new Error(`${old.id}: peer reattachment not verified`);
|
|
282
311
|
if (old.id === "codex" && peer.threadId !== old.threadId) throw new Error("Codex resumed a different conversation");
|
|
283
312
|
if (old.id === "claude" && peer.sessionId !== old.sessionId) throw new Error("Claude resumed a different conversation");
|
|
313
|
+
if (old.id === "pi" && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw new Error("Pi resumed a different conversation");
|
|
284
314
|
}
|
|
285
315
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
286
316
|
const saved = progress.terminals[`restored:${original.peer}`];
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface RecoveryPeer {
|
|
|
11
11
|
state: string;
|
|
12
12
|
threadId?: string;
|
|
13
13
|
sessionId?: string;
|
|
14
|
+
sessionFile?: string;
|
|
14
15
|
args?: Record<string, string>;
|
|
15
16
|
}
|
|
16
17
|
export interface Inspection {
|
|
@@ -112,6 +113,7 @@ export interface RecoveryDriver {
|
|
|
112
113
|
restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
|
|
113
114
|
installPlugin(op: RecoveryOperation): Promise<void>;
|
|
114
115
|
installGlobal(op: RecoveryOperation): Promise<void>;
|
|
116
|
+
refreshManager?(op: RecoveryOperation): Promise<void>;
|
|
115
117
|
release(project: Project, operation: string, instance: string): Promise<void>;
|
|
116
118
|
verify(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation): Promise<void>;
|
|
117
119
|
sleep(ms: number): Promise<void>;
|
|
@@ -245,6 +247,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
245
247
|
await driver.release(planned.project, id, progress.instanceId!);
|
|
246
248
|
progress.phase = "verified"; save();
|
|
247
249
|
}
|
|
250
|
+
if (driver.refreshManager) { step("refresh-manager"); await driver.refreshManager(op); }
|
|
248
251
|
if (op.plan.kind === "upgrade" && !op.globalInstalled) {
|
|
249
252
|
step("install-global"); await driver.installGlobal(op); op.globalInstalled = true; save();
|
|
250
253
|
}
|
|
@@ -6,8 +6,8 @@ export function stateDirFor(cwd: string): string {
|
|
|
6
6
|
return projectContext(cwd).stateDir;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap. The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
-
export const PROTOCOL =
|
|
9
|
+
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata. The plugin is installed apart from the daemon, so they can drift. */
|
|
10
|
+
export const PROTOCOL = 9; // controlled restart/recovery plus the Pi bridge contract
|
|
11
11
|
|
|
12
12
|
export interface Hello {
|
|
13
13
|
/** `tools`: acts for `peer` (task tools, hub_send) without being a delivery target: the MCP server Kimi and Codex run. */
|
|
@@ -51,11 +51,13 @@ export class ControlClient {
|
|
|
51
51
|
|
|
52
52
|
private constructor(private readonly ws: WebSocket) {}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/** `protocol` is used only by the upgrade coordinator for an authenticated, explicitly supported source contract. */
|
|
55
|
+
static connect(stateDir: string, hello: Hello, timeoutMs = 3000, protocol = PROTOCOL): Promise<ControlClient> {
|
|
56
|
+
if (protocol !== 8 && protocol !== PROTOCOL) return Promise.reject(new Error(`unsupported recovery source protocol ${protocol}`));
|
|
55
57
|
const control = readControl(stateDir);
|
|
56
58
|
if (!control) return Promise.reject(new Error(`no hub running for ${stateDir} (run: ahub up)`));
|
|
57
|
-
if (control.protocol !== undefined && control.protocol !==
|
|
58
|
-
return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${
|
|
59
|
+
if (control.protocol !== undefined && control.protocol !== protocol) {
|
|
60
|
+
return Promise.reject(Object.assign(new Error(`wire version mismatch: hub speaks ${control.protocol}, CLI speaks ${protocol}; stop it with its matching CLI, then upgrade and restart`), { code: 4426 }));
|
|
59
61
|
}
|
|
60
62
|
if ((hello.projectId && hello.projectId !== control.projectId) ||
|
|
61
63
|
(hello.instanceId && hello.instanceId !== control.instanceId) ||
|
|
@@ -87,7 +89,7 @@ export class ControlClient {
|
|
|
87
89
|
client.pending.delete(msg.rid);
|
|
88
90
|
done(msg);
|
|
89
91
|
};
|
|
90
|
-
ws.onopen = () => void client.request({ t: "hello", v:
|
|
92
|
+
ws.onopen = () => void client.request({ t: "hello", v: protocol, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
|
|
91
93
|
if (reply.t !== "welcome" || reply.ok === false) return refuse(new Error(reply.error ?? "hub refused handshake"));
|
|
92
94
|
if ((expected.projectId && reply.projectId !== expected.projectId) ||
|
|
93
95
|
(expected.instanceId && reply.instanceId !== expected.instanceId) ||
|