@staix/agent-hub 0.12.21 → 0.12.22
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 +10 -0
- package/README.md +13 -6
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +4 -0
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +13 -4
- package/docs/operations.md +253 -22
- package/docs/quickstart.md +6 -3
- package/docs/security.md +9 -6
- package/docs/smoke.md +61 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -5
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verified.json +21 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/acp.ts +10 -4
- package/src/adapters/codex-appserver.ts +27 -22
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +2 -2
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +137 -18
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +56 -2
- package/src/cli/upgrade.ts +27 -10
- package/src/hub/bus.ts +5 -3
- package/src/hub/daemon.ts +145 -60
- package/src/hub/events.ts +2 -1
- package/src/hub/peers.ts +2 -2
- package/src/hub/recovery-store.ts +38 -0
- package/src/hub/tasks.ts +32 -15
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +25 -6
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +17 -1
- package/src/ui/index.html +15 -11
package/src/cli/main.ts
CHANGED
|
@@ -18,16 +18,19 @@ import { OmniRoute } from "../omniroute/client.ts";
|
|
|
18
18
|
import { MemoryClient } from "../memory/client.ts";
|
|
19
19
|
import { init, planInit } from "./init.ts";
|
|
20
20
|
import { launcherPreview } from "./preview.ts";
|
|
21
|
-
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, UNATTENDED_WARNING } from "./launch.ts";
|
|
21
|
+
import { cleanupStaleClaudeSettings, cleanupClaudeSettings, buildLaunch, claudeObservationHooks, nativeLaunchEnv, recordClaudeLaunch, runClaudeLaunch, type ClaudeLaunchRecord, UNATTENDED_WARNING } from "./launch.ts";
|
|
22
22
|
import { nextStep, parseList, pluginState, type InstalledPlugin, type Marketplace } from "./setup.ts";
|
|
23
23
|
import { CLASSES } from "../hub/board.ts";
|
|
24
24
|
import { VERSION } from "../version.ts";
|
|
25
25
|
import { freeText } from "./free-text.ts";
|
|
26
26
|
import { createInterface } from "node:readline/promises";
|
|
27
|
-
import {
|
|
27
|
+
import { createInterface as lineReader } from "node:readline";
|
|
28
|
+
import { activeOperation, assertLifecycleAvailable, operationIdPattern, readOperation, recoveryLock, recoveryRunner, signedRunner, stopSignedRunner } from "../hub/recovery-store.ts";
|
|
28
29
|
import { childEnv } from "../hub/child-process.ts";
|
|
29
30
|
import { abortRecovery, createOperation, disposeRecovery, liveProjects, nextActionsText, publicOperation, recoveryCommand, registeredProjects, runRecovery, type RecoveryOperation } from "./upgrade.ts";
|
|
30
|
-
import { makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
31
|
+
import { endPlannedPeer, makeRecoveryDriver, makeUpgradePlan, preserveSource } from "./upgrade-runtime.ts";
|
|
32
|
+
import { exactVersion, latestRelease, newerVersion } from "./recovery-package.ts";
|
|
33
|
+
import { operationScreen, planScreen, type ScreenIO, type UpgradeHost } from "./upgrade-interactive.ts";
|
|
31
34
|
import { recordTerminalLaunch } from "./terminal-recovery.ts";
|
|
32
35
|
import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
33
36
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
@@ -339,32 +342,124 @@ async function hold(t: "pause" | "resume"): Promise<void> {
|
|
|
339
342
|
console.log(`${args[0]} is ${res.state}`);
|
|
340
343
|
}
|
|
341
344
|
|
|
342
|
-
function spawnRecovery(operation: RecoveryOperation): void {
|
|
345
|
+
function spawnRecovery(operation: RecoveryOperation, quiet = false): void {
|
|
343
346
|
const child = spawn(process.execPath, [join(operation.sourceRoot, "src/cli/main.js"), "recovery-run", operation.id], {
|
|
344
347
|
cwd, detached: true, stdio: "ignore", env: { ...process.env, AGENTHUB_RECOVERY_OPERATION: operation.id },
|
|
345
348
|
});
|
|
346
349
|
child.on("error", () => console.error(`runner launch failed; use ${recoveryCommand(operation, "resume")}`));
|
|
347
350
|
child.unref();
|
|
348
351
|
// #215: the operation's own coordinator, which has every recovery command; the global ahub may be older mid-upgrade.
|
|
349
|
-
console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
352
|
+
if (!quiet) console.log(`Recovery operation ${operation.id} scheduled.\n${recoveryCommand(operation, "status")}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** #272: a terminal session for the interactive screens. One line reader for the whole session, in the terminal's own
|
|
356
|
+
* line mode, so Ctrl+C stays a signal: from then on every question answers null and the screens leave. The terminal
|
|
357
|
+
* is not read again after it (a parent that died on the same Ctrl+C may have handed it back to the shell), and no
|
|
358
|
+
* runner is ever stopped by it. */
|
|
359
|
+
async function onTerminal(screens: (io: ScreenIO) => Promise<void>): Promise<void> {
|
|
360
|
+
const rl = lineReader({ input: process.stdin, terminal: false });
|
|
361
|
+
const lines: string[] = [], waiters: ((line: string | null) => void)[] = [];
|
|
362
|
+
let ended = false, interrupted = false;
|
|
363
|
+
rl.on("line", (line) => { const next = waiters.shift(); if (next) next(line.trim()); else lines.push(line.trim()); });
|
|
364
|
+
rl.on("close", () => { ended = true; for (const waiter of waiters.splice(0)) waiter(null); });
|
|
365
|
+
const onInterrupt = () => { interrupted = true; for (const waiter of waiters.splice(0)) waiter(null); };
|
|
366
|
+
process.on("SIGINT", onInterrupt);
|
|
367
|
+
try {
|
|
368
|
+
await screens({
|
|
369
|
+
ask: (question) => {
|
|
370
|
+
if (interrupted) return Promise.resolve(null);
|
|
371
|
+
process.stdout.write(question);
|
|
372
|
+
const typed = lines.shift();
|
|
373
|
+
return typed !== undefined ? Promise.resolve(typed) : ended ? Promise.resolve(null) : new Promise((resolve) => waiters.push(resolve));
|
|
374
|
+
},
|
|
375
|
+
out: (line) => console.log(line),
|
|
376
|
+
run: (argv) => new Promise((resolve) => {
|
|
377
|
+
// The command owns the terminal while it runs; a reader whose input already ended has nothing to pause.
|
|
378
|
+
if (!ended) rl.pause();
|
|
379
|
+
const done = (code: number) => { if (!ended) rl.resume(); resolve(code); };
|
|
380
|
+
const child = spawn(process.execPath, argv, { cwd, stdio: "inherit" });
|
|
381
|
+
child.on("error", () => done(1));
|
|
382
|
+
child.on("close", (code) => done(code ?? 1));
|
|
383
|
+
}),
|
|
384
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
385
|
+
interrupted: () => interrupted,
|
|
386
|
+
// Scripted input (a pipe) is answers typed ahead for the next question; only a terminal's line is a key press.
|
|
387
|
+
typed: () => !ended && lines.splice(0).length > 0,
|
|
388
|
+
});
|
|
389
|
+
} finally { process.off("SIGINT", onInterrupt); rl.close(); }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function upgradeHost(kind: "restart" | "upgrade", version: string): UpgradeHost {
|
|
393
|
+
const scope = kind === "restart" ? cwd : undefined;
|
|
394
|
+
return {
|
|
395
|
+
plan: () => makeUpgradePlan(kind, version, scope),
|
|
396
|
+
apply: async (plan, interrupted) => {
|
|
397
|
+
assertLifecycleAvailable();
|
|
398
|
+
const current = await makeUpgradePlan(kind, plan.version, scope);
|
|
399
|
+
if (current.fingerprint !== plan.fingerprint) throw new Error("the plan changed during review; review it again");
|
|
400
|
+
// The check above takes seconds: a Ctrl+C in that time is still a no.
|
|
401
|
+
if (interrupted()) throw new Error("interrupted; nothing was started");
|
|
402
|
+
const operation = createOperation(plan, preserveSource(plan));
|
|
403
|
+
spawnRecovery(operation, true);
|
|
404
|
+
return operation;
|
|
405
|
+
},
|
|
406
|
+
lock: recoveryLock,
|
|
407
|
+
read: (id) => readOperation<RecoveryOperation>(id),
|
|
408
|
+
runner: recoveryRunner,
|
|
409
|
+
live: (op) => liveProjects(op, makeRecoveryDriver().inspect),
|
|
410
|
+
endPeer: (planned, peer) => endPlannedPeer(planned, peer),
|
|
411
|
+
runnerStoppable: (id) => signedRunner(id) !== undefined,
|
|
412
|
+
stopRunner: (id) => stopSignedRunner(id),
|
|
413
|
+
entry: join(import.meta.dir, "main.js"),
|
|
414
|
+
version: VERSION,
|
|
415
|
+
};
|
|
350
416
|
}
|
|
351
417
|
|
|
352
418
|
async function upgrade(kind: "restart" | "upgrade"): Promise<void> {
|
|
353
419
|
const { one, rest } = takeFlags(args, ["--to"], []);
|
|
354
|
-
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]");
|
|
355
|
-
if (kind === "upgrade" && !one["--to"]) fail("upgrade requires --to <exact-version>");
|
|
420
|
+
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]");
|
|
356
421
|
if (kind === "upgrade" && selector) fail("upgrade changes the shared package/plugin; omit --project to review all affected running projects");
|
|
357
|
-
const
|
|
422
|
+
const dry = rest.includes("--dry-run"), yes = rest.includes("--yes");
|
|
423
|
+
// #272: on a terminal, with neither flag, the plan is reviewed, applied and followed in one session.
|
|
424
|
+
const interactive = !dry && !yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
425
|
+
let to = one["--to"];
|
|
426
|
+
if (kind === "upgrade" && !to) {
|
|
427
|
+
if (yes || (!dry && !interactive)) fail("upgrade requires --to <exact-version>");
|
|
428
|
+
to = await latestRelease();
|
|
429
|
+
console.error(`ahub: the latest release is ${to}`);
|
|
430
|
+
}
|
|
431
|
+
// Before the value reaches a package spec or the registry: a range such as 0.13 would resolve to whatever matches.
|
|
432
|
+
if (to !== undefined) { try { exactVersion(to); } catch (error) { fail((error as Error).message); } }
|
|
433
|
+
// An older CLI must not coordinate a newer target (docs/operations.md): the target's own coordinator takes over.
|
|
434
|
+
if (kind === "upgrade" && newerVersion(to!, VERSION) && process.env.AGENTHUB_UPGRADE_HANDOVER !== "1") {
|
|
435
|
+
const argv = ["x", "--package", `@staix/agent-hub@${to}`, "ahub", "upgrade", "--to", to!, ...rest];
|
|
436
|
+
console.error(`ahub: ${to} is newer than this CLI (${VERSION}), so its own coordinator runs the upgrade: bun ${argv.join(" ")}`);
|
|
437
|
+
if (interactive) {
|
|
438
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
439
|
+
try { if (!/^(|y|yes)$/i.test((await rl.question("Continue? [Y/n] ")).trim())) return; } finally { rl.close(); }
|
|
440
|
+
}
|
|
441
|
+
// The coordinator owns the terminal from here. This process only waits, and a Ctrl+C meant for that session must not
|
|
442
|
+
// end it first and hand the terminal back to the shell under it.
|
|
443
|
+
process.on("SIGINT", () => {});
|
|
444
|
+
const code = await new Promise<number>((resolve) => {
|
|
445
|
+
const child = spawn(process.execPath, argv, { cwd, stdio: "inherit", env: { ...process.env, AGENTHUB_UPGRADE_HANDOVER: "1" } });
|
|
446
|
+
child.on("error", (error) => { console.error(`ahub: cannot run bun x: ${error.message}`); resolve(1); });
|
|
447
|
+
child.on("close", (status) => resolve(status ?? 1));
|
|
448
|
+
});
|
|
449
|
+
process.exit(code);
|
|
450
|
+
}
|
|
451
|
+
if (interactive) return onTerminal((io) => planScreen(upgradeHost(kind, to ?? VERSION), io));
|
|
452
|
+
const plan = await makeUpgradePlan(kind, to ?? VERSION, kind === "restart" ? cwd : undefined);
|
|
358
453
|
console.log(JSON.stringify(plan, null, 2));
|
|
359
454
|
// #206: name every blocker and reconnect-only session on stderr, not only inside the JSON above.
|
|
360
455
|
for (const p of plan.projects) for (const peer of p.reconnectOnly ?? []) console.error(`ahub: ${p.project.id}: ${peer} is reconnect-only (unmanaged session): its plugin reattaches to the new hub; no terminal is closed or relaunched`);
|
|
361
456
|
for (const p of plan.projects) for (const peer of p.freshStart ?? []) console.error(`ahub: ${p.project.id}: ${peer} restarts as a new session: its thread has no rollout and the hub recorded no turn on it, so nothing is lost`);
|
|
362
457
|
const blockers = [...plan.blockers, ...plan.projects.flatMap((p) => p.blockers.map((b) => `${p.project.id}: ${b}`))];
|
|
363
458
|
for (const blocker of blockers) console.error(`ahub: blocker: ${blocker}`);
|
|
364
|
-
if (
|
|
459
|
+
if (dry) return;
|
|
365
460
|
if (blockers.length) fail("plan has blockers; no runtime was changed");
|
|
366
461
|
assertLifecycleAvailable();
|
|
367
|
-
if (!
|
|
462
|
+
if (!yes) {
|
|
368
463
|
if (!process.stdin.isTTY) fail("review --dry-run and use --yes in non-interactive sessions");
|
|
369
464
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
370
465
|
try {
|
|
@@ -382,9 +477,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
382
477
|
upgrade: () => upgrade("upgrade"),
|
|
383
478
|
restart: () => upgrade("restart"),
|
|
384
479
|
recovery: async () => {
|
|
385
|
-
const [action,
|
|
386
|
-
const usage = "usage: ahub recovery status|resume|abort <operation-id> | ahub recovery dispose <operation-id> --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
387
|
-
|
|
480
|
+
const [action, given, ...more] = args;
|
|
481
|
+
const usage = "usage: ahub recovery [status|resume|abort] [<operation-id>] | ahub recovery dispose [<operation-id>] --fresh-session <peer>|--stop-and-archive --reason <text>";
|
|
482
|
+
// #272: without an id, the operation that holds the machine's lock; bare on a terminal, its screen.
|
|
483
|
+
const named = !!given && operationIdPattern.test(given);
|
|
484
|
+
const id = named ? given : recoveryLock();
|
|
485
|
+
const rest = named || given === undefined ? more : [given, ...more];
|
|
486
|
+
if (action === undefined && process.stdin.isTTY && process.stdout.isTTY) {
|
|
487
|
+
if (!id) return console.log("no recovery operation is open");
|
|
488
|
+
return onTerminal(async (io) => { await operationScreen(upgradeHost("restart", VERSION), io, id); });
|
|
489
|
+
}
|
|
490
|
+
if (!["status", "resume", "abort", "dispose"].includes(action ?? "") || (action !== "dispose" && rest.length)) fail(usage);
|
|
491
|
+
if (!id) fail("no recovery operation holds the lock; name one by its id");
|
|
388
492
|
const operation = readOperation<RecoveryOperation>(id);
|
|
389
493
|
const runner = recoveryRunner(id);
|
|
390
494
|
if (action === "status") {
|
|
@@ -556,18 +660,18 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
556
660
|
let previousLaunch: unknown;
|
|
557
661
|
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")); } catch { /* no previous managed launch */ }
|
|
558
662
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(import.meta.dir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
559
|
-
let result: ReturnType<typeof
|
|
663
|
+
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
560
664
|
try {
|
|
665
|
+
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
561
666
|
if (control?.instanceId) {
|
|
562
667
|
// Publish only the final launch's installed hook and effective permission flags.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
chmodSync(`${file}.tmp`, 0o600); renameSync(`${file}.tmp`, file);
|
|
668
|
+
launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
669
|
+
recordClaudeLaunch(stateDir, launchRecord);
|
|
566
670
|
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
567
671
|
}
|
|
568
672
|
if (launch.warning) console.error(launch.warning);
|
|
569
673
|
const env = nativeLaunchEnv("claude", childEnv());
|
|
570
|
-
result =
|
|
674
|
+
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
571
675
|
} finally {
|
|
572
676
|
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
573
677
|
}
|
|
@@ -853,6 +957,20 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
853
957
|
pause: () => hold("pause"),
|
|
854
958
|
resume: () => hold("resume"),
|
|
855
959
|
|
|
960
|
+
stop: async () => {
|
|
961
|
+
const peer = args[0];
|
|
962
|
+
if (args.length !== 1 || !peer || !/^[a-z][a-z0-9-]{0,31}$/.test(peer)) fail("usage: ahub stop <peer>");
|
|
963
|
+
const hub = await connect();
|
|
964
|
+
try {
|
|
965
|
+
const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
|
|
966
|
+
if (reply.ok !== true) {
|
|
967
|
+
const error = String(reply.error ?? "peer stop request refused");
|
|
968
|
+
fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error);
|
|
969
|
+
}
|
|
970
|
+
console.log(`${peer}: ${reply.state}`);
|
|
971
|
+
} finally { hub.close(); }
|
|
972
|
+
},
|
|
973
|
+
|
|
856
974
|
permission: async () => {
|
|
857
975
|
const positional = args.filter(arg => arg !== "--yes");
|
|
858
976
|
const [peer, mode] = positional;
|
|
@@ -868,6 +986,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
868
986
|
}
|
|
869
987
|
if (reply.peers) for (const [id, value] of Object.entries(reply.peers)) console.log(`${id}: ${value}`);
|
|
870
988
|
else console.log(`${peer}: ${reply.permissionMode}`);
|
|
989
|
+
if (typeof reply.note === "string" && reply.note) console.log(reply.note);
|
|
871
990
|
} finally { hub.close(); }
|
|
872
991
|
},
|
|
873
992
|
|
|
@@ -67,6 +67,23 @@ export async function registryRelease(version: string, run: RunCommand = runComm
|
|
|
67
67
|
return { version, integrity: data["dist.integrity"] };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** #272: the registry's latest release, the target when `--to` is left out. */
|
|
71
|
+
export async function latestRelease(run: RunCommand = runCommand): Promise<string> {
|
|
72
|
+
const result = await run(["npm", "view", "@staix/agent-hub", "version", "--json"], { timeoutMs: 30_000 });
|
|
73
|
+
if (result.code !== 0) throw new Error("registry metadata unavailable for @staix/agent-hub; name the release with --to <version>");
|
|
74
|
+
let version: unknown;
|
|
75
|
+
try { version = JSON.parse(result.stdout); } catch { /* reported below */ }
|
|
76
|
+
if (typeof version !== "string") throw new Error("registry returned an unexpected latest release; name the release with --to <version>");
|
|
77
|
+
return exactVersion(version);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ponytail: compares the numeric triple only, so a pre-release never counts as newer than its own release; a semver compare if pre-releases are ever published. */
|
|
81
|
+
export function newerVersion(a: string, b: string): boolean {
|
|
82
|
+
const [x, y] = [a, b].map((v) => v.split("-")[0]!.split(".").map(Number));
|
|
83
|
+
for (let i = 0; i < 3; i++) if (x![i] !== y![i]) return (x![i] ?? 0) > (y![i] ?? 0);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
/** Stage outside the mutable global install; retain successful versions for recovery. */
|
|
71
88
|
export async function stageRelease(version: string, integrity: string, run: RunCommand = runCommand, home = hubHome()): Promise<{ root: string; digest: string }> {
|
|
72
89
|
exactVersion(version);
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { recoveryArgv } from "../hub/recovery-store.ts";
|
|
2
|
+
import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* #272: the interactive screens of `ahub upgrade`, `ahub restart` and bare `ahub recovery`. They add no way to change
|
|
6
|
+
* an operation: every action is an existing command (`recovery resume|abort|dispose`, `reset`) run for the person
|
|
7
|
+
* through `io.run`, so each command's own refusals decide. The one thing a screen does itself is stop a runner that
|
|
8
|
+
* only waits, and only one whose claim is verified (`runnerStoppable`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The terminal a screen talks to; tests script it. */
|
|
12
|
+
export interface ScreenIO {
|
|
13
|
+
/** One trimmed line from the person; null when the input ended or they pressed Ctrl+C, which no prompt takes for text. */
|
|
14
|
+
ask(question: string): Promise<string | null>;
|
|
15
|
+
out(line: string): void;
|
|
16
|
+
/** Run an ahub entry with the terminal attached (argv after `bun`); resolves with its exit code. */
|
|
17
|
+
run(argv: string[]): Promise<number>;
|
|
18
|
+
sleep(ms: number): Promise<void>;
|
|
19
|
+
/** True from the person's Ctrl+C on: the screens leave, and nothing is stopped. */
|
|
20
|
+
interrupted(): boolean;
|
|
21
|
+
/** True once when a line was entered while nothing was asked (during a follow); that line is dropped. */
|
|
22
|
+
typed(): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the screens read and start. */
|
|
26
|
+
export interface UpgradeHost {
|
|
27
|
+
plan(): Promise<UpgradePlan>;
|
|
28
|
+
/** Check the reviewed plan again and start it as a detached operation; `interrupted` is asked last, before anything is created. */
|
|
29
|
+
apply(plan: UpgradePlan, interrupted: () => boolean): Promise<RecoveryOperation>;
|
|
30
|
+
/** End one attached agent now: close a TUI agent's terminal, or ask the hub to stop a headless one. One line of outcome. */
|
|
31
|
+
endPeer(planned: PlannedProject, peer: RecoveryPeer): Promise<string>;
|
|
32
|
+
/** The operation that holds the machine's recovery lock. */
|
|
33
|
+
lock(): string | undefined;
|
|
34
|
+
read(id: string): RecoveryOperation;
|
|
35
|
+
runner(id: string): number | "unknown" | undefined;
|
|
36
|
+
live(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
|
|
37
|
+
/** Whether the runner's claim carries a process signature that still matches: the only kind a screen may stop. */
|
|
38
|
+
runnerStoppable(id: string): boolean;
|
|
39
|
+
/** Stop that verified runner and wait until it is gone; false when one still holds the operation. */
|
|
40
|
+
stopRunner(id: string): Promise<boolean>;
|
|
41
|
+
/** This release's CLI entry, for `reset`. */
|
|
42
|
+
entry: string;
|
|
43
|
+
/** This CLI's version. */
|
|
44
|
+
version: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await io.ask(question)) ?? "");
|
|
48
|
+
/** The way back after Ctrl+C: the operation's own coordinator, since the installed `ahub` may still be the older release (#215). */
|
|
49
|
+
const left = (op: { id: string; sourceRoot?: string }): string => `left: the runner keeps working; \`bun ${recoveryArgv(op, "status")[0]} recovery\` shows the operation and what can be done`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How an attached peer runs, which decides how it can be ended: a TUI agent in a terminal the plan could bind, a
|
|
53
|
+
* headless agent the hub owns, or `unmanaged` (shown as `own`): a session the hub did not launch, or one it launched
|
|
54
|
+
* whose terminal the plan could not bind (a person ends that one where it runs).
|
|
55
|
+
*/
|
|
56
|
+
export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
|
|
57
|
+
if (peer.state === "offline") return "offline";
|
|
58
|
+
if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
|
|
59
|
+
if (peer.id === "claude" || peer.id === "codex" || (peer.id === "pi" && peer.args?.mode === "tui")) return "unmanaged";
|
|
60
|
+
return "headless";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const STEPS: Record<string, string> = {
|
|
64
|
+
stage: "staging the release", prepare: "holding deliveries and waiting until the hub is quiet", reprepare: "preparing the source again after an expired hold",
|
|
65
|
+
commit: "closing terminals and stopping the old hub", restart: "restarting a target that stopped", start: "starting the new hub", restore: "restoring Codex and Pi sessions",
|
|
66
|
+
"install-plugin": "installing the Claude plugin", "restore-claude": "restoring the Claude session and verifying the hub", release: "releasing held deliveries",
|
|
67
|
+
"refresh-manager": "refreshing the dashboard manager", "install-global": "installing the global CLI", completed: "completed", cancelled: "cancelled",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** A receipt step (`commit:<project>`) in a person's words; a step this release does not know is shown as it is. */
|
|
71
|
+
export function stepLabel(step: string): string {
|
|
72
|
+
const at = step.indexOf(":"), name = at < 0 ? step : step.slice(0, at);
|
|
73
|
+
return STEPS[name] ? `${at < 0 ? "" : `${step.slice(at + 1)}: `}${STEPS[name]}` : step;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
|
|
77
|
+
if (peer.state === "offline") return "offline, left as it is";
|
|
78
|
+
if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
|
|
79
|
+
if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
|
|
80
|
+
const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
|
|
81
|
+
if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
|
|
82
|
+
return peer.id === "pi" ? "restarts headless on its recorded session" : "restarts headless as a new session";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const KINDS = { tui: "TUI", headless: "headless", unmanaged: "own", offline: "-" } as const;
|
|
86
|
+
|
|
87
|
+
/** What the source says keeps it from being quiet; an older hub names only its busy peers. */
|
|
88
|
+
const inProgress = (source: Inspection): string[] => source.recovery?.waiting ?? source.peers.filter((p) => p.state === "busy").map((p) => `${p.id} is busy`);
|
|
89
|
+
|
|
90
|
+
export function planLines(plan: UpgradePlan, current: string): string[] {
|
|
91
|
+
const lines = [plan.kind === "upgrade" ? `upgrade to ${plan.version} (coordinator ${current})` : `restart on ${plan.version}`];
|
|
92
|
+
for (const p of plan.projects) {
|
|
93
|
+
lines.push("", `${p.project.id} ${p.project.root} hub ${p.source.version ?? "unknown"} (${p.source.state})`);
|
|
94
|
+
const width = Math.max(0, ...p.source.peers.map((peer) => peer.id.length));
|
|
95
|
+
for (const peer of p.source.peers) lines.push(` ${peer.id.padEnd(width)} ${peer.state.padEnd(7)} ${KINDS[peerKind(p, peer)].padEnd(8)} ${peerAction(p, peer)}`);
|
|
96
|
+
const waiting = inProgress(p.source);
|
|
97
|
+
if (waiting.length) lines.push(` in progress: ${waiting.join(", ")} (apply waits up to 10 minutes for it, then leaves this hub running)`);
|
|
98
|
+
for (const blocker of p.blockers) lines.push(` blocker: ${blocker}`);
|
|
99
|
+
}
|
|
100
|
+
if (plan.blockers.length) lines.push("");
|
|
101
|
+
for (const blocker of plan.blockers) lines.push(`blocker: ${blocker}`);
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ago = (ms: number): string => ms < 90_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 5_400_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`;
|
|
106
|
+
|
|
107
|
+
export function operationLines(op: RecoveryOperation, runner: number | "unknown" | undefined, now: number): string[] {
|
|
108
|
+
const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
|
|
109
|
+
const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
|
|
110
|
+
for (const p of op.projects) {
|
|
111
|
+
const effects = Object.entries(p.terminals).map(([key, value]) => value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
112
|
+
lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
|
|
113
|
+
}
|
|
114
|
+
// The error ends with the commands status would print; the menu below offers them instead.
|
|
115
|
+
if (op.error) lines.push(` error: ${op.error.split("; next actions: ")[0]}`);
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Print steps and readiness waits until the operation completes, blocks or loses its runner ("open"), the person
|
|
121
|
+
* enters a line (also "open": the menu), or presses Ctrl+C ("left"). `scheduled` is the receipt's `updatedAt` from
|
|
122
|
+
* before a runner was scheduled (resume, a fresh session): until that runner writes, the receipt still shows the state
|
|
123
|
+
* it was scheduled from, which is not the outcome.
|
|
124
|
+
*/
|
|
125
|
+
export async function follow(host: UpgradeHost, io: ScreenIO, id: string, scheduled?: number): Promise<"completed" | "open" | "left"> {
|
|
126
|
+
let step = "", waiting = "", unowned = 0, tick = 0;
|
|
127
|
+
for (;; tick++) {
|
|
128
|
+
const op = host.read(id);
|
|
129
|
+
if (io.interrupted()) { io.out(left(op)); return "left"; }
|
|
130
|
+
if (io.typed()) return "open";
|
|
131
|
+
const unwritten = scheduled !== undefined && op.updatedAt === scheduled;
|
|
132
|
+
if (!unwritten) {
|
|
133
|
+
scheduled = undefined;
|
|
134
|
+
if (op.step !== step) { step = op.step; waiting = ""; tick = 0; io.out(` ${stepLabel(step)}`); }
|
|
135
|
+
if (op.phase === "completed") return "completed";
|
|
136
|
+
if (op.phase === "cancelled" || op.phase === "blocked") return "open";
|
|
137
|
+
}
|
|
138
|
+
// A runner claims and writes a moment after it is spawned: only 5 s without either reads as gone.
|
|
139
|
+
unowned = host.runner(id) === undefined || unwritten ? unowned + 1 : 0;
|
|
140
|
+
if (unowned > 20) return "open";
|
|
141
|
+
if (!unwritten && /^(re)?prepare:/.test(step) && tick % 8 === 0) {
|
|
142
|
+
const live = await host.live(op).catch(() => ({} as Record<string, Inspection | undefined>));
|
|
143
|
+
// A hub that is not ready and names no cause (one older than #272 lists only busy peers and approvals) still waits.
|
|
144
|
+
const held = Object.values(live).some((i) => i?.recovery?.operationId === id && i.recovery.ready === false);
|
|
145
|
+
const now = [...new Set(Object.values(live).flatMap((i) => i?.recovery?.waiting ?? []))].join(", ") || (held ? "the hub is not quiet yet (it names no cause: a completion check, a task command or a Pi call may be in flight)" : "");
|
|
146
|
+
if (now && now !== waiting) io.out(` waiting for: ${now}`);
|
|
147
|
+
waiting = now;
|
|
148
|
+
}
|
|
149
|
+
await io.sleep(250);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* End attached agents before the upgrade, by kind or by name: an ended agent is offline in the next plan, so it is
|
|
155
|
+
* neither waited for nor restored. True when at least one was asked to end.
|
|
156
|
+
*/
|
|
157
|
+
export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { planned: PlannedProject; peer: RecoveryPeer }[]): Promise<boolean> {
|
|
158
|
+
type Agent = (typeof endable)[number];
|
|
159
|
+
// An upgrade plans every running project and each has its own claude or codex: with several, a name carries its project.
|
|
160
|
+
const several = new Set(endable.map((e) => e.planned.project.id)).size > 1;
|
|
161
|
+
const full = (e: Agent) => `${e.planned.project.id}/${e.peer.id}`, label = (e: Agent) => several ? full(e) : e.peer.id;
|
|
162
|
+
const of = (kind: string) => endable.filter((e) => peerKind(e.planned, e.peer) === kind);
|
|
163
|
+
const tui = of("tui"), headless = of("headless");
|
|
164
|
+
const choices = [...(tui.length ? [`[t] the TUI agents (${tui.map(label).join(", ")})`] : []), ...(headless.length ? [`[h] the headless agents (${headless.map(label).join(", ")})`] : []), "or names separated by spaces", "[Enter] none"];
|
|
165
|
+
const words = new Set(((await io.ask(`End which agents? ${choices.join(" ")}: `)) ?? "").toLowerCase().split(/\s+/).filter(Boolean));
|
|
166
|
+
const picked = new Set<Agent>(), unknown: string[] = [], ambiguous: string[] = [];
|
|
167
|
+
for (const word of words) {
|
|
168
|
+
const named = word === "t" ? tui : word === "h" ? headless : endable.filter((e) => word === full(e) || word === e.peer.id);
|
|
169
|
+
if (word === "t" || word === "h" || named.length === 1) for (const e of named) picked.add(e);
|
|
170
|
+
else (named.length ? ambiguous : unknown).push(word);
|
|
171
|
+
}
|
|
172
|
+
if (unknown.length) io.out(`not an agent that can be ended here: ${unknown.join(", ")}`);
|
|
173
|
+
if (ambiguous.length) io.out(`${ambiguous.join(", ")}: attached in several projects; name the one to end with its project (${endable.filter((e) => ambiguous.includes(e.peer.id)).map(full).join(", ")})`);
|
|
174
|
+
const chosen = endable.filter((e) => picked.has(e));
|
|
175
|
+
if (!chosen.length) { io.out("no agent was ended"); return false; }
|
|
176
|
+
if (!(await yes(io, `End ${chosen.map(label).join(", ")} now? A TUI agent's terminal is closed, a turn in progress is cut, and none of them is restored by the upgrade. [y/N] `))) { io.out("no agent was ended"); return false; }
|
|
177
|
+
let asked = 0;
|
|
178
|
+
for (const e of chosen) {
|
|
179
|
+
if (io.interrupted()) { io.out(`interrupted: ${chosen.length - asked} of ${chosen.length} left alone (${chosen.slice(asked).map(label).join(", ")})`); break; }
|
|
180
|
+
io.out(` ${await host.endPeer(e.planned, e.peer)}`);
|
|
181
|
+
asked++;
|
|
182
|
+
}
|
|
183
|
+
return asked > 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
|
|
187
|
+
export async function resetFlow(host: UpgradeHost, io: ScreenIO, projects: { id: string; root: string }[]): Promise<void> {
|
|
188
|
+
if (host.lock()) return io.out("an operation holds the recovery lock; cancel or end it before a reset");
|
|
189
|
+
let project = projects[0];
|
|
190
|
+
if (projects.length > 1) {
|
|
191
|
+
projects.forEach((p, i) => io.out(` ${i + 1} ${p.id} ${p.root}`));
|
|
192
|
+
project = projects[Number(await io.ask("Reset which project? [number, Enter: none] ")) - 1];
|
|
193
|
+
}
|
|
194
|
+
if (!project) return io.out("nothing was reset");
|
|
195
|
+
const scope = ((await io.ask("[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
|
|
196
|
+
if (scope !== "r" && scope !== "a") return io.out("nothing was reset");
|
|
197
|
+
const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
|
|
198
|
+
if ((await io.run(reset)) !== 0) return; // the dry run: it lists and changes nothing
|
|
199
|
+
if (!(await yes(io, `Apply this reset of ${project.id}? [y/N] `))) return io.out("nothing was reset");
|
|
200
|
+
await io.run([...reset, "--yes"]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** One open operation: what it did, and the choices `status` would name, run on the spot. */
|
|
204
|
+
export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: string): Promise<"completed" | "ended" | "quit"> {
|
|
205
|
+
for (let acted = false, reset = false; ;) {
|
|
206
|
+
if (io.interrupted()) return "quit";
|
|
207
|
+
const op = host.read(id), runner = host.runner(id);
|
|
208
|
+
if (op.phase === "completed") { io.out(`${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
|
|
209
|
+
if (op.phase === "cancelled") {
|
|
210
|
+
io.out(`operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
|
|
211
|
+
// Ended from this screen: the reset the person may have come for is one key away.
|
|
212
|
+
if (acted && !reset && await yes(io, "Reset a project's hub now? [y/N] ")) await resetFlow(host, io, op.plan.projects.map((p) => p.project));
|
|
213
|
+
return "ended";
|
|
214
|
+
}
|
|
215
|
+
const live = runner ? {} : await host.live(op);
|
|
216
|
+
io.out("");
|
|
217
|
+
for (const line of operationLines(op, runner, Date.now())) io.out(line);
|
|
218
|
+
const run = async (action: "resume" | "abort" | "dispose", flags: string[] = []) => { acted = true; return (await io.run(recoveryArgv(op, action, flags))) === 0; };
|
|
219
|
+
// A runner scheduled by a command has not written yet: follow from the receipt as that command left it (resume
|
|
220
|
+
// writes nothing, a fresh-session choice records itself first).
|
|
221
|
+
const followed = async (): Promise<boolean> => (await follow(host, io, id, host.read(id).updatedAt)) === "left";
|
|
222
|
+
const end = async (): Promise<boolean> => {
|
|
223
|
+
if (!(await yes(io, "End this operation? Its own targets are stopped and the upgrade is abandoned, not completed. [y/N] "))) return false;
|
|
224
|
+
const reason = await io.ask("Reason for the audit [Enter: ended from the upgrade screen]: ");
|
|
225
|
+
if (reason === null) return false; // Ctrl+C or the end of input is never a reason
|
|
226
|
+
return run("dispose", ["--stop-and-archive", "--reason", reason.slice(0, 500) || "ended from the upgrade screen"]);
|
|
227
|
+
};
|
|
228
|
+
// An act returns true when the person left (Ctrl+C in a follow).
|
|
229
|
+
const menu = new Map<string, { label: string; act: () => Promise<boolean | void> }>();
|
|
230
|
+
if (typeof runner === "number") {
|
|
231
|
+
menu.set("w", { label: "follow its progress", act: async () => (await follow(host, io, id)) === "left" });
|
|
232
|
+
if (cancellableWait(op) && host.runnerStoppable(id)) menu.set("c", { label: "cancel: stop the waiting runner and cancel (nothing was closed or stopped)", act: async () => {
|
|
233
|
+
// Decided again on the receipt as it is now: the runner may have moved on while the menu was read.
|
|
234
|
+
if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return io.out("the runner moved on; nothing was stopped or cancelled");
|
|
235
|
+
if (await host.stopRunner(id)) await run("abort");
|
|
236
|
+
else io.out("the runner could not be stopped; nothing was cancelled");
|
|
237
|
+
} });
|
|
238
|
+
else if (cancellableWait(op)) io.out(" cancel is not offered: this runner's claim cannot be verified (an older coordinator's, or unreadable); a live runner gives up its wait after 10 minutes");
|
|
239
|
+
}
|
|
240
|
+
for (const choice of nextChoices(op, runner, live)) {
|
|
241
|
+
if (choice.kind === "wait") { if (typeof runner !== "number") io.out(` ${choice.text}`); }
|
|
242
|
+
else if (choice.kind === "resume") menu.set("r", { label: `resume${op.error ? " (after the step the error names)" : ""}`, act: async () => (await run("resume")) && followed() });
|
|
243
|
+
else if (choice.kind === "abort") menu.set("c", { label: "cancel (no runtime was stopped; nothing to roll back)", act: async () => { await run("abort"); } });
|
|
244
|
+
else if (choice.kind === "fresh") menu.set(menu.has("f") ? `f-${choice.peer}` : "f", { label: `start ${choice.peer} as a new session (its conversation is recorded as lost)`, act: async () => {
|
|
245
|
+
const reason = await io.ask(`Reason for losing ${choice.peer}'s conversation [Enter: go back]: `);
|
|
246
|
+
return !!reason && (await run("dispose", ["--fresh-session", choice.peer!, "--reason", reason.slice(0, 500)])) && followed();
|
|
247
|
+
} });
|
|
248
|
+
else {
|
|
249
|
+
menu.set("e", { label: "end: stop and archive (the upgrade is abandoned, not completed)", act: async () => { await end(); } });
|
|
250
|
+
menu.set("x", { label: "end, then reset a project's hub", act: async () => { if (await end()) { reset = true; await resetFlow(host, io, op.plan.projects.map((p) => p.project)); } } });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
menu.set("s", { label: "refresh", act: async () => {} });
|
|
254
|
+
menu.set("j", { label: "receipt as JSON", act: async () => io.out(JSON.stringify(publicOperation(op, runner, live), null, 2)) });
|
|
255
|
+
for (const [key, item] of menu) io.out(` [${key}] ${item.label}`);
|
|
256
|
+
io.out(" [q] quit (the operation stays as it is)");
|
|
257
|
+
const answer = await io.ask("> ");
|
|
258
|
+
if (answer === null || answer.toLowerCase() === "q") return "quit";
|
|
259
|
+
if ((await menu.get(answer.toLowerCase())?.act()) === true) return "quit";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The plan, reviewed with the person: apply and follow, or an open operation's screen first. */
|
|
264
|
+
export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void> {
|
|
265
|
+
for (let touched = false; ;) {
|
|
266
|
+
if (io.interrupted()) return;
|
|
267
|
+
const owner = host.lock();
|
|
268
|
+
if (owner) {
|
|
269
|
+
if ((await operationScreen(host, io, owner)) !== "ended") return;
|
|
270
|
+
if (!(await yes(io, "Plan again? [y/N] "))) return;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const plan = await host.plan();
|
|
274
|
+
if (io.interrupted()) return;
|
|
275
|
+
io.out("");
|
|
276
|
+
for (const line of planLines(plan, host.version)) io.out(line);
|
|
277
|
+
const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
|
|
278
|
+
if (!plan.projects.length) io.out(plan.kind === "upgrade" ? `no hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
|
|
279
|
+
const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
|
|
280
|
+
const keys = [...(blocked ? [] : ["[a] apply"]), "[r] refresh", ...(endable.length ? ["[k] end agents"] : []), "[j] plan as JSON", ...(plan.projects.length ? ["[x] reset a project's hub"] : []), "[q] quit"];
|
|
281
|
+
const answer = (await io.ask(`${blocked ? "Blocked: take the next action each blocker names, then refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase() ?? "q";
|
|
282
|
+
if (answer === "q") return touched ? undefined : io.out("nothing was changed");
|
|
283
|
+
if (answer === "j") io.out(JSON.stringify(plan, null, 2));
|
|
284
|
+
else if (answer === "x" && plan.projects.length) { touched = true; await resetFlow(host, io, plan.projects.map((p) => p.project)); }
|
|
285
|
+
else if (answer === "k" && endable.length) touched = (await endAgents(host, io, endable)) || touched;
|
|
286
|
+
else if (answer === "a" && !blocked) {
|
|
287
|
+
touched = true;
|
|
288
|
+
let op: RecoveryOperation;
|
|
289
|
+
try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { io.out(`ahub: ${(error as Error).message}`); continue; }
|
|
290
|
+
io.out(`operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
|
|
291
|
+
const end = await follow(host, io, op.id);
|
|
292
|
+
if (end === "completed") return io.out(`${plan.kind} to ${plan.version} completed`);
|
|
293
|
+
if (end === "left") return;
|
|
294
|
+
// Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|