@staix/agent-hub 0.12.30 → 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 (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
@@ -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,3 +1,7 @@
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";
1
5
  import { recoveryArgv } from "../hub/recovery-store.ts";
2
6
  import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
3
7
 
@@ -10,6 +14,8 @@ import { cancellableWait, nextChoices, publicOperation, type Inspection, type Pl
10
14
 
11
15
  /** The terminal a screen talks to; tests script it. */
12
16
  export interface ScreenIO {
17
+ /** Optional terminal presentation; raw JSON always bypasses this layer. */
18
+ presentation?: RecoveryPresentationOptions;
13
19
  /** One trimmed line from the person; null when the input ended or they pressed Ctrl+C, which no prompt takes for text. */
14
20
  ask(question: string): Promise<string | null>;
15
21
  out(line: string): void;
@@ -34,6 +40,8 @@ export interface UpgradeHost {
34
40
  read(id: string): RecoveryOperation;
35
41
  runner(id: string): number | "unknown" | undefined;
36
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>>;
37
45
  /** Whether the runner's claim carries a process signature that still matches: the only kind a screen may stop. */
38
46
  runnerStoppable(id: string): boolean;
39
47
  /** Stop that verified runner and wait until it is gone; false when one still holds the operation. */
@@ -44,9 +52,28 @@ export interface UpgradeHost {
44
52
  version: string;
45
53
  }
46
54
 
47
- 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)) ?? "");
48
74
  /** 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`;
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`;
50
77
 
51
78
  /**
52
79
  * How an attached peer runs, which decides how it can be ended: a TUI agent in a terminal the plan could bind, a
@@ -60,19 +87,6 @@ export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "
60
87
  return "headless";
61
88
  }
62
89
 
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
90
  function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
77
91
  if (peer.state === "offline") return "offline, left as it is";
78
92
  if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
@@ -102,20 +116,6 @@ export function planLines(plan: UpgradePlan, current: string): string[] {
102
116
  return lines;
103
117
  }
104
118
 
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
119
  /**
120
120
  * Print steps and readiness waits until the operation completes, blocks or loses its runner ("open"), the person
121
121
  * enters a line (also "open": the menu), or presses Ctrl+C ("left"). `scheduled` is the receipt's `updatedAt` from
@@ -123,15 +123,27 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
123
123
  * it was scheduled from, which is not the outcome.
124
124
  */
125
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;
126
+ let step = "", waiting = "", unowned = 0, tick = 0, started = clock(io), lastWaitAt = -Infinity;
127
+ const resuming = scheduled !== undefined;
128
+ let resumeAnnounced = false;
127
129
  for (;; tick++) {
128
130
  const op = host.read(id);
129
- if (io.interrupted()) { io.out(left(op)); return "left"; }
131
+ if (io.interrupted()) { human(io, left(op, io)); return "left"; }
130
132
  if (io.typed()) return "open";
131
133
  const unwritten = scheduled !== undefined && op.updatedAt === scheduled;
132
134
  if (!unwritten) {
133
135
  scheduled = undefined;
134
- 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
+ }
135
147
  if (op.phase === "completed") return "completed";
136
148
  if (op.phase === "cancelled" || op.phase === "blocked") return "open";
137
149
  }
@@ -143,9 +155,20 @@ export async function follow(host: UpgradeHost, io: ScreenIO, id: string, schedu
143
155
  // A hub that is not ready and names no cause (one older than #272 lists only busy peers and approvals) still waits.
144
156
  const held = Object.values(live).some((i) => i?.recovery?.operationId === id && i.recovery.ready === false);
145
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)" : "");
146
- 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
+ }
147
163
  waiting = now;
148
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
+ }
149
172
  await io.sleep(250);
150
173
  }
151
174
  }
@@ -162,22 +185,22 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
162
185
  const of = (kind: string) => endable.filter((e) => peerKind(e.planned, e.peer) === kind);
163
186
  const tui = of("tui"), headless = of("headless");
164
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"];
165
- 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));
166
189
  const picked = new Set<Agent>(), unknown: string[] = [], ambiguous: string[] = [];
167
190
  for (const word of words) {
168
191
  const named = word === "t" ? tui : word === "h" ? headless : endable.filter((e) => word === full(e) || word === e.peer.id);
169
192
  if (word === "t" || word === "h" || named.length === 1) for (const e of named) picked.add(e);
170
193
  else (named.length ? ambiguous : unknown).push(word);
171
194
  }
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(", ")})`);
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(", ")})`);
174
197
  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; }
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; }
177
200
  let asked = 0;
178
201
  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)}`);
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)}`);
181
204
  asked++;
182
205
  }
183
206
  return asked > 0;
@@ -185,18 +208,19 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
185
208
 
186
209
  /** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
187
210
  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");
211
+ if (host.lock()) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
189
212
  let project = projects[0];
190
213
  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];
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];
193
216
  }
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");
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");
197
220
  const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
198
221
  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");
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");
200
224
  await io.run([...reset, "--yes"]);
201
225
  }
202
226
 
@@ -205,44 +229,54 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
205
229
  for (let acted = false, reset = false; ;) {
206
230
  if (io.interrupted()) return "quit";
207
231
  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"; }
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"; }
209
233
  if (op.phase === "cancelled") {
210
- 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`);
211
236
  // Ended from this screen: the reset the person may have come for is one key away.
212
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));
213
238
  return "ended";
214
239
  }
215
240
  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; };
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; };
219
245
  // A runner scheduled by a command has not written yet: follow from the receipt as that command left it (resume
220
246
  // writes nothing, a fresh-session choice records itself first).
221
247
  const followed = async (): Promise<boolean> => (await follow(host, io, id, host.read(id).updatedAt)) === "left";
222
248
  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]: ");
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]: ");
225
251
  if (reason === null) return false; // Ctrl+C or the end of input is never a reason
226
252
  return run("dispose", ["--stop-and-archive", "--reason", reason.slice(0, 500) || "ended from the upgrade screen"]);
227
253
  };
228
254
  // 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> }>();
255
+ const menu = new Map<string, { label: string; disabledReason?: string; act: () => Promise<boolean | void> }>();
230
256
  if (typeof runner === "number") {
231
257
  menu.set("w", { label: "follow its progress", act: async () => (await follow(host, io, id)) === "left" });
232
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 () => {
233
259
  // 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");
260
+ if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return human(io, "the runner moved on; nothing was stopped or cancelled");
235
261
  if (await host.stopRunner(id)) await run("abort");
236
- 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");
237
263
  } });
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");
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");
239
265
  }
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() });
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() });
243
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!]); } });
244
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 () => {
245
- 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]: `);
246
280
  return !!reason && (await run("dispose", ["--fresh-session", choice.peer!, "--reason", reason.slice(0, 500)])) && followed();
247
281
  } });
248
282
  else {
@@ -252,11 +286,17 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
252
286
  }
253
287
  menu.set("s", { label: "refresh", act: async () => {} });
254
288
  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("> ");
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, "> ");
258
296
  if (answer === null || answer.toLowerCase() === "q") return "quit";
259
- 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";
260
300
  }
261
301
  }
262
302
 
@@ -272,24 +312,39 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
272
312
  }
273
313
  const plan = await host.plan();
274
314
  if (io.interrupted()) return;
275
- io.out("");
276
- 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);
277
318
  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");
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");
279
320
  const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
280
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"];
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");
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");
283
338
  if (answer === "j") io.out(JSON.stringify(plan, null, 2));
284
339
  else if (answer === "x" && plan.projects.length) { touched = true; await resetFlow(host, io, plan.projects.map((p) => p.project)); }
285
340
  else if (answer === "k" && endable.length) touched = (await endAgents(host, io, endable)) || touched;
286
341
  else if (answer === "a" && !blocked) {
287
342
  touched = true;
288
343
  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}`);
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}`);
291
346
  const end = await follow(host, io, op.id);
292
- 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; }
293
348
  if (end === "left") return;
294
349
  // Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
295
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
+