@staix/agent-hub 0.12.31 → 0.12.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/README.md +14 -7
- package/docs/agent-notes/adapters.md +7 -4
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +13 -8
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +4 -3
- package/docs/agent-notes/tests.md +1 -0
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +1 -1
- package/docs/operations.md +141 -25
- package/docs/quickstart.md +10 -6
- package/docs/security.md +18 -9
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verification/2026-10-18-0.12.33.md +42 -0
- package/docs/verified.json +24 -16
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1296 -26
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/definitions/builtin-metadata.ts +121 -0
- package/src/adapters/definitions/kimi-native.ts +4 -0
- package/src/adapters/definitions/qwen-native.ts +24 -0
- package/src/adapters/definitions/qwen.ts +71 -0
- package/src/adapters/pi.ts +6 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/console-state.ts +74 -5
- package/src/cli/console.ts +35 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +13 -3
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +200 -39
- package/src/cli/output.ts +2 -0
- package/src/cli/preview.ts +8 -5
- package/src/cli/recovery-guidance.ts +137 -0
- package/src/cli/recovery-presentation-options.ts +8 -0
- package/src/cli/recovery-session-file.ts +18 -0
- package/src/cli/reset.ts +13 -1
- package/src/cli/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -75
- package/src/cli/upgrade-labels.ts +30 -0
- package/src/cli/upgrade-presentation-data.ts +41 -0
- package/src/cli/upgrade-presentation-flags.ts +31 -0
- package/src/cli/upgrade-presentation.ts +227 -0
- package/src/cli/upgrade-restore-state.ts +6 -2
- package/src/cli/upgrade-runtime.ts +360 -199
- package/src/cli/upgrade.ts +278 -25
- package/src/hub/bus.ts +144 -14
- package/src/hub/cohorts.ts +2 -2
- package/src/hub/config-apply.ts +242 -0
- package/src/hub/config-reload.ts +120 -0
- package/src/hub/config-trust.ts +11 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +577 -179
- package/src/hub/deferred-hub.ts +60 -0
- package/src/hub/delivery-journal.ts +6 -0
- package/src/hub/events.ts +2 -2
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +10 -13
- package/src/hub/peers.ts +12 -2
- package/src/hub/permission-mode.ts +5 -3
- package/src/hub/recovery-continuity.ts +152 -0
- package/src/hub/recovery-store.ts +10 -4
- package/src/hub/restart.ts +12 -1
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +50 -36
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/supervision.ts +3 -2
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +23 -7
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
|
|
2
|
+
import { quoted, wrap } from "./console-state.ts";
|
|
3
|
+
import { operationLines, stepLabel } from "./upgrade-labels.ts";
|
|
4
|
+
export { operationLines, stepLabel } from "./upgrade-labels.ts";
|
|
2
5
|
import { recoveryArgv } from "../hub/recovery-store.ts";
|
|
3
6
|
import { cancellableWait, nextChoices, publicOperation, type Inspection, type PlannedProject, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
|
|
4
7
|
|
|
@@ -11,6 +14,8 @@ import { cancellableWait, nextChoices, publicOperation, type Inspection, type Pl
|
|
|
11
14
|
|
|
12
15
|
/** The terminal a screen talks to; tests script it. */
|
|
13
16
|
export interface ScreenIO {
|
|
17
|
+
/** Optional terminal presentation; raw JSON always bypasses this layer. */
|
|
18
|
+
presentation?: RecoveryPresentationOptions;
|
|
14
19
|
/** One trimmed line from the person; null when the input ended or they pressed Ctrl+C, which no prompt takes for text. */
|
|
15
20
|
ask(question: string): Promise<string | null>;
|
|
16
21
|
out(line: string): void;
|
|
@@ -35,6 +40,8 @@ export interface UpgradeHost {
|
|
|
35
40
|
read(id: string): RecoveryOperation;
|
|
36
41
|
runner(id: string): number | "unknown" | undefined;
|
|
37
42
|
live(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
|
|
43
|
+
/** Read-only current state after completion/disposal; failure remains unknown. */
|
|
44
|
+
endInspection?(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
|
|
38
45
|
/** Whether the runner's claim carries a process signature that still matches: the only kind a screen may stop. */
|
|
39
46
|
runnerStoppable(id: string): boolean;
|
|
40
47
|
/** Stop that verified runner and wait until it is gone; false when one still holds the operation. */
|
|
@@ -45,9 +52,28 @@ export interface UpgradeHost {
|
|
|
45
52
|
version: string;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
function emit(io: ScreenIO, lines: readonly string[]): void { for (const line of lines) io.out(line); }
|
|
56
|
+
function human(io: ScreenIO, line: string): void {
|
|
57
|
+
if (!io.presentation) return io.out(line);
|
|
58
|
+
emit(io, wrap(/\p{Cc}/u.test(line) ? quoted(line) : line, io.presentation.columns));
|
|
59
|
+
}
|
|
60
|
+
function ask(io: ScreenIO, question: string): Promise<string | null> {
|
|
61
|
+
if (!io.presentation) return io.ask(question);
|
|
62
|
+
const lines = wrap(/\p{Cc}/u.test(question) ? quoted(question) : question, io.presentation.columns);
|
|
63
|
+
emit(io, lines.slice(0, -1));
|
|
64
|
+
return io.ask(lines.at(-1) ?? "> ");
|
|
65
|
+
}
|
|
66
|
+
async function summary(host: UpgradeHost, io: ScreenIO, op: RecoveryOperation): Promise<void> {
|
|
67
|
+
if (!io.presentation) return;
|
|
68
|
+
const live = await host.endInspection?.(op).catch(() => ({} as Record<string, Inspection | undefined>));
|
|
69
|
+
emit(io, renderSummary(op, live ?? {}, io.presentation));
|
|
70
|
+
}
|
|
71
|
+
const clock = (io: ScreenIO) => io.presentation?.now() ?? Date.now();
|
|
72
|
+
|
|
73
|
+
const yes = async (io: ScreenIO, question: string): Promise<boolean> => /^y(es)?$/i.test((await ask(io, question)) ?? "");
|
|
49
74
|
/** The way back after Ctrl+C: the operation's own coordinator, since the installed `ahub` may still be the older release (#215). */
|
|
50
|
-
const left = (op: { id: string; sourceRoot?: string }): string =>
|
|
75
|
+
const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string => io.presentation && !io.presentation.verbose
|
|
76
|
+
? `left: the runner keeps working; ahub recovery status ${op.id} shows the operation (use --verbose for its retained coordinator path)` : `left: the runner keeps working; \`bun ${recoveryArgv(op, "status")[0]} recovery\` shows the operation and what can be done`;
|
|
51
77
|
|
|
52
78
|
/**
|
|
53
79
|
* How an attached peer runs, which decides how it can be ended: a TUI agent in a terminal the plan could bind, a
|
|
@@ -61,19 +87,6 @@ export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "
|
|
|
61
87
|
return "headless";
|
|
62
88
|
}
|
|
63
89
|
|
|
64
|
-
const STEPS: Record<string, string> = {
|
|
65
|
-
stage: "staging the release", prepare: "holding deliveries and waiting until the hub is quiet", reprepare: "preparing the source again after an expired hold",
|
|
66
|
-
commit: "closing terminals and stopping the old hub", restart: "restarting a target that stopped", start: "starting the new hub", restore: "restoring Codex and Pi sessions",
|
|
67
|
-
"install-plugin": "installing the Claude plugin", "restore-claude": "restoring the Claude session and verifying the hub", release: "releasing held deliveries",
|
|
68
|
-
"refresh-manager": "refreshing the dashboard manager", "install-global": "installing the global CLI", completed: "completed", cancelled: "cancelled",
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/** A receipt step (`commit:<project>`) in a person's words; a step this release does not know is shown as it is. */
|
|
72
|
-
export function stepLabel(step: string): string {
|
|
73
|
-
const at = step.indexOf(":"), name = at < 0 ? step : step.slice(0, at);
|
|
74
|
-
return STEPS[name] ? `${at < 0 ? "" : `${step.slice(at + 1)}: `}${STEPS[name]}` : step;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
90
|
function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
|
|
78
91
|
if (peer.state === "offline") return "offline, left as it is";
|
|
79
92
|
if (planned.reconnectOnly?.includes(peer.id)) return "reconnects by itself (unmanaged session; its terminal is left alone)";
|
|
@@ -103,20 +116,6 @@ export function planLines(plan: UpgradePlan, current: string): string[] {
|
|
|
103
116
|
return lines;
|
|
104
117
|
}
|
|
105
118
|
|
|
106
|
-
const ago = (ms: number): string => ms < 90_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 5_400_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`;
|
|
107
|
-
|
|
108
|
-
export function operationLines(op: RecoveryOperation, runner: number | "unknown" | undefined, now: number): string[] {
|
|
109
|
-
const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
|
|
110
|
-
const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
|
|
111
|
-
for (const p of op.projects) {
|
|
112
|
-
const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
113
|
-
lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
|
|
114
|
-
}
|
|
115
|
-
// The error ends with the commands status would print; the menu below offers them instead.
|
|
116
|
-
if (op.error) lines.push(` error: ${op.error.split("; next actions: ")[0]}`);
|
|
117
|
-
return lines;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
119
|
/**
|
|
121
120
|
* Print steps and readiness waits until the operation completes, blocks or loses its runner ("open"), the person
|
|
122
121
|
* enters a line (also "open": the menu), or presses Ctrl+C ("left"). `scheduled` is the receipt's `updatedAt` from
|
|
@@ -124,15 +123,27 @@ export function operationLines(op: RecoveryOperation, runner: number | "unknown"
|
|
|
124
123
|
* it was scheduled from, which is not the outcome.
|
|
125
124
|
*/
|
|
126
125
|
export async function follow(host: UpgradeHost, io: ScreenIO, id: string, scheduled?: number): Promise<"completed" | "open" | "left"> {
|
|
127
|
-
let step = "", waiting = "", unowned = 0, tick = 0;
|
|
126
|
+
let step = "", waiting = "", unowned = 0, tick = 0, started = clock(io), lastWaitAt = -Infinity;
|
|
127
|
+
const resuming = scheduled !== undefined;
|
|
128
|
+
let resumeAnnounced = false;
|
|
128
129
|
for (;; tick++) {
|
|
129
130
|
const op = host.read(id);
|
|
130
|
-
if (io.interrupted()) { io
|
|
131
|
+
if (io.interrupted()) { human(io, left(op, io)); return "left"; }
|
|
131
132
|
if (io.typed()) return "open";
|
|
132
133
|
const unwritten = scheduled !== undefined && op.updatedAt === scheduled;
|
|
133
134
|
if (!unwritten) {
|
|
134
135
|
scheduled = undefined;
|
|
135
|
-
if (op.step !== step) {
|
|
136
|
+
if (op.step !== step) {
|
|
137
|
+
const now = clock(io);
|
|
138
|
+
if (io.presentation && step) emit(io, renderStep(resuming && step === "stage" ? "checking retained release" : stepLabel(step), "done", Math.max(0, now - started), io.presentation));
|
|
139
|
+
step = op.step; waiting = ""; tick = 0; started = now; lastWaitAt = -Infinity;
|
|
140
|
+
if (io.presentation) {
|
|
141
|
+
if (resuming && !resumeAnnounced) { human(io, `resuming from ${step === "stage" ? "checking retained release" : stepLabel(step)}`); resumeAnnounced = true; }
|
|
142
|
+
if (op.phase === "blocked") emit(io, renderStep(stepLabel(step), "blocked", undefined, io.presentation));
|
|
143
|
+
else if (op.phase === "completed") emit(io, renderStep(stepLabel(step), "done", undefined, io.presentation));
|
|
144
|
+
else emit(io, renderStep(resuming && step === "stage" ? "checking retained release" : stepLabel(step), "running", undefined, io.presentation));
|
|
145
|
+
} else human(io, ` ${stepLabel(step)}`);
|
|
146
|
+
}
|
|
136
147
|
if (op.phase === "completed") return "completed";
|
|
137
148
|
if (op.phase === "cancelled" || op.phase === "blocked") return "open";
|
|
138
149
|
}
|
|
@@ -144,9 +155,20 @@ export async function follow(host: UpgradeHost, io: ScreenIO, id: string, schedu
|
|
|
144
155
|
// A hub that is not ready and names no cause (one older than #272 lists only busy peers and approvals) still waits.
|
|
145
156
|
const held = Object.values(live).some((i) => i?.recovery?.operationId === id && i.recovery.ready === false);
|
|
146
157
|
const now = [...new Set(Object.values(live).flatMap((i) => i?.recovery?.waiting ?? []))].join(", ") || (held ? "the hub is not quiet yet (it names no cause: a completion check, a task command or a Pi call may be in flight)" : "");
|
|
147
|
-
if (now && now !== waiting
|
|
158
|
+
if (now && (now !== waiting || io.presentation && clock(io) - lastWaitAt >= 60_000)) {
|
|
159
|
+
lastWaitAt = clock(io);
|
|
160
|
+
if (io.presentation) emit(io, renderWait(op, step, Object.values(live).flatMap(value => value?.recovery?.waiting ?? []).length ? Object.values(live).flatMap(value => value?.recovery?.waiting ?? []) : [now], io.presentation));
|
|
161
|
+
else human(io, ` waiting for: ${now}`);
|
|
162
|
+
}
|
|
148
163
|
waiting = now;
|
|
149
164
|
}
|
|
165
|
+
if (io.presentation && !unwritten && !/^(re)?prepare:/.test(step) && op.waits?.[step] && !op.waits[step]!.waitEndedAt && tick % 8 === 0) {
|
|
166
|
+
const reason = "waiting for the recorded native session to attach";
|
|
167
|
+
if (waiting !== reason || clock(io) - lastWaitAt >= 60_000) {
|
|
168
|
+
emit(io, renderWait(op, step, [reason], io.presentation));
|
|
169
|
+
waiting = reason; lastWaitAt = clock(io);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
150
172
|
await io.sleep(250);
|
|
151
173
|
}
|
|
152
174
|
}
|
|
@@ -163,22 +185,22 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
|
|
|
163
185
|
const of = (kind: string) => endable.filter((e) => peerKind(e.planned, e.peer) === kind);
|
|
164
186
|
const tui = of("tui"), headless = of("headless");
|
|
165
187
|
const choices = [...(tui.length ? [`[t] the TUI agents (${tui.map(label).join(", ")})`] : []), ...(headless.length ? [`[h] the headless agents (${headless.map(label).join(", ")})`] : []), "or names separated by spaces", "[Enter] none"];
|
|
166
|
-
const words = new Set(((await
|
|
188
|
+
const words = new Set(((await ask(io, `End which agents? ${choices.join(" ")}: `)) ?? "").toLowerCase().split(/\s+/).filter(Boolean));
|
|
167
189
|
const picked = new Set<Agent>(), unknown: string[] = [], ambiguous: string[] = [];
|
|
168
190
|
for (const word of words) {
|
|
169
191
|
const named = word === "t" ? tui : word === "h" ? headless : endable.filter((e) => word === full(e) || word === e.peer.id);
|
|
170
192
|
if (word === "t" || word === "h" || named.length === 1) for (const e of named) picked.add(e);
|
|
171
193
|
else (named.length ? ambiguous : unknown).push(word);
|
|
172
194
|
}
|
|
173
|
-
if (unknown.length) io
|
|
174
|
-
if (ambiguous.length) io
|
|
195
|
+
if (unknown.length) human(io, `not an agent that can be ended here: ${unknown.join(", ")}`);
|
|
196
|
+
if (ambiguous.length) human(io, `${ambiguous.join(", ")}: attached in several projects; name the one to end with its project (${endable.filter((e) => ambiguous.includes(e.peer.id)).map(full).join(", ")})`);
|
|
175
197
|
const chosen = endable.filter((e) => picked.has(e));
|
|
176
|
-
if (!chosen.length) { io
|
|
177
|
-
if (!(await yes(io, `End ${chosen.map(label).join(", ")} now? A TUI agent's terminal is closed, a turn in progress is cut, and none of them is restored by the upgrade. [y/N] `))) { io
|
|
198
|
+
if (!chosen.length) { human(io, "no agent was ended"); return false; }
|
|
199
|
+
if (!(await yes(io, `End ${chosen.map(label).join(", ")} now? A TUI agent's terminal is closed, a turn in progress is cut, and none of them is restored by the upgrade. [y/N] `))) { human(io, "no agent was ended"); return false; }
|
|
178
200
|
let asked = 0;
|
|
179
201
|
for (const e of chosen) {
|
|
180
|
-
if (io.interrupted()) { io
|
|
181
|
-
io
|
|
202
|
+
if (io.interrupted()) { human(io, `interrupted: ${chosen.length - asked} of ${chosen.length} left alone (${chosen.slice(asked).map(label).join(", ")})`); break; }
|
|
203
|
+
human(io, ` ${await host.endPeer(e.planned, e.peer)}`);
|
|
182
204
|
asked++;
|
|
183
205
|
}
|
|
184
206
|
return asked > 0;
|
|
@@ -186,18 +208,19 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
|
|
|
186
208
|
|
|
187
209
|
/** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
|
|
188
210
|
export async function resetFlow(host: UpgradeHost, io: ScreenIO, projects: { id: string; root: string }[]): Promise<void> {
|
|
189
|
-
if (host.lock()) return io
|
|
211
|
+
if (host.lock()) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
|
|
190
212
|
let project = projects[0];
|
|
191
213
|
if (projects.length > 1) {
|
|
192
|
-
projects.forEach((p, i) => io
|
|
193
|
-
project = projects[Number(await
|
|
214
|
+
projects.forEach((p, i) => human(io, ` ${i + 1} ${p.id} ${p.root}`));
|
|
215
|
+
project = projects[Number(await ask(io, "Reset which project? [number, Enter: none] ")) - 1];
|
|
194
216
|
}
|
|
195
|
-
if (!project) return io
|
|
196
|
-
const scope = ((await
|
|
197
|
-
if (scope !== "r" && scope !== "a") return io
|
|
217
|
+
if (!project) return human(io, "nothing was reset");
|
|
218
|
+
const scope = ((await ask(io, "[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
|
|
219
|
+
if (scope !== "r" && scope !== "a") return human(io, "nothing was reset");
|
|
198
220
|
const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
|
|
199
221
|
if ((await io.run(reset)) !== 0) return; // the dry run: it lists and changes nothing
|
|
200
|
-
|
|
222
|
+
const consequence = scope === "a" ? "archive this project's hub state" : "reset delivery holds, pauses and session pointers";
|
|
223
|
+
if (!(await yes(io, io.presentation ? `Reset ${project.id}: ${consequence}? [y/N] ` : `Apply this reset of ${project.id}? [y/N] `))) return human(io, "nothing was reset");
|
|
201
224
|
await io.run([...reset, "--yes"]);
|
|
202
225
|
}
|
|
203
226
|
|
|
@@ -206,44 +229,54 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
|
|
|
206
229
|
for (let acted = false, reset = false; ;) {
|
|
207
230
|
if (io.interrupted()) return "quit";
|
|
208
231
|
const op = host.read(id), runner = host.runner(id);
|
|
209
|
-
if (op.phase === "completed") { io.
|
|
232
|
+
if (op.phase === "completed") { if (io.presentation) await summary(host, io, op); else human(io, `${op.plan.kind} to ${op.plan.version} completed`); return "completed"; }
|
|
210
233
|
if (op.phase === "cancelled") {
|
|
211
|
-
io.
|
|
234
|
+
if (io.presentation) await summary(host, io, op);
|
|
235
|
+
else human(io, `operation ${id} is ${op.disposition ? "ended (abandoned, not completed)" : "cancelled"}; the recovery lock is free`);
|
|
212
236
|
// Ended from this screen: the reset the person may have come for is one key away.
|
|
213
237
|
if (acted && !reset && await yes(io, "Reset a project's hub now? [y/N] ")) await resetFlow(host, io, op.plan.projects.map((p) => p.project));
|
|
214
238
|
return "ended";
|
|
215
239
|
}
|
|
216
240
|
const live = runner ? {} : await host.live(op);
|
|
217
|
-
io
|
|
218
|
-
|
|
219
|
-
|
|
241
|
+
human(io, "");
|
|
242
|
+
if (io.presentation) emit(io, renderOperation(op, runner, io.presentation));
|
|
243
|
+
else for (const line of operationLines(op, runner, Date.now())) human(io, line);
|
|
244
|
+
const run = async (action: "resume" | "abort" | "dispose" | "reattach", flags: string[] = []) => { acted = true; return (await io.run(recoveryArgv(op, action, flags))) === 0; };
|
|
220
245
|
// A runner scheduled by a command has not written yet: follow from the receipt as that command left it (resume
|
|
221
246
|
// writes nothing, a fresh-session choice records itself first).
|
|
222
247
|
const followed = async (): Promise<boolean> => (await follow(host, io, id, host.read(id).updatedAt)) === "left";
|
|
223
248
|
const end = async (): Promise<boolean> => {
|
|
224
|
-
if (!(await yes(io, "End this operation? Its own targets are stopped and the upgrade is abandoned, not completed. [y/N] "))) return false;
|
|
225
|
-
const reason = await
|
|
249
|
+
if (!(await yes(io, io.presentation ? `Stop this operation's ${op.plan.version} targets? The upgrade is abandoned, not completed; sessions remain recorded. [y/N] ` : "End this operation? Its own targets are stopped and the upgrade is abandoned, not completed. [y/N] "))) return false;
|
|
250
|
+
const reason = await ask(io, "Reason for the audit [Enter: ended from the upgrade screen]: ");
|
|
226
251
|
if (reason === null) return false; // Ctrl+C or the end of input is never a reason
|
|
227
252
|
return run("dispose", ["--stop-and-archive", "--reason", reason.slice(0, 500) || "ended from the upgrade screen"]);
|
|
228
253
|
};
|
|
229
254
|
// An act returns true when the person left (Ctrl+C in a follow).
|
|
230
|
-
const menu = new Map<string, { label: string; act: () => Promise<boolean | void> }>();
|
|
255
|
+
const menu = new Map<string, { label: string; disabledReason?: string; act: () => Promise<boolean | void> }>();
|
|
231
256
|
if (typeof runner === "number") {
|
|
232
257
|
menu.set("w", { label: "follow its progress", act: async () => (await follow(host, io, id)) === "left" });
|
|
233
258
|
if (cancellableWait(op) && host.runnerStoppable(id)) menu.set("c", { label: "cancel: stop the waiting runner and cancel (nothing was closed or stopped)", act: async () => {
|
|
234
259
|
// Decided again on the receipt as it is now: the runner may have moved on while the menu was read.
|
|
235
|
-
if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return io
|
|
260
|
+
if (!cancellableWait(host.read(id)) || !host.runnerStoppable(id)) return human(io, "the runner moved on; nothing was stopped or cancelled");
|
|
236
261
|
if (await host.stopRunner(id)) await run("abort");
|
|
237
|
-
else io
|
|
262
|
+
else human(io, "the runner could not be stopped; nothing was cancelled");
|
|
238
263
|
} });
|
|
239
|
-
else if (cancellableWait(op)) io
|
|
264
|
+
else if (cancellableWait(op)) human(io, " cancel is not offered: this runner's claim cannot be verified (an older coordinator's, or unreadable); a live runner gives up its wait after 10 minutes");
|
|
240
265
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
266
|
+
const choices = nextChoices(op, runner, live);
|
|
267
|
+
if (io.presentation && op.phase === "blocked") emit(io, renderBlocked(op, choices, io.presentation));
|
|
268
|
+
for (const choice of choices) {
|
|
269
|
+
if (choice.kind === "wait") { if (typeof runner !== "number") {
|
|
270
|
+
if (io.presentation && op.phase !== "blocked") human(io, choiceInstruction(op, choice, io.presentation));
|
|
271
|
+
if (io.presentation) menu.set("w", { label: "wait for the current recovery condition", disabledReason: runner === "unknown" ? "runner ownership is unknown; refresh to read its claim" : "no verified runner is working; refresh after the reported condition changes", act: async () => {} });
|
|
272
|
+
else human(io, ` ${choice.text}`);
|
|
273
|
+
} }
|
|
274
|
+
else if (choice.kind === "resume") menu.set("r", { label: io.presentation ? op.error ? "resume after reviewing the recorded failure" : "resume from the recorded step" : `resume${op.error ? " (after the step the error names)" : ""}`, act: async () => (await run("resume")) && followed() });
|
|
244
275
|
else if (choice.kind === "abort") menu.set("c", { label: "cancel (no runtime was stopped; nothing to roll back)", act: async () => { await run("abort"); } });
|
|
276
|
+
else if (choice.kind === "reattach") menu.set("a", { label: `reattach ${choice.peer} with its recorded session only (then resume to finish)`, act: async () => { await run("reattach", [choice.peer!]); } });
|
|
245
277
|
else if (choice.kind === "fresh") menu.set(menu.has("f") ? `f-${choice.peer}` : "f", { label: `start ${choice.peer} as a new session (its conversation is recorded as lost)`, act: async () => {
|
|
246
|
-
|
|
278
|
+
if (io.presentation && !(await yes(io, `Start ${choice.peer} fresh? Its recorded conversation is marked lost. [y/N] `))) return;
|
|
279
|
+
const reason = await ask(io, `Reason for losing ${choice.peer}'s conversation [Enter: go back]: `);
|
|
247
280
|
return !!reason && (await run("dispose", ["--fresh-session", choice.peer!, "--reason", reason.slice(0, 500)])) && followed();
|
|
248
281
|
} });
|
|
249
282
|
else {
|
|
@@ -253,11 +286,17 @@ export async function operationScreen(host: UpgradeHost, io: ScreenIO, id: strin
|
|
|
253
286
|
}
|
|
254
287
|
menu.set("s", { label: "refresh", act: async () => {} });
|
|
255
288
|
menu.set("j", { label: "receipt as JSON", act: async () => io.out(JSON.stringify(publicOperation(op, runner, live), null, 2)) });
|
|
256
|
-
|
|
257
|
-
io.
|
|
258
|
-
|
|
289
|
+
const preferred = [typeof runner === "number" ? "w" : runner === "unknown" ? "s" : "j", "s"].find(key => menu.has(key) && !menu.get(key)!.disabledReason);
|
|
290
|
+
if (io.presentation) {
|
|
291
|
+
const items: MenuPresentationItem[] = [...menu].map(([key, item]) => ({ key, label: item.label, ...(item.disabledReason ? { disabledReason: item.disabledReason } : {}), ...(key === preferred ? { recommended: true } : {}), ...(["e", "x"].includes(key) || key === "f" || key.startsWith("f-") ? { destructive: true } : {}) }));
|
|
292
|
+
emit(io, renderMenu(items, io.presentation));
|
|
293
|
+
} else for (const [key, item] of menu) human(io, ` [${key}] ${item.label}`);
|
|
294
|
+
human(io, " [q] quit (the operation stays as it is)");
|
|
295
|
+
const answer = await ask(io, "> ");
|
|
259
296
|
if (answer === null || answer.toLowerCase() === "q") return "quit";
|
|
260
|
-
|
|
297
|
+
const selected = menu.get(io.presentation && answer === "" ? preferred ?? "s" : answer.toLowerCase());
|
|
298
|
+
if (selected?.disabledReason) { human(io, `disabled: ${selected.disabledReason}`); continue; }
|
|
299
|
+
if ((await selected?.act()) === true) return "quit";
|
|
261
300
|
}
|
|
262
301
|
}
|
|
263
302
|
|
|
@@ -273,24 +312,39 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
|
|
|
273
312
|
}
|
|
274
313
|
const plan = await host.plan();
|
|
275
314
|
if (io.interrupted()) return;
|
|
276
|
-
io
|
|
277
|
-
|
|
315
|
+
human(io, "");
|
|
316
|
+
if (io.presentation) emit(io, renderPlan(plan, host.version, io.presentation, planLines(plan, host.version)));
|
|
317
|
+
else for (const line of planLines(plan, host.version)) human(io, line);
|
|
278
318
|
const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
|
|
279
|
-
if (!plan.projects.length) io
|
|
319
|
+
if (!plan.projects.length) human(io, plan.kind === "upgrade" ? `no hub is running, so nothing is carried over: install with \`bun add -g @staix/agent-hub@${plan.version}\`, then \`ahub setup\`` : "this project's hub is not running: `ahub up` starts it");
|
|
280
320
|
const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
|
|
281
321
|
const keys = [...(blocked ? [] : ["[a] apply"]), "[r] refresh", ...(endable.length ? ["[k] end agents"] : []), "[j] plan as JSON", ...(plan.projects.length ? ["[x] reset a project's hub"] : []), "[q] quit"];
|
|
282
|
-
const
|
|
283
|
-
if (
|
|
322
|
+
const preferred = "r"; // Enter never closes terminals, stops a hub, resumes or reattaches implicitly.
|
|
323
|
+
if (io.presentation) {
|
|
324
|
+
if (blocked) human(io, "Blocked: take the next action each blocker names, then refresh.");
|
|
325
|
+
const items: MenuPresentationItem[] = [
|
|
326
|
+
...(!blocked ? [{ key: "a", label: "apply this reviewed plan" }] : []),
|
|
327
|
+
{ key: "r", label: "refresh the plan", recommended: preferred === "r" },
|
|
328
|
+
...(endable.length ? [{ key: "k", label: "end selected agents before upgrading", destructive: true }] : []),
|
|
329
|
+
{ key: "j", label: "plan as JSON" },
|
|
330
|
+
...(plan.projects.length ? [{ key: "x", label: "reset a project's hub", destructive: true }] : []),
|
|
331
|
+
{ key: "q", label: "quit" },
|
|
332
|
+
];
|
|
333
|
+
emit(io, renderMenu(items, io.presentation));
|
|
334
|
+
}
|
|
335
|
+
const entered = (await ask(io, io.presentation ? "> " : `${blocked ? "Blocked: take the next action each blocker names, then refresh.\n" : ""}${keys.join(" ")}: `))?.toLowerCase();
|
|
336
|
+
const answer = entered === undefined ? "q" : io.presentation && entered === "" ? preferred : entered;
|
|
337
|
+
if (answer === "q") return touched ? undefined : human(io, "nothing was changed");
|
|
284
338
|
if (answer === "j") io.out(JSON.stringify(plan, null, 2));
|
|
285
339
|
else if (answer === "x" && plan.projects.length) { touched = true; await resetFlow(host, io, plan.projects.map((p) => p.project)); }
|
|
286
340
|
else if (answer === "k" && endable.length) touched = (await endAgents(host, io, endable)) || touched;
|
|
287
341
|
else if (answer === "a" && !blocked) {
|
|
288
342
|
touched = true;
|
|
289
343
|
let op: RecoveryOperation;
|
|
290
|
-
try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { io
|
|
291
|
-
io
|
|
344
|
+
try { op = await host.apply(plan, () => io.interrupted()); } catch (error) { human(io, `ahub: ${(error as Error).message}`); continue; }
|
|
345
|
+
human(io, `operation ${op.id} started; Enter opens its menu, Ctrl+C leaves, and neither stops the ${plan.kind}`);
|
|
292
346
|
const end = await follow(host, io, op.id);
|
|
293
|
-
if (end === "completed")
|
|
347
|
+
if (end === "completed") { if (io.presentation) await summary(host, io, host.read(op.id)); else human(io, `${plan.kind} to ${plan.version} completed`); return; }
|
|
294
348
|
if (end === "left") return;
|
|
295
349
|
// Blocked, cancelled or left to its runner: the lock check above opens its screen, or plans again once it is free.
|
|
296
350
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
2
|
+
import type { RecoveryOperation } from "./upgrade.ts";
|
|
3
|
+
|
|
4
|
+
const STEPS: Record<string, string> = {
|
|
5
|
+
stage: "staging the release", prepare: "holding deliveries and waiting until the hub is quiet", reprepare: "preparing the source again after an expired hold",
|
|
6
|
+
commit: "closing terminals and stopping the old hub", restart: "restarting a target that stopped", start: "starting the new hub", restore: "restoring Codex and Pi sessions",
|
|
7
|
+
"install-plugin": "installing the Claude plugin", "restore-claude": "restoring the Claude session and verifying the hub", release: "releasing held deliveries",
|
|
8
|
+
"refresh-manager": "refreshing the dashboard manager", "install-global": "installing the global CLI", completed: "completed", cancelled: "cancelled",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** A receipt step (`commit:<project>`) in a person's words; a step this release does not know is shown as it is. */
|
|
12
|
+
export function stepLabel(step: string): string {
|
|
13
|
+
const at = step.indexOf(":"), name = at < 0 ? step : step.slice(0, at);
|
|
14
|
+
return STEPS[name] ? `${at < 0 ? "" : `${step.slice(at + 1)}: `}${STEPS[name]}` : step;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ago = (ms: number): string => ms < 90_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 5_400_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3_600_000)}h`;
|
|
18
|
+
|
|
19
|
+
export function operationLines(op: RecoveryOperation, runner: number | "unknown" | undefined, now: number): string[] {
|
|
20
|
+
const held = runner === "unknown" ? "; whether a runner holds it cannot be read" : runner ? `; runner ${runner} is working` : op.phase === "running" ? "; no runner holds it (the last one stopped mid-step)" : "";
|
|
21
|
+
const lines = [`${op.plan.kind} to ${op.plan.version}: operation ${op.id}`, ` ${op.phase} at "${stepLabel(op.step)}", updated ${ago(now - op.updatedAt)} ago${held}`];
|
|
22
|
+
for (const p of op.projects) {
|
|
23
|
+
const effects = Object.entries(p.terminals).map(([key, value]) => key.startsWith("restored:") ? `${key} ${restoreReceiptStatus(value)}` : value === "pending" || value === "failed" ? `${key} ${value}` : key);
|
|
24
|
+
lines.push(` ${p.id} ${p.phase}${effects.length ? ` (${effects.join(", ")})` : ""}${p.restarts?.length ? ` restarts ${p.restarts.length}` : ""}`);
|
|
25
|
+
}
|
|
26
|
+
// The error ends with the commands status would print; the menu below offers them instead.
|
|
27
|
+
if (op.error) lines.push(` error: ${op.error.split("; next actions: ")[0]}`);
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Read-only observations of a coordinator wait, never input to its deadline or decisions. */
|
|
2
|
+
export interface RecoveryWaitTiming {
|
|
3
|
+
readonly waitStartedAt: string;
|
|
4
|
+
readonly deadlineAt: string;
|
|
5
|
+
readonly waitEndedAt?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RecoveryPreservationCheck = "queues" | "manualPaused" | "boardDigest" | "budgetDigest";
|
|
9
|
+
|
|
10
|
+
export interface RecoveryPreservationCause {
|
|
11
|
+
readonly kind: "preservation";
|
|
12
|
+
readonly check: RecoveryPreservationCheck;
|
|
13
|
+
readonly projectId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecoveryNativeCause {
|
|
17
|
+
readonly kind: "native_recovery";
|
|
18
|
+
readonly reason: "terminal_unverified" | "session_changed" | "launcher_unverified" | "target_unavailable" | "attachment_timeout";
|
|
19
|
+
readonly projectId: string;
|
|
20
|
+
readonly peer: string;
|
|
21
|
+
readonly handle?: string;
|
|
22
|
+
readonly expectedSession?: string;
|
|
23
|
+
readonly observedSession?: string;
|
|
24
|
+
readonly targetState?: string;
|
|
25
|
+
readonly launcherState?: string;
|
|
26
|
+
readonly timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type RecoveryPresentationCause = RecoveryPreservationCause | RecoveryNativeCause;
|
|
30
|
+
|
|
31
|
+
/** Only a guard with observed evidence constructs this error; messages are never classification input. */
|
|
32
|
+
export class RecoveryPresentationError extends Error {
|
|
33
|
+
constructor(message: string, readonly presentationCause: RecoveryPresentationCause) { super(message); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The failing integrity comparison supplies the cause; presentation never parses the error text. */
|
|
37
|
+
export class RecoveryPreservationError extends RecoveryPresentationError {
|
|
38
|
+
constructor(check: RecoveryPreservationCheck, projectId?: string) {
|
|
39
|
+
super("queue, manual pause, task board or budget preservation was not verified", { kind: "preservation", check, ...(projectId === undefined ? {} : { projectId }) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { recoveryPresentationFlag } from "./recovery-presentation-options.ts";
|
|
2
|
+
import { resolveColor } from "./console-state.ts";
|
|
3
|
+
import type { RecoveryPresentationOptions } from "./upgrade-presentation.ts";
|
|
4
|
+
|
|
5
|
+
/** Presentation flags never consume another option's audit, version or peer value. */
|
|
6
|
+
export function readRecoveryPresentationFlags(
|
|
7
|
+
argv: readonly string[],
|
|
8
|
+
terminal: { isTTY: boolean; columns?: number; TERM?: string; NO_COLOR?: string },
|
|
9
|
+
now: () => number = Date.now,
|
|
10
|
+
): { args: string[]; flags: string[]; json: boolean; options: RecoveryPresentationOptions } {
|
|
11
|
+
const args: string[] = [], flags: string[] = [];
|
|
12
|
+
let color: string | undefined, json = false, verbose = false;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i]!, flag = recoveryPresentationFlag(arg);
|
|
15
|
+
if (["--reason", "--fresh-session", "--to"].includes(arg)) {
|
|
16
|
+
args.push(arg);
|
|
17
|
+
if (i + 1 < argv.length) args.push(argv[++i]!);
|
|
18
|
+
} else if (arg === "--color") {
|
|
19
|
+
throw new Error("usage: ahub <command> [--color=auto|always|never]");
|
|
20
|
+
} else if (flag?.kind === "color") {
|
|
21
|
+
if (color !== undefined) throw new Error("usage: ahub <command> [--color=auto|always|never]");
|
|
22
|
+
color = flag.value; flags.push(arg);
|
|
23
|
+
} else if (flag?.kind === "json") { json = true; flags.push(arg); }
|
|
24
|
+
else if (flag?.kind === "verbose") { verbose = true; flags.push(arg); }
|
|
25
|
+
else args.push(arg);
|
|
26
|
+
}
|
|
27
|
+
const resolved = resolveColor(color, terminal);
|
|
28
|
+
if (resolved instanceof Error) throw resolved;
|
|
29
|
+
const columns = terminal.columns !== undefined && Number.isFinite(terminal.columns) && terminal.columns > 0 ? Math.floor(terminal.columns) : 80;
|
|
30
|
+
return { args, flags, json, options: { columns, color: resolved, verbose, now } };
|
|
31
|
+
}
|