@staix/agent-hub 0.12.31 → 0.12.32

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +10 -7
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +114 -22
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +12 -8
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +191 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verified.json +20 -15
  20. package/package.json +2 -2
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +1296 -26
  23. package/src/adapters/acp.ts +5 -6
  24. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  25. package/src/adapters/definitions/kimi-native.ts +4 -0
  26. package/src/adapters/definitions/qwen-native.ts +24 -0
  27. package/src/adapters/definitions/qwen.ts +71 -0
  28. package/src/adapters/pi.ts +6 -2
  29. package/src/adapters/registry-types.ts +174 -0
  30. package/src/adapters/registry.ts +36 -0
  31. package/src/cli/console-state.ts +74 -5
  32. package/src/cli/console.ts +35 -2
  33. package/src/cli/help.ts +3 -1
  34. package/src/cli/identity.ts +13 -3
  35. package/src/cli/launch.ts +1 -6
  36. package/src/cli/main.ts +200 -39
  37. package/src/cli/output.ts +2 -0
  38. package/src/cli/preview.ts +8 -5
  39. package/src/cli/recovery-guidance.ts +137 -0
  40. package/src/cli/recovery-presentation-options.ts +8 -0
  41. package/src/cli/recovery-session-file.ts +18 -0
  42. package/src/cli/terminal-recovery.ts +48 -0
  43. package/src/cli/upgrade-interactive.ts +129 -75
  44. package/src/cli/upgrade-labels.ts +30 -0
  45. package/src/cli/upgrade-presentation-data.ts +41 -0
  46. package/src/cli/upgrade-presentation-flags.ts +31 -0
  47. package/src/cli/upgrade-presentation.ts +217 -0
  48. package/src/cli/upgrade-restore-state.ts +6 -2
  49. package/src/cli/upgrade-runtime.ts +345 -194
  50. package/src/cli/upgrade.ts +276 -25
  51. package/src/hub/bus.ts +11 -0
  52. package/src/hub/cohorts.ts +2 -2
  53. package/src/hub/config-apply.ts +242 -0
  54. package/src/hub/config-reload.ts +120 -0
  55. package/src/hub/config-trust.ts +11 -2
  56. package/src/hub/crash.ts +8 -6
  57. package/src/hub/daemon.ts +559 -170
  58. package/src/hub/events.ts +2 -2
  59. package/src/hub/manager.ts +1 -1
  60. package/src/hub/peer-catalog.ts +10 -13
  61. package/src/hub/peers.ts +12 -2
  62. package/src/hub/permission-mode.ts +5 -3
  63. package/src/hub/recovery-continuity.ts +152 -0
  64. package/src/hub/recovery-store.ts +10 -4
  65. package/src/hub/sdlc.ts +5 -1
  66. package/src/hub/settings.ts +50 -36
  67. package/src/hub/settled-roles.ts +35 -0
  68. package/src/hub/supervision.ts +3 -2
  69. package/src/hub/tasks.ts +28 -8
  70. package/src/hub/ui.ts +1 -1
  71. package/src/memory/brief.ts +6 -5
  72. package/src/pi/session.ts +23 -4
  73. package/src/ui/index.html +23 -7
@@ -0,0 +1,137 @@
1
+ import { recordedFileState } from "./recovery-session-file.ts";
2
+ import { decodeRestoreReceipt } from "./upgrade-restore-state.ts";
3
+ import { shellQuote } from "./terminal-recovery.ts";
4
+ import type { TerminalBinding } from "./terminal-recovery.ts";
5
+ import type { Inspection, PlannedProject, ProjectProgress, RecoveryOperation, RecoveryPeer } from "./upgrade.ts";
6
+
7
+ export type RecoveryNativePeer = "codex" | "pi" | "claude";
8
+ export type RecordedSessionOverride = Pick<TerminalBinding, "sessionId" | "sessionFile">;
9
+ export interface RecordedPeerGuidance {
10
+ peer: RecoveryNativePeer;
11
+ sessionId?: string;
12
+ sessionFile?: string;
13
+ command?: string;
14
+ reattachCommand?: string;
15
+ note: string;
16
+ }
17
+ export interface RecoveryGuidanceOptions {
18
+ /** Person-only blocked guidance; no lifecycle action is taken by this projection. */
19
+ stopAndArchiveCommand?: string;
20
+ /** Caller-validated retained repair bindings, including accepted sessions no longer in a done/waiting receipt. */
21
+ recorded?: Partial<Record<RecoveryNativePeer, RecordedSessionOverride>>;
22
+ /** The caller's fixed formatter for this operation's own coordinator, never model-supplied code. */
23
+ reattachCommand?: (peer: RecoveryNativePeer) => string | undefined;
24
+ }
25
+
26
+ const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
27
+ const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
28
+ const peers: readonly RecoveryNativePeer[] = ["codex", "pi", "claude"];
29
+
30
+ function sourceSession(peer: RecoveryNativePeer, source: RecoveryPeer | undefined): { sessionId?: string; sessionFile?: string } {
31
+ const sessionId = peer === "codex" ? source?.threadId : source?.sessionId;
32
+ const sessionFile = peer === "pi" ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
33
+ return { ...(text(sessionId) ? { sessionId } : {}), ...(text(sessionFile) ? { sessionFile } : {}) };
34
+ }
35
+
36
+ /** Fixed person commands for the existing Pi archive exit; this helper performs no lifecycle action. */
37
+ export function piArchiveExit(planned: PlannedProject, stopAndArchiveCommand: string): string {
38
+ return `Run ${stopAndArchiveCommand}; after stop-and-archive, run ${argv("ahub", "--project", planned.project.root, "pi")}. A proven missing archived bookmark is retired by the normal Pi start without a fresh fallback in that same call; repeat the normal start as its notice directs.`;
39
+ }
40
+
41
+ /** Accepted identity outlives an incarnation's repair fence. Malformed evidence never means absence. */
42
+ export function resolveAcceptedBinding(planned: PlannedProject, progress: ProjectProgress, peer: RecoveryNativePeer): TerminalBinding | undefined {
43
+ if (progress.id !== planned.project.id) throw new Error("recorded binding project mismatch");
44
+ const binding = (value: unknown): TerminalBinding => {
45
+ const receipt = decodeRestoreReceipt({ state: "done", binding: value });
46
+ if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== planned.project.root) throw new Error("recorded binding does not match its peer and project");
47
+ return receipt.binding;
48
+ };
49
+ const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
50
+ if (restored?.state === "done" || restored?.state === "waiting") return binding(restored.binding);
51
+ if (progress.reattachments !== undefined) {
52
+ if (!progress.reattachments || typeof progress.reattachments !== "object" || Array.isArray(progress.reattachments)) throw new Error("invalid recorded reattachment markers");
53
+ if (Object.hasOwn(progress.reattachments, peer)) {
54
+ const marker = progress.reattachments[peer];
55
+ if (!marker || typeof marker !== "object" || Array.isArray(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
56
+ !["ready", "attempted"].includes(marker.state) || marker.cause !== "detached after restore" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid recorded reattachment marker");
57
+ return binding(marker.binding);
58
+ }
59
+ }
60
+ const retiredKey = `retired:${peer}`;
61
+ if (Object.hasOwn(progress.terminals, retiredKey)) return binding(progress.terminals[retiredKey]);
62
+ const original = planned.terminals.filter(value => !!value && typeof value === "object" && (value as { peer?: unknown }).peer === peer);
63
+ if (original.length > 1) throw new Error("ambiguous planned recorded bindings");
64
+ return original.length ? binding(original[0]) : undefined;
65
+ }
66
+
67
+ /**
68
+ * Person-only projection of recorded identities, never a peer/tool result or evidence of live attachment.
69
+ * A supplied override must already be validated by the recovery coordinator. It deliberately wins over both
70
+ * current receipts and the original plan. A malformed current receipt otherwise stays unavailable.
71
+ */
72
+ export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectProgress, options: RecoveryGuidanceOptions = {}): RecordedPeerGuidance[] {
73
+ return peers.map(peer => {
74
+ let session: { sessionId?: string; sessionFile?: string } | undefined;
75
+ let unavailable = false;
76
+ const override = options.recorded && Object.hasOwn(options.recorded, peer) ? options.recorded[peer] : undefined;
77
+ if (override !== undefined) session = override;
78
+ else {
79
+ try {
80
+ session = resolveAcceptedBinding(planned, progress, peer)
81
+ ?? sourceSession(peer, planned.source.peers.find(row => row.id === peer));
82
+ } catch { unavailable = true; }
83
+ }
84
+ const sessionId = text(session?.sessionId) ? session.sessionId : undefined;
85
+ const sessionFile = peer === "pi" && text(session?.sessionFile) ? session.sessionFile : undefined;
86
+ if (unavailable || (!sessionId && !sessionFile)) return { peer, note: `${peer}: recorded session identity unavailable; inspect the retained recovery receipt before choosing a conversation` };
87
+ const prefix = ["ahub", "--project", planned.project.root];
88
+ const command = peer === "codex"
89
+ ? sessionId ? argv(...prefix, "codex", "resume", sessionId) : undefined
90
+ : peer === "pi"
91
+ ? sessionFile ? argv(...prefix, "pi", "--session-file", sessionFile) : sessionId ? argv(...prefix, "pi", "--session-id", sessionId) : undefined
92
+ : sessionId ? argv(...prefix, "claude", "--resume", sessionId) : undefined;
93
+ let note = peer === "claude"
94
+ ? "The existing Claude channel may reconnect while its native session remains open. Transcript availability is unknown; the recorded resume command is guidance, not proof that the transcript can be resumed."
95
+ : peer === "codex" ? "Recorded Codex thread; leave the old native client before resuming this conversation."
96
+ : "Recorded Pi conversation; preserve this session when starting Pi again.";
97
+ if (sessionFile && options.stopAndArchiveCommand && recordedFileState(sessionFile) === "missing") {
98
+ note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand)}`;
99
+ }
100
+ const reattachCommand = command ? options.reattachCommand?.(peer) : undefined;
101
+ return { peer, ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}), ...(command ? { command } : {}), ...(reattachCommand ? { reattachCommand } : {}), note };
102
+ });
103
+ }
104
+
105
+ /** Historical receipt facts only. A stopped/archived target is never described as still running. */
106
+ export function stopAndArchiveSummary(op: RecoveryOperation, live: Readonly<Record<string, Inspection | undefined>> = {}): string[] {
107
+ const lines = [op.disposition
108
+ ? `${op.plan.kind} abandoned, not completed; committed transitions were not rolled back.`
109
+ : "Stop-and-archive is not recorded; no abandonment outcome is claimed."];
110
+ for (const progress of op.projects) {
111
+ if (["started", "peers-restored", "verified"].includes(progress.phase) || progress.restarts?.length) lines.push(`${progress.id}: target ${op.plan.version} had started; this is a recorded historical step.`);
112
+ const outcome = op.disposition?.projects[progress.id];
113
+ if (outcome) lines.push(`${progress.id}: ${outcome}`);
114
+ const continuity = op.disposition?.continuity?.[progress.id];
115
+ if (continuity === "not-archived") lines.push(`${progress.id}: no Pi continuity bookmark because the snapshot was not archived by this operation; use the manual recorded-session commands below.`);
116
+ else if (continuity === "unavailable") lines.push(`${progress.id}: Pi continuity bookmark unavailable; automatic session continuity is unconfirmed, so use the manual recorded-session commands below.`);
117
+ const observed = live[progress.id];
118
+ if (observed) lines.push(`${progress.id}: supplied inspection reported ${observed.state}${observed.version ? ` on ${observed.version}` : ""}; it does not change the recorded disposition.`);
119
+ const planned = op.plan.projects.find(project => project.project.id === progress.id);
120
+ if (planned) for (const row of recordedPeerGuidance(planned, progress)) {
121
+ if (row.command) lines.push(`${progress.id} ${row.peer}: ${row.command}`);
122
+ lines.push(`${progress.id} ${row.peer}: ${row.note}`);
123
+ }
124
+ }
125
+ if (op.freshOverride) {
126
+ const { peer, evidence, checked, at } = op.freshOverride;
127
+ lines.push(`Explicit fresh override: ${peer} in ${evidence.projectId}; accepted session ${shellQuote(evidence.sessionId)}, ${evidence.kind}${evidence.checkedPath ? ` at ${shellQuote(evidence.checkedPath)}` : ""} checked ${checked} at ${at}; missing-history replacement was person-authorized.`);
128
+ }
129
+ lines.push(op.pluginInstalled ? "Claude plugin installation recorded." : "Claude plugin installation not recorded.");
130
+ if (op.globalInstalled) lines.push("global CLI installation recorded.");
131
+ else if (op.plan.kind === "upgrade") {
132
+ lines.push("global CLI installation remains; it was not recorded as completed.");
133
+ const exact = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(op.plan.version);
134
+ lines.push(exact ? argv("bun", "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts") : "Exact target version unavailable; review the retained receipt before installing the global CLI.");
135
+ } else lines.push("global CLI installation not recorded; a restart does not install a new CLI.");
136
+ return lines;
137
+ }
@@ -0,0 +1,8 @@
1
+ /** Pure shared recognition; the console still validates colour values and precedence. */
2
+ export function recoveryPresentationFlag(arg: string): { kind: "json" | "verbose" } | { kind: "color"; value: string } | undefined {
3
+ if (arg === "--json") return { kind: "json" };
4
+ if (arg === "--verbose") return { kind: "verbose" };
5
+ const prefix = "--color=";
6
+ if (arg.startsWith(prefix)) return { kind: "color", value: arg.slice(prefix.length) };
7
+ return undefined;
8
+ }
@@ -0,0 +1,18 @@
1
+ import { statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { TerminalBinding } from "./terminal-recovery.ts";
5
+
6
+ /** The exact transcript file, or the exact sessions directory recursively searched for a rollout. */
7
+ export function checkedSessionPath(binding: TerminalBinding, kind: "rollout" | "transcript"): string {
8
+ if (kind === "rollout") return join(binding.launch.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
9
+ const config = binding.launch.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
10
+ const slug = binding.projectRoot.replace(/[^a-zA-Z0-9]/g, "-");
11
+ return join(config, "projects", slug, `${binding.sessionId}.jsonl`);
12
+ }
13
+
14
+ /** Only an absent path is positive missing evidence. Unreadable or non-file evidence stays unknown. */
15
+ export function recordedFileState(file: string): "found" | "missing" | "unknown" {
16
+ try { return statSync(file).isFile() ? "found" : "unknown"; }
17
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; }
18
+ }
@@ -76,6 +76,12 @@ export interface RecoveryBlocker {
76
76
  nextAction?: string;
77
77
  }
78
78
 
79
+ /** Read-only evidence for one already recorded terminal, never a session-selection result. */
80
+ export interface SavedTerminalInspection {
81
+ state: "present" | "orphaned" | "missing" | "changed" | "unknown";
82
+ blocker?: RecoveryBlocker;
83
+ }
84
+
79
85
  export interface TerminalInspection {
80
86
  bindings: TerminalBinding[];
81
87
  /** A map is convenient for callers recovering both peers at once. */
@@ -539,6 +545,48 @@ async function showBinding(binding: TerminalBinding, options: Required<TerminalR
539
545
  return actual;
540
546
  }
541
547
 
548
+ /**
549
+ * Judge only the saved terminal identity. The caller separately fences the peer's current/restarted launchers.
550
+ * A failed read is unknown; only a typed stale handle plus a complete inventory can prove absence.
551
+ */
552
+ export async function inspectSavedTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions): Promise<SavedTerminalInspection> {
553
+ const config = normalizeOptions(options);
554
+ const unknown = (): SavedTerminalInspection => ({ state: "unknown", blocker: blocker("command-error", "saved terminal evidence is unavailable or inconsistent", binding.peer, binding.handle) });
555
+ const completeIdentity = (value: Record<string, unknown>) =>
556
+ [nestedString(value, ["handle"]), nestedString(value, ["incarnationId"]), nestedString(value, ["worktreeId"]), nestedString(value, ["worktreePath", "projectRoot"])].every(Boolean);
557
+ try {
558
+ const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]));
559
+ if (!completeIdentity(shown)) return unknown();
560
+ const issue = verifyBindingData(shown, binding);
561
+ if (issue) return { state: "changed", blocker: issue };
562
+ if (shown.orphaned === true) return { state: "orphaned", blocker: blocker("terminal-gone", "the saved terminal is orphaned", binding.peer, binding.handle) };
563
+ if (shown.connected === false) return unknown();
564
+ return { state: "present" };
565
+ } catch (error) {
566
+ if (!(error instanceof OrcaCommandError) || error.blocker.code !== "terminal-gone") return unknown();
567
+ }
568
+ try {
569
+ const result = responseResult(await run(config.runner, ["terminal", "list", "--json"]));
570
+ const rows = result.terminals ?? result.items;
571
+ if (result.truncated === true || !Array.isArray(rows)) return unknown();
572
+ const handles = new Set<string>(), incarnations = new Set<string>();
573
+ for (const raw of rows) {
574
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return unknown();
575
+ const row = raw as Record<string, unknown>;
576
+ if (!completeIdentity(row)) return unknown();
577
+ const handle = nestedString(row, ["handle"])!, incarnation = nestedString(row, ["incarnationId"])!;
578
+ if (handles.has(handle) || incarnations.has(incarnation)) return unknown();
579
+ handles.add(handle); incarnations.add(incarnation);
580
+ }
581
+ const retained = (rows as Record<string, unknown>[]).find(row => row.handle === binding.handle || row.incarnationId === binding.incarnationId);
582
+ if (retained) {
583
+ const issue = verifyBindingData(retained, binding);
584
+ return issue ? { state: "changed", blocker: issue } : unknown();
585
+ }
586
+ return { state: "missing", blocker: blocker("terminal-gone", "the saved terminal handle and incarnation are absent from the complete inventory", binding.peer, binding.handle) };
587
+ } catch { return unknown(); }
588
+ }
589
+
542
590
  /**
543
591
  * Read Orca's authoritative terminal inventory and bind only exact agent/session identities.
544
592
  * Titles, previews, pty ids and process names are deliberately never used as identity.
@@ -1,4 +1,7 @@
1
- import { restoreReceiptStatus } from "./upgrade-restore-state.ts";
1
+ import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
2
+ import { quoted, wrap } from "./console-state.ts";
3
+ import { operationLines, stepLabel } from "./upgrade-labels.ts";
4
+ export { operationLines, stepLabel } from "./upgrade-labels.ts";
2
5
  import { recoveryArgv } from "../hub/recovery-store.ts";
3
6
  import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
4
7
 
@@ -11,6 +14,8 @@ import { cancellableWait, nextChoices, publicOperation, type Inspection, type Pl
11
14
 
12
15
  /** The terminal a screen talks to; tests script it. */
13
16
  export interface ScreenIO {
17
+ /** Optional terminal presentation; raw JSON always bypasses this layer. */
18
+ presentation?: RecoveryPresentationOptions;
14
19
  /** One trimmed line from the person; null when the input ended or they pressed Ctrl+C, which no prompt takes for text. */
15
20
  ask(question: string): Promise<string | null>;
16
21
  out(line: string): void;
@@ -35,6 +40,8 @@ export interface UpgradeHost {
35
40
  read(id: string): RecoveryOperation;
36
41
  runner(id: string): number | "unknown" | undefined;
37
42
  live(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
43
+ /** Read-only current state after completion/disposal; failure remains unknown. */
44
+ endInspection?(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
38
45
  /** Whether the runner's claim carries a process signature that still matches: the only kind a screen may stop. */
39
46
  runnerStoppable(id: string): boolean;
40
47
  /** Stop that verified runner and wait until it is gone; false when one still holds the operation. */
@@ -45,9 +52,28 @@ export interface UpgradeHost {
45
52
  version: string;
46
53
  }
47
54
 
48
- const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await io.ask(question)) ?? "");
55
+ function emit(io: ScreenIO, lines: readonly string[]): void { for (const line of lines) io.out(line); }
56
+ function human(io: ScreenIO, line: string): void {
57
+ if (!io.presentation) return io.out(line);
58
+ emit(io, wrap(/\p{Cc}/u.test(line) ? quoted(line) : line, io.presentation.columns));
59
+ }
60
+ function ask(io: ScreenIO, question: string): Promise<string | null> {
61
+ if (!io.presentation) return io.ask(question);
62
+ const lines = wrap(/\p{Cc}/u.test(question) ? quoted(question) : question, io.presentation.columns);
63
+ emit(io, lines.slice(0, -1));
64
+ return io.ask(lines.at(-1) ?? "> ");
65
+ }
66
+ async function summary(host: UpgradeHost, io: ScreenIO, op: RecoveryOperation): Promise<void> {
67
+ if (!io.presentation) return;
68
+ const live = await host.endInspection?.(op).catch(() => ({} as Record<string, Inspection | undefined>));
69
+ emit(io, renderSummary(op, live ?? {}, io.presentation));
70
+ }
71
+ const clock = (io: ScreenIO) => io.presentation?.now() ?? Date.now();
72
+
73
+ const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await ask(io, question)) ?? "");
49
74
  /** The way back after Ctrl+C: the operation's own coordinator, since the installed `ahub` may still be the older release (#215). */
50
- 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`;
75
+ const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string => io.presentation && !io.presentation.verbose
76
+ ? `left: the runner keeps working; ahub recovery status ${op.id} shows the operation (use --verbose for its retained coordinator path)` : `left: the runner keeps working; \`bun ${recoveryArgv(op, "status")[0]} recovery\` shows the operation and what can be done`;
51
77
 
52
78
  /**
53
79
  * How an attached peer runs, which decides how it can be ended: a TUI agent in a terminal the plan could bind, a
@@ -61,19 +87,6 @@ export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "
61
87
  return "headless";
62
88
  }
63
89
 
64
- const STEPS: Record<string, string> = {
65
- stage: "staging the release", prepare: "holding deliveries and waiting until the hub is quiet", reprepare: "preparing the source again after an expired hold",
66
- 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",
67
- "install-plugin": "installing the Claude plugin", "restore-claude": "restoring the Claude session and verifying the hub", release: "releasing held deliveries",
68
- "refresh-manager": "refreshing the dashboard manager", "install-global": "installing the global CLI", completed: "completed", cancelled: "cancelled",
69
- };
70
-
71
- /** A receipt step (`commit:<project>`) in a person's words; a step this release does not know is shown as it is. */
72
- export function stepLabel(step: string): string {
73
- const at = step.indexOf(":"), name = at < 0 ? step : step.slice(0, at);
74
- return STEPS[name] ? `${at < 0 ? "" : `${step.slice(at + 1)}: `}${STEPS[name]}` : step;
75
- }
76
-
77
90
  function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
78
91
  if (peer.state === "offline") return "offline, left as it is";
79
92
  if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
@@ -103,20 +116,6 @@ export function planLines(plan: UpgradePlan, current: string): string[] {
103
116
  return lines;
104
117
  }
105
118
 
106
- 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`;
107
-
108
- export function operationLines(op: RecoveryOperation, runner: number | "unknown" | undefined, now: number): string[] {
109
- 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)" : "";
110
- 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}`];
111
- for (const p of op.projects) {
112
- const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
113
- lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
114
- }
115
- // The error ends with the commands status would print; the menu below offers them instead.
116
- if (op.error) lines.push(` error: ${op.error.split("; next actions: ")[0]}`);
117
- return lines;
118
- }
119
-
120
119
  /**
121
120
  * Print steps and readiness waits until the operation completes, blocks or loses its runner ("open"), the person
122
121
  * enters a line (also "open": the menu), or presses Ctrl+C ("left"). `scheduled` is the receipt's `updatedAt` from
@@ -124,15 +123,27 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
124
123
  * it was scheduled from, which is not the outcome.
125
124
  */
126
125
  export async function follow(host: UpgradeHost, io: ScreenIO, id: string, scheduled?: number): Promise<"completed" | "open" | "left"> {
127
- let step = "", waiting = "", unowned = 0, tick = 0;
126
+ let step = "", waiting = "", unowned = 0, tick = 0, started = clock(io), lastWaitAt = -Infinity;
127
+ const resuming = scheduled !== undefined;
128
+ let resumeAnnounced = false;
128
129
  for (;; tick++) {
129
130
  const op = host.read(id);
130
- if (io.interrupted()) { io.out(left(op)); return "left"; }
131
+ if (io.interrupted()) { human(io, left(op, io)); return "left"; }
131
132
  if (io.typed()) return "open";
132
133
  const unwritten = scheduled !== undefined && op.updatedAt === scheduled;
133
134
  if (!unwritten) {
134
135
  scheduled = undefined;
135
- if (op.step !== step) { step = op.step; waiting = ""; tick = 0; io.out(` ${stepLabel(step)}`); }
136
+ if (op.step !== step) {
137
+ const now = clock(io);
138
+ if (io.presentation && step) emit(io, renderStep(resuming && step === "stage" ? "checking retained release" : stepLabel(step), "done", Math.max(0, now - started), io.presentation));
139
+ step = op.step; waiting = ""; tick = 0; started = now; lastWaitAt = -Infinity;
140
+ if (io.presentation) {
141
+ if (resuming && !resumeAnnounced) { human(io, `resuming from ${step === "stage" ? "checking retained release" : stepLabel(step)}`); resumeAnnounced = true; }
142
+ if (op.phase === "blocked") emit(io, renderStep(stepLabel(step), "blocked", undefined, io.presentation));
143
+ else if (op.phase === "completed") emit(io, renderStep(stepLabel(step), "done", undefined, io.presentation));
144
+ else emit(io, renderStep(resuming && step === "stage" ? "checking retained release" : stepLabel(step), "running", undefined, io.presentation));
145
+ } else human(io, ` ${stepLabel(step)}`);
146
+ }
136
147
  if (op.phase === "completed") return "completed";
137
148
  if (op.phase === "cancelled" || op.phase === "blocked") return "open";
138
149
  }
@@ -144,9 +155,20 @@ export async function follow(host: UpgradeHost, io: ScreenIO, id: string, schedu
144
155
  // A hub that is not ready and names no cause (one older than #272 lists only busy peers and approvals) still waits.
145
156
  const held = Object.values(live).some((i) => i?.recovery?.operationId === id && i.recovery.ready === false);
146
157
  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)" : "");
147
- if (now && now !== waiting) io.out(` waiting for: ${now}`);
158
+ if (now && (now !== waiting || io.presentation && clock(io) - lastWaitAt >= 60_000)) {
159
+ lastWaitAt = clock(io);
160
+ if (io.presentation) emit(io, renderWait(op, step, Object.values(live).flatMap(value => value?.recovery?.waiting ?? []).length ? Object.values(live).flatMap(value => value?.recovery?.waiting ?? []) : [now], io.presentation));
161
+ else human(io, ` waiting for: ${now}`);
162
+ }
148
163
  waiting = now;
149
164
  }
165
+ if (io.presentation && !unwritten && !/^(re)?prepare:/.test(step) && op.waits?.[step] && !op.waits[step]!.waitEndedAt && tick % 8 === 0) {
166
+ const reason = "waiting for the recorded native session to attach";
167
+ if (waiting !== reason || clock(io) - lastWaitAt >= 60_000) {
168
+ emit(io, renderWait(op, step, [reason], io.presentation));
169
+ waiting = reason; lastWaitAt = clock(io);
170
+ }
171
+ }
150
172
  await io.sleep(250);
151
173
  }
152
174
  }
@@ -163,22 +185,22 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
163
185
  const of = (kind: string) => endable.filter((e) => peerKind(e.planned, e.peer) === kind);
164
186
  const tui = of("tui"), headless = of("headless");
165
187
  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"];
166
- const words = new Set(((await io.ask(`End which agents? ${choices.join(" ")}: `)) ?? "").toLowerCase().split(/\s+/).filter(Boolean));
188
+ const words = new Set(((await ask(io, `End which agents? ${choices.join(" ")}: `)) ?? "").toLowerCase().split(/\s+/).filter(Boolean));
167
189
  const picked = new Set<Agent>(), unknown: string[] = [], ambiguous: string[] = [];
168
190
  for (const word of words) {
169
191
  const named = word === "t" ? tui : word === "h" ? headless : endable.filter((e) => word === full(e) || word === e.peer.id);
170
192
  if (word === "t" || word === "h" || named.length === 1) for (const e of named) picked.add(e);
171
193
  else (named.length ? ambiguous : unknown).push(word);
172
194
  }
173
- if (unknown.length) io.out(`not an agent that can be ended here: ${unknown.join(", ")}`);
174
- 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(", ")})`);
195
+ if (unknown.length) human(io, `not an agent that can be ended here: ${unknown.join(", ")}`);
196
+ if (ambiguous.length) human(io, `${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(", ")})`);
175
197
  const chosen = endable.filter((e) => picked.has(e));
176
- if (!chosen.length) { io.out("no agent was ended"); return false; }
177
- 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; }
198
+ if (!chosen.length) { human(io, "no agent was ended"); return false; }
199
+ 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] `))) { human(io, "no agent was ended"); return false; }
178
200
  let asked = 0;
179
201
  for (const e of chosen) {
180
- if (io.interrupted()) { io.out(`interrupted: ${chosen.length - asked} of ${chosen.length} left alone (${chosen.slice(asked).map(label).join(", ")})`); break; }
181
- io.out(` ${await host.endPeer(e.planned, e.peer)}`);
202
+ if (io.interrupted()) { human(io, `interrupted: ${chosen.length - asked} of ${chosen.length} left alone (${chosen.slice(asked).map(label).join(", ")})`); break; }
203
+ human(io, ` ${await host.endPeer(e.planned, e.peer)}`);
182
204
  asked++;
183
205
  }
184
206
  return asked > 0;
@@ -186,18 +208,19 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
186
208
 
187
209
  /** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
188
210
  export async function resetFlow(host: UpgradeHost, io: ScreenIO, projects: { id: string; root: string }[]): Promise<void> {
189
- if (host.lock()) return io.out("an operation holds the recovery lock; cancel or end it before a reset");
211
+ if (host.lock()) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
190
212
  let project = projects[0];
191
213
  if (projects.length > 1) {
192
- projects.forEach((p, i) => io.out(` ${i + 1} ${p.id} ${p.root}`));
193
- project = projects[Number(await io.ask("Reset which project? [number, Enter: none] ")) - 1];
214
+ projects.forEach((p, i) => human(io, ` ${i + 1} ${p.id} ${p.root}`));
215
+ project = projects[Number(await ask(io, "Reset which project? [number, Enter: none] ")) - 1];
194
216
  }
195
- if (!project) return io.out("nothing was reset");
196
- const scope = ((await io.ask("[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
197
- if (scope !== "r" && scope !== "a") return io.out("nothing was reset");
217
+ if (!project) return human(io, "nothing was reset");
218
+ const scope = ((await ask(io, "[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
219
+ if (scope !== "r" && scope !== "a") return human(io, "nothing was reset");
198
220
  const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
199
221
  if ((await io.run(reset)) !== 0) return; // the dry run: it lists and changes nothing
200
- if (!(await yes(io, `Apply this reset of ${project.id}? [y/N] `))) return io.out("nothing was reset");
222
+ const consequence = scope === "a" ? "archive this project's hub state" : "reset delivery holds, pauses and session pointers";
223
+ if (!(await yes(io, io.presentation ? `Reset ${project.id}: ${consequence}? [y/N] ` : `Apply this reset of ${project.id}? [y/N] `))) return human(io, "nothing was reset");
201
224
  await io.run([...reset, "--yes"]);
202
225
  }
203
226
 
@@ -206,44 +229,54 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
206
229
  for (let acted = false, reset = false; ;) {
207
230
  if (io.interrupted()) return "quit";
208
231
  const op = host.read(id), runner = host.runner(id);
209
- if (op.phase === "completed") { io.out(`${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
232
+ if (op.phase === "completed") { if (io.presentation) await summary(host, io, op); else human(io, `${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
210
233
  if (op.phase === "cancelled") {
211
- io.out(`operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
234
+ if (io.presentation) await summary(host, io, op);
235
+ else human(io, `operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
212
236
  // Ended from this screen: the reset the person may have come for is one key away.
213
237
  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));
214
238
  return "ended";
215
239
  }
216
240
  const live = runner ? {} : await host.live(op);
217
- io.out("");
218
- for (const line of operationLines(op, runner, Date.now())) io.out(line);
219
- const run = async (action: "resume" | "abort" | "dispose", flags: string[] = []) => { acted = true; return (await io.run(recoveryArgv(op, action, flags))) === 0; };
241
+ human(io, "");
242
+ if (io.presentation) emit(io, renderOperation(op, runner, io.presentation));
243
+ else for (const line of operationLines(op, runner, Date.now())) human(io, line);
244
+ const run = async (action: "resume" | "abort" | "dispose" | "reattach", flags: string[] = []) => { acted = true; return (await io.run(recoveryArgv(op, action, flags))) === 0; };
220
245
  // A runner scheduled by a command has not written yet: follow from the receipt as that command left it (resume
221
246
  // writes nothing, a fresh-session choice records itself first).
222
247
  const followed = async (): Promise<boolean> => (await follow(host, io, id, host.read(id).updatedAt)) === "left";
223
248
  const end = async (): Promise<boolean> => {
224
- if (!(await yes(io, "End this operation? Its own targets are stopped and the upgrade is abandoned, not completed. [y/N] "))) return false;
225
- const reason = await io.ask("Reason for the audit [Enter: ended from the upgrade screen]: ");
249
+ if (!(await yes(io, io.presentation ? `Stop this operation's ${op.plan.version} targets? The upgrade is abandoned, not completed; sessions remain recorded. [y/N] ` : "End this operation? Its own targets are stopped and the upgrade is abandoned, not completed. [y/N] "))) return false;
250
+ const reason = await ask(io, "Reason for the audit [Enter: ended from the upgrade screen]: ");
226
251
  if (reason === null) return false; // Ctrl+C or the end of input is never a reason
227
252
  return run("dispose", ["--stop-and-archive", "--reason", reason.slice(0, 500) || "ended from the upgrade screen"]);
228
253
  };
229
254
  // An act returns true when the person left (Ctrl+C in a follow).
230
- const menu = new Map<string, { label: string; act: () => Promise<boolean | void> }>();
255
+ const menu = new Map<string, { label: string; disabledReason?: string; act: () => Promise<boolean | void> }>();
231
256
  if (typeof runner === "number") {
232
257
  menu.set("w", { label: "follow its progress", act: async () => (await follow(host, io, id)) === "left" });
233
258
  if (cancellableWait(op) && host.runnerStoppable(id)) menu.set("c", { label: "cancel: stop the waiting runner and cancel (nothing was closed or stopped)", act: async () => {
234
259
  // Decided again on the receipt as it is now: the runner may have moved on while the menu was read.
235
- if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return io.out("the runner moved on; nothing was stopped or cancelled");
260
+ if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return human(io, "the runner moved on; nothing was stopped or cancelled");
236
261
  if (await host.stopRunner(id)) await run("abort");
237
- else io.out("the runner could not be stopped; nothing was cancelled");
262
+ else human(io, "the runner could not be stopped; nothing was cancelled");
238
263
  } });
239
- 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");
264
+ else if (cancellableWait(op)) human(io, " 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");
240
265
  }
241
- for (const choice of nextChoices(op, runner, live)) {
242
- if (choice.kind === "wait") { if (typeof runner !== "number") io.out(` ${choice.text}`); }
243
- else if (choice.kind === "resume") menu.set("r", { label: `resume${op.error ? " (after the step the error names)" : ""}`, act: async () => (await run("resume")) && followed() });
266
+ const choices = nextChoices(op, runner, live);
267
+ if (io.presentation && op.phase === "blocked") emit(io, renderBlocked(op, choices, io.presentation));
268
+ for (const choice of choices) {
269
+ if (choice.kind === "wait") { if (typeof runner !== "number") {
270
+ if (io.presentation && op.phase !== "blocked") human(io, choiceInstruction(op, choice, io.presentation));
271
+ if (io.presentation) menu.set("w", { label: "wait for the current recovery condition", disabledReason: runner === "unknown" ? "runner ownership is unknown; refresh to read its claim" : "no verified runner is working; refresh after the reported condition changes", act: async () => {} });
272
+ else human(io, ` ${choice.text}`);
273
+ } }
274
+ else if (choice.kind === "resume") menu.set("r", { label: io.presentation ? op.error ? "resume after reviewing the recorded failure" : "resume from the recorded step" : `resume${op.error ? " (after the step the error names)" : ""}`, act: async () => (await run("resume")) && followed() });
244
275
  else if (choice.kind === "abort") menu.set("c", { label: "cancel (no runtime was stopped; nothing to roll back)", act: async () => { await run("abort"); } });
276
+ else if (choice.kind === "reattach") menu.set("a", { label: `reattach ${choice.peer} with its recorded session only (then resume to finish)`, act: async () => { await run("reattach", [choice.peer!]); } });
245
277
  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 () => {
246
- const reason = await io.ask(`Reason for losing ${choice.peer}'s conversation [Enter: go back]: `);
278
+ if (io.presentation && !(await yes(io, `Start ${choice.peer} fresh? Its recorded conversation is marked lost. [y/N] `))) return;
279
+ const reason = await ask(io, `Reason for losing ${choice.peer}'s conversation [Enter: go back]: `);
247
280
  return !!reason && (await run("dispose", ["--fresh-session", choice.peer!, "--reason", reason.slice(0, 500)])) && followed();
248
281
  } });
249
282
  else {
@@ -253,11 +286,17 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
253
286
  }
254
287
  menu.set("s", { label: "refresh", act: async () => {} });
255
288
  menu.set("j", { label: "receipt as JSON", act: async () => io.out(JSON.stringify(publicOperation(op, runner, live), null, 2)) });
256
- for (const [key, item] of menu) io.out(` [${key}] ${item.label}`);
257
- io.out(" [q] quit (the operation stays as it is)");
258
- const answer = await io.ask("> ");
289
+ const preferred = [typeof runner === "number" ? "w" : runner === "unknown" ? "s" : "j", "s"].find(key => menu.has(key) && !menu.get(key)!.disabledReason);
290
+ if (io.presentation) {
291
+ const items: MenuPresentationItem[] = [...menu].map(([key, item]) => ({ key, label: item.label, ...(item.disabledReason ? { disabledReason: item.disabledReason } : {}), ...(key === preferred ? { recommended: true } : {}), ...(["e", "x"].includes(key) || key === "f" || key.startsWith("f-") ? { destructive: true } : {}) }));
292
+ emit(io, renderMenu(items, io.presentation));
293
+ } else for (const [key, item] of menu) human(io, ` [${key}] ${item.label}`);
294
+ human(io, " [q] quit (the operation stays as it is)");
295
+ const answer = await ask(io, "> ");
259
296
  if (answer === null || answer.toLowerCase() === "q") return "quit";
260
- if ((await menu.get(answer.toLowerCase())?.act()) === true) return "quit";
297
+ const selected = menu.get(io.presentation && answer === "" ? preferred ?? "s" : answer.toLowerCase());
298
+ if (selected?.disabledReason) { human(io, `disabled: ${selected.disabledReason}`); continue; }
299
+ if ((await selected?.act()) === true) return "quit";
261
300
  }
262
301
  }
263
302
 
@@ -273,24 +312,39 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
273
312
  }
274
313
  const plan = await host.plan();
275
314
  if (io.interrupted()) return;
276
- io.out("");
277
- for (const line of planLines(plan, host.version)) io.out(line);
315
+ human(io, "");
316
+ if (io.presentation) emit(io, renderPlan(plan, host.version, io.presentation, planLines(plan, host.version)));
317
+ else for (const line of planLines(plan, host.version)) human(io, line);
278
318
  const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
279
- 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");
319
+ if (!plan.projects.length) human(io, 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");
280
320
  const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
281
321
  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"];
282
- const answer = (await io.ask(`${blocked ? "Blocked: take the next action each blocker names, then refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase() ?? "q";
283
- if (answer === "q") return touched ? undefined : io.out("nothing was changed");
322
+ const preferred = "r"; // Enter never closes terminals, stops a hub, resumes or reattaches implicitly.
323
+ if (io.presentation) {
324
+ if (blocked) human(io, "Blocked: take the next action each blocker names, then refresh.");
325
+ const items: MenuPresentationItem[] = [
326
+ ...(!blocked ? [{ key: "a", label: "apply this reviewed plan" }] : []),
327
+ { key: "r", label: "refresh the plan", recommended: preferred === "r" },
328
+ ...(endable.length ? [{ key: "k", label: "end selected agents before upgrading", destructive: true }] : []),
329
+ { key: "j", label: "plan as JSON" },
330
+ ...(plan.projects.length ? [{ key: "x", label: "reset a project's hub", destructive: true }] : []),
331
+ { key: "q", label: "quit" },
332
+ ];
333
+ emit(io, renderMenu(items, io.presentation));
334
+ }
335
+ const entered = (await ask(io, io.presentation ? "> " : `${blocked ? "Blocked: take the next action each blocker names, then refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase();
336
+ const answer = entered === undefined ? "q" : io.presentation && entered === "" ? preferred : entered;
337
+ if (answer === "q") return touched ? undefined : human(io, "nothing was changed");
284
338
  if (answer === "j") io.out(JSON.stringify(plan, null, 2));
285
339
  else if (answer === "x" && plan.projects.length) { touched = true; await resetFlow(host, io, plan.projects.map((p) => p.project)); }
286
340
  else if (answer === "k" && endable.length) touched = (await endAgents(host, io, endable)) || touched;
287
341
  else if (answer === "a" && !blocked) {
288
342
  touched = true;
289
343
  let op: RecoveryOperation;
290
- try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { io.out(`ahub: ${(error as Error).message}`); continue; }
291
- io.out(`operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
344
+ try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { human(io, `ahub: ${(error as Error).message}`); continue; }
345
+ human(io, `operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
292
346
  const end = await follow(host, io, op.id);
293
- if (end === "completed") return io.out(`${plan.kind} to ${plan.version} completed`);
347
+ if (end === "completed") { if (io.presentation) await summary(host, io, host.read(op.id)); else human(io, `${plan.kind} to ${plan.version} completed`); return; }
294
348
  if (end === "left") return;
295
349
  // Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
296
350
  }
@@ -0,0 +1,30 @@
1
+ import { restoreReceiptStatus } from "./upgrade-restore-state.ts";
2
+ import type { RecoveryOperation } from "./upgrade.ts";
3
+
4
+ const STEPS: Record<string, string> = {
5
+ stage: "staging the release", prepare: "holding deliveries and waiting until the hub is quiet", reprepare: "preparing the source again after an expired hold",
6
+ 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",
7
+ "install-plugin": "installing the Claude plugin", "restore-claude": "restoring the Claude session and verifying the hub", release: "releasing held deliveries",
8
+ "refresh-manager": "refreshing the dashboard manager", "install-global": "installing the global CLI", completed: "completed", cancelled: "cancelled",
9
+ };
10
+
11
+ /** A receipt step (`commit:<project>`) in a person's words; a step this release does not know is shown as it is. */
12
+ export function stepLabel(step: string): string {
13
+ const at = step.indexOf(":"), name = at < 0 ? step : step.slice(0, at);
14
+ return STEPS[name] ? `${at < 0 ? "" : `${step.slice(at + 1)}: `}${STEPS[name]}` : step;
15
+ }
16
+
17
+ 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`;
18
+
19
+ export function operationLines(op: RecoveryOperation, runner: number | "unknown" | undefined, now: number): string[] {
20
+ 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)" : "";
21
+ 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}`];
22
+ for (const p of op.projects) {
23
+ const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
24
+ lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
25
+ }
26
+ // The error ends with the commands status would print; the menu below offers them instead.
27
+ if (op.error) lines.push(` error: ${op.error.split("; next actions: ")[0]}`);
28
+ return lines;
29
+ }
30
+