@staix/agent-hub 0.12.21 → 0.12.23

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 (87) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +27 -12
  3. package/docs/agent-notes/adapters.md +15 -7
  4. package/docs/agent-notes/benchmarks.md +4 -1
  5. package/docs/agent-notes/budget.md +4 -0
  6. package/docs/agent-notes/bus.md +2 -1
  7. package/docs/agent-notes/daemon.md +12 -1
  8. package/docs/agent-notes/local-worker.md +4 -1
  9. package/docs/agent-notes/tasks.md +2 -1
  10. package/docs/agent-notes/tests.md +4 -0
  11. package/docs/events.md +17 -4
  12. package/docs/operations.md +478 -41
  13. package/docs/quickstart.md +14 -6
  14. package/docs/security.md +23 -11
  15. package/docs/smoke.md +133 -7
  16. package/docs/specs/2026-09-19-agent-hub-design.md +115 -15
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
  18. package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
  19. package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
  20. package/docs/verification/2026-10-11-0.12.23.md +90 -0
  21. package/docs/verified.json +32 -17
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +14 -3
  25. package/src/adapters/acp-quota.ts +13 -0
  26. package/src/adapters/acp.ts +105 -17
  27. package/src/adapters/codex-appserver.ts +70 -35
  28. package/src/adapters/local-worker.ts +44 -10
  29. package/src/adapters/pi.ts +158 -19
  30. package/src/cli/bench.ts +2 -1
  31. package/src/cli/console-state.ts +28 -22
  32. package/src/cli/console.ts +20 -10
  33. package/src/cli/help.ts +25 -19
  34. package/src/cli/identity.ts +3 -3
  35. package/src/cli/init.ts +1 -1
  36. package/src/cli/launch.ts +182 -7
  37. package/src/cli/main.ts +409 -177
  38. package/src/cli/output.ts +363 -0
  39. package/src/cli/preview.ts +2 -1
  40. package/src/cli/recovery-package.ts +17 -0
  41. package/src/cli/reset.ts +3 -0
  42. package/src/cli/status-lines.ts +0 -28
  43. package/src/cli/terminal-recovery.ts +27 -1
  44. package/src/cli/upgrade-interactive.ts +297 -0
  45. package/src/cli/upgrade-runtime.ts +80 -9
  46. package/src/cli/upgrade.ts +50 -15
  47. package/src/hub/board.ts +2 -0
  48. package/src/hub/budget.ts +39 -7
  49. package/src/hub/bus.ts +15 -14
  50. package/src/hub/child-process.ts +11 -0
  51. package/src/hub/conductor.ts +23 -11
  52. package/src/hub/config-trust.ts +16 -5
  53. package/src/hub/conflicts.ts +2 -0
  54. package/src/hub/context-window.ts +8 -1
  55. package/src/hub/crash.ts +2 -1
  56. package/src/hub/daemon.ts +483 -83
  57. package/src/hub/db-timeout.ts +3 -0
  58. package/src/hub/delivery-journal.ts +2 -1
  59. package/src/hub/envelope.ts +1 -1
  60. package/src/hub/events.ts +4 -1
  61. package/src/hub/execution-budget.ts +2 -0
  62. package/src/hub/facts.ts +5 -4
  63. package/src/hub/git.ts +32 -0
  64. package/src/hub/hub-tools.ts +1 -1
  65. package/src/hub/lifecycle.ts +10 -4
  66. package/src/hub/manager.ts +18 -5
  67. package/src/hub/peers.ts +7 -5
  68. package/src/hub/permission-mode.ts +14 -0
  69. package/src/hub/project.ts +2 -1
  70. package/src/hub/recovery-error.ts +31 -0
  71. package/src/hub/recovery-store.ts +43 -4
  72. package/src/hub/registry.ts +2 -1
  73. package/src/hub/routing.ts +114 -13
  74. package/src/hub/settings.ts +392 -0
  75. package/src/hub/snapshots.ts +4 -2
  76. package/src/hub/start-mode.ts +48 -0
  77. package/src/hub/tasks.ts +158 -43
  78. package/src/hub/terminal-open.ts +64 -0
  79. package/src/hub/ui.ts +25 -13
  80. package/src/local/deny.ts +71 -2
  81. package/src/local/sandbox.ts +47 -6
  82. package/src/local/tools.ts +25 -6
  83. package/src/memory/recall.ts +2 -2
  84. package/src/models/mlx.ts +2 -1
  85. package/src/pi/extension.ts +43 -20
  86. package/src/pi/tool-receipts.ts +19 -2
  87. package/src/ui/index.html +172 -21
@@ -0,0 +1,297 @@
1
+ import { recoveryArgv } from "../hub/recovery-store.ts";
2
+ import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
3
+
4
+ /**
5
+ * #272: the interactive screens of `ahub upgrade`, `ahub restart` and bare `ahub recovery`. They add no way to change
6
+ * an operation: every action is an existing command (`recovery resume|abort|dispose`, `reset`) run for the person
7
+ * through `io.run`, so each command's own refusals decide. The one thing a screen does itself is stop a runner that
8
+ * only waits, and only one whose claim is verified (`runnerStoppable`).
9
+ */
10
+
11
+ /** The terminal a screen talks to; tests script it. */
12
+ export interface ScreenIO {
13
+ /** One trimmed line from the person; null when the input ended or they pressed Ctrl+C, which no prompt takes for text. */
14
+ ask(question: string): Promise<string | null>;
15
+ out(line: string): void;
16
+ /** Run an ahub entry with the terminal attached (argv after `bun`); resolves with its exit code. */
17
+ run(argv: string[]): Promise<number>;
18
+ sleep(ms: number): Promise<void>;
19
+ /** True from the person's Ctrl+C on: the screens leave, and nothing is stopped. */
20
+ interrupted(): boolean;
21
+ /** True once when a line was entered while nothing was asked (during a follow); that line is dropped. */
22
+ typed(): boolean;
23
+ }
24
+
25
+ /** What the screens read and start. */
26
+ export interface UpgradeHost {
27
+ plan(): Promise<UpgradePlan>;
28
+ /** Check the reviewed plan again and start it as a detached operation; `interrupted` is asked last, before anything is created. */
29
+ apply(plan: UpgradePlan, interrupted: () => boolean): Promise<RecoveryOperation>;
30
+ /** End one attached agent now: close a TUI agent's terminal, or ask the hub to stop a headless one. One line of outcome. */
31
+ endPeer(planned: PlannedProject, peer: RecoveryPeer): Promise<string>;
32
+ /** The operation that holds the machine's recovery lock. */
33
+ lock(): string | undefined;
34
+ read(id: string): RecoveryOperation;
35
+ runner(id: string): number | "unknown" | undefined;
36
+ live(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
37
+ /** Whether the runner's claim carries a process signature that still matches: the only kind a screen may stop. */
38
+ runnerStoppable(id: string): boolean;
39
+ /** Stop that verified runner and wait until it is gone; false when one still holds the operation. */
40
+ stopRunner(id: string): Promise<boolean>;
41
+ /** This release's CLI entry, for `reset`. */
42
+ entry: string;
43
+ /** This CLI's version. */
44
+ version: string;
45
+ }
46
+
47
+ const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await io.ask(question)) ?? "");
48
+ /** The way back after Ctrl+C: the operation's own coordinator, since the installed `ahub` may still be the older release (#215). */
49
+ const left = (op: { id: string; sourceRoot?: string }): string => `left: the runner keeps working; \`bun ${recoveryArgv(op, "status")[0]} recovery\` shows the operation and what can be done`;
50
+
51
+ /**
52
+ * How an attached peer runs, which decides how it can be ended: a TUI agent in a terminal the plan could bind, a
53
+ * headless agent the hub owns, or `unmanaged` (shown as `own`): a session the hub did not launch, or one it launched
54
+ * whose terminal the plan could not bind (a person ends that one where it runs).
55
+ */
56
+ export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
57
+ if (peer.state === "offline") return "offline";
58
+ if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
59
+ if (peer.id === "claude" || peer.id === "codex" || (peer.id === "pi" && peer.args?.mode === "tui")) return "unmanaged";
60
+ return "headless";
61
+ }
62
+
63
+ const STEPS: Record<string, string> = {
64
+ stage: "staging the release", prepare: "holding deliveries and waiting until the hub is quiet", reprepare: "preparing the source again after an expired hold",
65
+ commit: "closing terminals and stopping the old hub", restart: "restarting a target that stopped", start: "starting the new hub", restore: "restoring Codex and Pi sessions",
66
+ "install-plugin": "installing the Claude plugin", "restore-claude": "restoring the Claude session and verifying the hub", release: "releasing held deliveries",
67
+ "refresh-manager": "refreshing the dashboard manager", "install-global": "installing the global CLI", completed: "completed", cancelled: "cancelled",
68
+ };
69
+
70
+ /** A receipt step (`commit:<project>`) in a person's words; a step this release does not know is shown as it is. */
71
+ export function stepLabel(step: string): string {
72
+ const at = step.indexOf(":"), name = at < 0 ? step : step.slice(0, at);
73
+ return STEPS[name] ? `${at < 0 ? "" : `${step.slice(at + 1)}: `}${STEPS[name]}` : step;
74
+ }
75
+
76
+ function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
77
+ if (peer.state === "offline") return "offline, left as it is";
78
+ if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
79
+ if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
80
+ const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
81
+ if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
82
+ return peer.id === "pi" ? "restarts headless on its recorded session" : "restarts headless as a new session";
83
+ }
84
+
85
+ const KINDS = { tui: "TUI", headless: "headless", unmanaged: "own", offline: "-" } as const;
86
+
87
+ /** What the source says keeps it from being quiet; an older hub names only its busy peers. */
88
+ const inProgress = (source: Inspection): string[] => source.recovery?.waiting ?? source.peers.filter((p) => p.state === "busy").map((p) => `${p.id} is busy`);
89
+
90
+ export function planLines(plan: UpgradePlan, current: string): string[] {
91
+ const lines = [plan.kind === "upgrade" ? `upgrade to ${plan.version} (coordinator ${current})` : `restart on ${plan.version}`];
92
+ for (const p of plan.projects) {
93
+ lines.push("", `${p.project.id} ${p.project.root} hub ${p.source.version ?? "unknown"} (${p.source.state})`);
94
+ const width = Math.max(0, ...p.source.peers.map((peer) => peer.id.length));
95
+ for (const peer of p.source.peers) lines.push(` ${peer.id.padEnd(width)} ${peer.state.padEnd(7)} ${KINDS[peerKind(p, peer)].padEnd(8)} ${peerAction(p, peer)}`);
96
+ const waiting = inProgress(p.source);
97
+ if (waiting.length) lines.push(` in progress: ${waiting.join(", ")} (apply waits up to 10 minutes for it, then leaves this hub running)`);
98
+ for (const blocker of p.blockers) lines.push(` blocker: ${blocker}`);
99
+ }
100
+ if (plan.blockers.length) lines.push("");
101
+ for (const blocker of plan.blockers) lines.push(`blocker: ${blocker}`);
102
+ return lines;
103
+ }
104
+
105
+ const ago = (ms: number): string => ms < 90_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 5_400_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`;
106
+
107
+ export function operationLines(op: RecoveryOperation, runner: number | "unknown" | undefined, now: number): string[] {
108
+ const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
109
+ const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
110
+ for (const p of op.projects) {
111
+ const effects = Object.entries(p.terminals).map(([key, value]) => value === "pending" || value === "failed" ? `${key} ${value}` : key);
112
+ lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
113
+ }
114
+ // The error ends with the commands status would print; the menu below offers them instead.
115
+ if (op.error) lines.push(` error: ${op.error.split("; next actions: ")[0]}`);
116
+ return lines;
117
+ }
118
+
119
+ /**
120
+ * Print steps and readiness waits until the operation completes, blocks or loses its runner ("open"), the person
121
+ * enters a line (also "open": the menu), or presses Ctrl+C ("left"). `scheduled` is the receipt's `updatedAt` from
122
+ * before a runner was scheduled (resume, a fresh session): until that runner writes, the receipt still shows the state
123
+ * it was scheduled from, which is not the outcome.
124
+ */
125
+ export async function follow(host: UpgradeHost, io: ScreenIO, id: string, scheduled?: number): Promise<"completed" | "open" | "left"> {
126
+ let step = "", waiting = "", unowned = 0, tick = 0;
127
+ for (;; tick++) {
128
+ const op = host.read(id);
129
+ if (io.interrupted()) { io.out(left(op)); return "left"; }
130
+ if (io.typed()) return "open";
131
+ const unwritten = scheduled !== undefined && op.updatedAt === scheduled;
132
+ if (!unwritten) {
133
+ scheduled = undefined;
134
+ if (op.step !== step) { step = op.step; waiting = ""; tick = 0; io.out(` ${stepLabel(step)}`); }
135
+ if (op.phase === "completed") return "completed";
136
+ if (op.phase === "cancelled" || op.phase === "blocked") return "open";
137
+ }
138
+ // A runner claims and writes a moment after it is spawned: only 5 s without either reads as gone.
139
+ unowned = host.runner(id) === undefined || unwritten ? unowned + 1 : 0;
140
+ if (unowned > 20) return "open";
141
+ if (!unwritten && /^(re)?prepare:/.test(step) && tick % 8 === 0) {
142
+ const live = await host.live(op).catch(() => ({} as Record<string, Inspection | undefined>));
143
+ // A hub that is not ready and names no cause (one older than #272 lists only busy peers and approvals) still waits.
144
+ const held = Object.values(live).some((i) => i?.recovery?.operationId === id && i.recovery.ready === false);
145
+ const now = [...new Set(Object.values(live).flatMap((i) => i?.recovery?.waiting ?? []))].join(", ") || (held ? "the hub is not quiet yet (it names no cause: a completion check, a task command or a Pi call may be in flight)" : "");
146
+ if (now && now !== waiting) io.out(` waiting for: ${now}`);
147
+ waiting = now;
148
+ }
149
+ await io.sleep(250);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * End attached agents before the upgrade, by kind or by name: an ended agent is offline in the next plan, so it is
155
+ * neither waited for nor restored. True when at least one was asked to end.
156
+ */
157
+ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { planned: PlannedProject; peer: RecoveryPeer }[]): Promise<boolean> {
158
+ type Agent = (typeof endable)[number];
159
+ // An upgrade plans every running project and each has its own claude or codex: with several, a name carries its project.
160
+ const several = new Set(endable.map((e) => e.planned.project.id)).size > 1;
161
+ const full = (e: Agent) => `${e.planned.project.id}/${e.peer.id}`, label = (e: Agent) => several ? full(e) : e.peer.id;
162
+ const of = (kind: string) => endable.filter((e) => peerKind(e.planned, e.peer) === kind);
163
+ const tui = of("tui"), headless = of("headless");
164
+ const choices = [...(tui.length ? [`[t] the TUI agents (${tui.map(label).join(", ")})`] : []), ...(headless.length ? [`[h] the headless agents (${headless.map(label).join(", ")})`] : []), "or names separated by spaces", "[Enter] none"];
165
+ const words = new Set(((await io.ask(`End which agents? ${choices.join(" ")}: `)) ?? "").toLowerCase().split(/\s+/).filter(Boolean));
166
+ const picked = new Set<Agent>(), unknown: string[] = [], ambiguous: string[] = [];
167
+ for (const word of words) {
168
+ const named = word === "t" ? tui : word === "h" ? headless : endable.filter((e) => word === full(e) || word === e.peer.id);
169
+ if (word === "t" || word === "h" || named.length === 1) for (const e of named) picked.add(e);
170
+ else (named.length ? ambiguous : unknown).push(word);
171
+ }
172
+ if (unknown.length) io.out(`not an agent that can be ended here: ${unknown.join(", ")}`);
173
+ if (ambiguous.length) io.out(`${ambiguous.join(", ")}: attached in several projects; name the one to end with its project (${endable.filter((e) => ambiguous.includes(e.peer.id)).map(full).join(", ")})`);
174
+ const chosen = endable.filter((e) => picked.has(e));
175
+ if (!chosen.length) { io.out("no agent was ended"); return false; }
176
+ if (!(await yes(io, `End ${chosen.map(label).join(", ")} now? A TUI agent's terminal is closed, a turn in progress is cut, and none of them is restored by the upgrade. [y/N] `))) { io.out("no agent was ended"); return false; }
177
+ let asked = 0;
178
+ for (const e of chosen) {
179
+ if (io.interrupted()) { io.out(`interrupted: ${chosen.length - asked} of ${chosen.length} left alone (${chosen.slice(asked).map(label).join(", ")})`); break; }
180
+ io.out(` ${await host.endPeer(e.planned, e.peer)}`);
181
+ asked++;
182
+ }
183
+ return asked > 0;
184
+ }
185
+
186
+ /** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
187
+ export async function resetFlow(host: UpgradeHost, io: ScreenIO, projects: { id: string; root: string }[]): Promise<void> {
188
+ if (host.lock()) return io.out("an operation holds the recovery lock; cancel or end it before a reset");
189
+ let project = projects[0];
190
+ if (projects.length > 1) {
191
+ projects.forEach((p, i) => io.out(` ${i + 1} ${p.id} ${p.root}`));
192
+ project = projects[Number(await io.ask("Reset which project? [number, Enter: none] ")) - 1];
193
+ }
194
+ if (!project) return io.out("nothing was reset");
195
+ const scope = ((await io.ask("[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
196
+ if (scope !== "r" && scope !== "a") return io.out("nothing was reset");
197
+ const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
198
+ if ((await io.run(reset)) !== 0) return; // the dry run: it lists and changes nothing
199
+ if (!(await yes(io, `Apply this reset of ${project.id}? [y/N] `))) return io.out("nothing was reset");
200
+ await io.run([...reset, "--yes"]);
201
+ }
202
+
203
+ /** One open operation: what it did, and the choices `status` would name, run on the spot. */
204
+ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: string): Promise<"completed" | "ended" | "quit"> {
205
+ for (let acted = false, reset = false; ;) {
206
+ if (io.interrupted()) return "quit";
207
+ const op = host.read(id), runner = host.runner(id);
208
+ if (op.phase === "completed") { io.out(`${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
209
+ if (op.phase === "cancelled") {
210
+ io.out(`operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
211
+ // Ended from this screen: the reset the person may have come for is one key away.
212
+ if (acted && !reset && await yes(io, "Reset a project's hub now? [y/N] ")) await resetFlow(host, io, op.plan.projects.map((p) => p.project));
213
+ return "ended";
214
+ }
215
+ const live = runner ? {} : await host.live(op);
216
+ io.out("");
217
+ for (const line of operationLines(op, runner, Date.now())) io.out(line);
218
+ const run = async (action: "resume" | "abort" | "dispose", flags: string[] = []) => { acted = true; return (await io.run(recoveryArgv(op, action, flags))) === 0; };
219
+ // A runner scheduled by a command has not written yet: follow from the receipt as that command left it (resume
220
+ // writes nothing, a fresh-session choice records itself first).
221
+ const followed = async (): Promise<boolean> => (await follow(host, io, id, host.read(id).updatedAt)) === "left";
222
+ const end = async (): Promise<boolean> => {
223
+ if (!(await yes(io, "End this operation? Its own targets are stopped and the upgrade is abandoned, not completed. [y/N] "))) return false;
224
+ const reason = await io.ask("Reason for the audit [Enter: ended from the upgrade screen]: ");
225
+ if (reason === null) return false; // Ctrl+C or the end of input is never a reason
226
+ return run("dispose", ["--stop-and-archive", "--reason", reason.slice(0, 500) || "ended from the upgrade screen"]);
227
+ };
228
+ // An act returns true when the person left (Ctrl+C in a follow).
229
+ const menu = new Map<string, { label: string; act: () => Promise<boolean | void> }>();
230
+ if (typeof runner === "number") {
231
+ menu.set("w", { label: "follow its progress", act: async () => (await follow(host, io, id)) === "left" });
232
+ if (cancellableWait(op) && host.runnerStoppable(id)) menu.set("c", { label: "cancel: stop the waiting runner and cancel (nothing was closed or stopped)", act: async () => {
233
+ // Decided again on the receipt as it is now: the runner may have moved on while the menu was read.
234
+ if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return io.out("the runner moved on; nothing was stopped or cancelled");
235
+ if (await host.stopRunner(id)) await run("abort");
236
+ else io.out("the runner could not be stopped; nothing was cancelled");
237
+ } });
238
+ else if (cancellableWait(op)) io.out(" cancel is not offered: this runner's claim cannot be verified (an older coordinator's, or unreadable); a live runner gives up its wait after 10 minutes");
239
+ }
240
+ for (const choice of nextChoices(op, runner, live)) {
241
+ if (choice.kind === "wait") { if (typeof runner !== "number") io.out(` ${choice.text}`); }
242
+ else if (choice.kind === "resume") menu.set("r", { label: `resume${op.error ? " (after the step the error names)" : ""}`, act: async () => (await run("resume")) && followed() });
243
+ else if (choice.kind === "abort") menu.set("c", { label: "cancel (no runtime was stopped; nothing to roll back)", act: async () => { await run("abort"); } });
244
+ else if (choice.kind === "fresh") menu.set(menu.has("f") ? `f-${choice.peer}` : "f", { label: `start ${choice.peer} as a new session (its conversation is recorded as lost)`, act: async () => {
245
+ const reason = await io.ask(`Reason for losing ${choice.peer}'s conversation [Enter: go back]: `);
246
+ return !!reason && (await run("dispose", ["--fresh-session", choice.peer!, "--reason", reason.slice(0, 500)])) && followed();
247
+ } });
248
+ else {
249
+ menu.set("e", { label: "end: stop and archive (the upgrade is abandoned, not completed)", act: async () => { await end(); } });
250
+ menu.set("x", { label: "end, then reset a project's hub", act: async () => { if (await end()) { reset = true; await resetFlow(host, io, op.plan.projects.map((p) => p.project)); } } });
251
+ }
252
+ }
253
+ menu.set("s", { label: "refresh", act: async () => {} });
254
+ menu.set("j", { label: "receipt as JSON", act: async () => io.out(JSON.stringify(publicOperation(op, runner, live), null, 2)) });
255
+ for (const [key, item] of menu) io.out(` [${key}] ${item.label}`);
256
+ io.out(" [q] quit (the operation stays as it is)");
257
+ const answer = await io.ask("> ");
258
+ if (answer === null || answer.toLowerCase() === "q") return "quit";
259
+ if ((await menu.get(answer.toLowerCase())?.act()) === true) return "quit";
260
+ }
261
+ }
262
+
263
+ /** The plan, reviewed with the person: apply and follow, or an open operation's screen first. */
264
+ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void> {
265
+ for (let touched = false; ;) {
266
+ if (io.interrupted()) return;
267
+ const owner = host.lock();
268
+ if (owner) {
269
+ if ((await operationScreen(host, io, owner)) !== "ended") return;
270
+ if (!(await yes(io, "Plan again? [y/N] "))) return;
271
+ continue;
272
+ }
273
+ const plan = await host.plan();
274
+ if (io.interrupted()) return;
275
+ io.out("");
276
+ for (const line of planLines(plan, host.version)) io.out(line);
277
+ const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
278
+ if (!plan.projects.length) io.out(plan.kind === "upgrade" ? `no hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
279
+ const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
280
+ const keys = [...(blocked ? [] : ["[a] apply"]), "[r] refresh", ...(endable.length ? ["[k] end agents"] : []), "[j] plan as JSON", ...(plan.projects.length ? ["[x] reset a project's hub"] : []), "[q] quit"];
281
+ const answer = (await io.ask(`${blocked ? "Blocked: take the next action each blocker names, then refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase() ?? "q";
282
+ if (answer === "q") return touched ? undefined : io.out("nothing was changed");
283
+ if (answer === "j") io.out(JSON.stringify(plan, null, 2));
284
+ else if (answer === "x" && plan.projects.length) { touched = true; await resetFlow(host, io, plan.projects.map((p) => p.project)); }
285
+ else if (answer === "k" && endable.length) touched = (await endAgents(host, io, endable)) || touched;
286
+ else if (answer === "a" && !blocked) {
287
+ touched = true;
288
+ let op: RecoveryOperation;
289
+ try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { io.out(`ahub: ${(error as Error).message}`); continue; }
290
+ io.out(`operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
291
+ const end = await follow(host, io, op.id);
292
+ if (end === "completed") return io.out(`${plan.kind} to ${plan.version} completed`);
293
+ if (end === "left") return;
294
+ // Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
295
+ }
296
+ }
297
+ }
@@ -1,3 +1,4 @@
1
+ import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
1
2
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, join, resolve } from "node:path";
@@ -8,10 +9,11 @@ import type { Project } from "../hub/registry.ts";
8
9
  import { hubHome } from "../hub/project.ts";
9
10
  import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
10
11
  import { inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
11
- import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
12
+ import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
12
13
  import { readEvents } from "../hub/events.ts";
13
14
  import { refreshManager } from "../hub/manager.ts";
14
15
  import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
16
+ import { recoveryLock } from "../hub/recovery-store.ts";
15
17
 
16
18
  /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
17
19
  const RECONNECT_WAIT_MS = 90_000;
@@ -82,21 +84,79 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
82
84
  ...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
83
85
  }
84
86
 
87
+ const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
88
+
85
89
  async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
86
- const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, 30_000, protocol);
90
+ const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
87
91
  try {
88
- const reply = await client.request(message, 30_000);
89
- if (reply.ok === false) throw new Error(reply.error ?? "recovery control request failed");
92
+ const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
93
+ if (reply.ok === false) {
94
+ // An older hub has only its exact SQLite message; preserve compatibility without trusting arbitrary text.
95
+ if (isDatabaseBusyReply(reply)) throw new RecoveryDatabaseBusy();
96
+ throw new Error(reply.error ?? "recovery control request failed");
97
+ }
90
98
  return reply;
91
99
  } finally { client.close(); }
92
100
  }
93
101
 
102
+ /**
103
+ * #272: end one attached agent before an upgrade, for the person who chose it on the plan screen. The plan is what was
104
+ * drawn, so all of it is read again and any difference ends nothing: no operation holds the lock, the hub runs as the
105
+ * instance the plan read with the peer attached, and for a TUI agent the attached session is the one the plan bound and
106
+ * binds again to a terminal with the plan's handle and incarnation. `closeTerminal` itself compares only the terminal's
107
+ * handle, incarnation, worktree and root, never who runs in it. A headless agent is the hub's own process, so the hub
108
+ * is asked to stop it, which a hub older than that request cannot do. `deps` are a test's fakes.
109
+ */
110
+ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer, deps: { run?: RunCommand; inspect?: (project: Project) => Promise<Inspection>; lock?: () => string | undefined } = {}): Promise<string> {
111
+ const { run = runCommand, inspect = inspectRecovery, lock = recoveryLock } = deps;
112
+ const name = `${planned.project.id}/${peer.id}`;
113
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === peer.id);
114
+ const refused = (why: string) => `${name}: not ended (${why})`;
115
+ let cut = "", current: TerminalBinding | undefined;
116
+ try {
117
+ const owner = lock();
118
+ if (owner) return refused(`recovery operation ${owner} holds the lock`);
119
+ const hub = await inspect(planned.project);
120
+ if (hub.state !== "running" || hub.instanceId !== planned.source.instanceId) return refused(`its hub reads as ${hub.state === "running" ? `instance ${hub.instanceId}, not the ${planned.source.instanceId} of the plan` : hub.state}`);
121
+ const now = hub.peers.find((p) => p.id === peer.id && p.state !== "offline");
122
+ if (!now) return refused("it is no longer attached to its hub");
123
+ // The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
124
+ if (now.state === "busy") cut = "; its turn was cut: a delivery it had not settled is held as needs_review, and its queue stays held until ahub queue resolve";
125
+ if (binding) {
126
+ if ((binding.peer === "codex" ? now.threadId : now.sessionId) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
127
+ // The same question the plan asked (`makeUpgradePlan`), for this one peer.
128
+ const session = binding.peer === "pi" ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
129
+ const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
130
+ current = found.byPeer[binding.peer];
131
+ if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
132
+ if (current.handle !== binding.handle || current.incarnationId !== binding.incarnationId) return refused(`its session is now bound to terminal ${current.handle} (${current.incarnationId}), not the plan's ${binding.handle} (${binding.incarnationId})`);
133
+ }
134
+ // The reads above take a second or more: an operation that took the lock meanwhile still stops this.
135
+ const taken = lock();
136
+ if (taken) return refused(`recovery operation ${taken} holds the lock`);
137
+ } catch (error) { return refused((error as Error).message); } // a lock or a registry that cannot be read throws
138
+ if (current) {
139
+ const result = await closeTerminal(current, 0, terminalOptions(run));
140
+ return result.closed ? `${name}: terminal ${current.handle} closed${cut}` : `${name}: its terminal was not closed (${result.blockers[0]?.message ?? "unknown reason"}); end it in that terminal`;
141
+ }
142
+ try {
143
+ await rpc(planned.project, { t: "peer_stop", peer: peer.id }, planned.source.protocol ?? PROTOCOL);
144
+ return `${name}: stopped${cut}`;
145
+ } catch (error) {
146
+ const message = (error as Error).message;
147
+ return /does not know "peer_stop"/.test(message)
148
+ ? `${name}: hub ${planned.source.version ?? "of this version"} cannot end a headless agent by itself; it stops with the old hub at the upgrade and the new hub starts it again (ahub stop ${peer.id} ends it then)`
149
+ : `${name}: not stopped (${message})`;
150
+ }
151
+ }
152
+
94
153
  export async function inspectRecovery(project: Project): Promise<Inspection> {
95
154
  // #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
96
155
  // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
97
156
  // caller reads that as not inspected, never as stopped.
98
157
  const claim = registeredProjects().find((p) => p.id === project.id);
99
- const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
158
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS);
159
+ if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
100
160
  const control = readControl(project.stateDir);
101
161
  const sourceProtocol = control?.protocol;
102
162
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
@@ -109,11 +169,16 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
109
169
  ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
110
170
  };
111
171
  let status = base.status;
112
- if (!status && legacySupported) {
172
+ if (!status && control && sourceProtocol === PROTOCOL) return {
173
+ state: "unavailable", peers: [], blockers: ["runtime changed during recovery inspection"],
174
+ ...(control.instanceId ? { instanceId: control.instanceId } : {}), protocol: control.protocol,
175
+ };
176
+ if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
113
177
  try {
114
178
  const readback = await rpc(project, { t: "status" }, sourceProtocol);
115
179
  status = readback.status;
116
- } catch {
180
+ } catch (error) {
181
+ if (isDatabaseBusy(error)) throw error;
117
182
  // server.stop() runs before state-file removal, so a stopping hub briefly refuses
118
183
  // connections with its manifest still on disk. Never infer stopped from a connect
119
184
  // failure: callers poll again and see "stopped" once the manifest is gone.
@@ -124,15 +189,18 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
124
189
  if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
125
190
  let response: any;
126
191
  try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
127
- catch {
192
+ catch (error) {
193
+ if (isDatabaseBusy(error)) throw error;
128
194
  // A stop can complete between authenticated status and the second read. Never infer stopped
129
195
  // from a connection failure: the coordinator will inspect the ownership manifest again.
130
196
  return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
131
197
  peers: [], blockers: ["runtime changed during recovery inspection"] };
132
198
  }
133
199
  const peers = Object.values(response.recovery?.peers ?? {}) as any[];
200
+ // #272: the hub's own words for what it waits on; shown to the person, never decided on.
201
+ const waiting = (Array.isArray(response.recovery?.blockers) ? response.recovery.blockers : []).filter((b: unknown): b is string => typeof b === "string").slice(0, 20).map((b: string) => b.slice(0, 200));
134
202
  return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
135
- recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
203
+ recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready, ...(waiting.length ? { waiting } : {}) }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
136
204
  ...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
137
205
  ...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
138
206
  ...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
@@ -449,6 +517,9 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
449
517
  if (current && current.state !== "offline") continue;
450
518
  const args = [peer.id];
451
519
  for (const key of ["mode", "backend", "model", "route"]) if (typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
520
+ // #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
521
+ // one is filtered out above), so a snapshot that recorded no mode still restores it headless.
522
+ if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
452
523
  if (peer.id === "pi") {
453
524
  const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
454
525
  if (typeof sessionFile === "string") args.push("--session-file", sessionFile);