@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/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 { join } from "node:path";
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
- : ["bun", packageEntrypoint, "--project", projectRoot, "claude", "--resume", sessionId];
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][]).filter((entry): entry is [TerminalPeer, string] => typeof entry[1] === "string" && entry[1].length > 0);
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, sessionId] of requested) {
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 launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded);
485
- const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, launch, launchMetadata: launch };
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
- async function rpc(project: Project, message: Record<string, unknown>): Promise<any> {
23
- const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id });
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
- if (base.state !== "running") return {
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
- const status = base.status;
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.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
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 !== PROTOCOL) body.blockers.push("manager requires manual bootstrap with its matching CLI before protocol-8 recovery");
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 !== PROTOCOL || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated recovery-capable source is required");
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 !== "kimi" && peer.id !== "local") blockers.push(`${peer.id}: no automatic recovery adapter`);
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) => rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance }).then(() => {});
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 = [process.execPath, entrypoint, "--project", planned.project.root, original.peer,
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}`];
@@ -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 = 8; // controlled daemon restart/recovery RPC and project/instance identity
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
- static connect(stateDir: string, hello: Hello, timeoutMs = 3000): Promise<ControlClient> {
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 !== PROTOCOL) {
58
- 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
+ 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: PROTOCOL, token: control.token, ...hello, ...expected }, timeoutMs).then((reply) => {
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) ||