@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.
- package/CHANGELOG.md +33 -0
- package/README.md +29 -9
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +5 -0
- package/docs/agent-notes/daemon.md +14 -8
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/cooperbench.md +1 -1
- package/docs/gsd-collaboration-analysis.md +1 -1
- package/docs/memory.md +2 -2
- package/docs/operations.md +236 -37
- package/docs/quickstart.md +24 -5
- package/docs/security.md +42 -10
- package/docs/smoke.md +34 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
- package/docs/verification/2026-10-17-0.12.31.md +71 -0
- package/docs/verification/2026-10-17-418-probes.md +21 -0
- package/docs/verification/2026-10-18-0.12.32.md +54 -0
- package/docs/verified.json +20 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1361 -44
- package/src/adapters/acp.ts +5 -6
- package/src/adapters/claude-channel.ts +29 -11
- 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/local-worker.ts +2 -2
- package/src/adapters/pi.ts +11 -2
- package/src/adapters/registry-types.ts +174 -0
- package/src/adapters/registry.ts +36 -0
- package/src/cli/attention.ts +5 -1
- package/src/cli/console-state.ts +130 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/help.ts +3 -1
- package/src/cli/identity.ts +16 -5
- package/src/cli/jcode-mcp.ts +1 -1
- package/src/cli/launch.ts +1 -6
- package/src/cli/main.ts +208 -40
- package/src/cli/output.ts +15 -2
- 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/terminal-recovery.ts +48 -0
- package/src/cli/upgrade-interactive.ts +129 -74
- 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 +217 -0
- package/src/cli/upgrade-restore-state.ts +137 -0
- package/src/cli/upgrade-runtime.ts +430 -175
- package/src/cli/upgrade.ts +325 -49
- package/src/hub/board.ts +82 -24
- package/src/hub/bus.ts +89 -29
- package/src/hub/check-runner.ts +514 -0
- package/src/hub/child-process.ts +1 -0
- 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 +5 -1
- package/src/hub/crash.ts +8 -6
- package/src/hub/daemon.ts +931 -209
- package/src/hub/delivery-hints.ts +30 -0
- package/src/hub/delivery-journal.ts +185 -12
- package/src/hub/delivery-superseded.ts +52 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -2
- package/src/hub/host-executable.ts +11 -0
- package/src/hub/hub-tools.ts +9 -1
- package/src/hub/manager.ts +1 -1
- package/src/hub/peer-catalog.ts +13 -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/routing.ts +35 -16
- package/src/hub/sdlc.ts +5 -1
- package/src/hub/settings.ts +52 -37
- package/src/hub/settled-roles.ts +35 -0
- package/src/hub/spec-read.ts +120 -0
- package/src/hub/supervision.ts +24 -8
- package/src/hub/task-sweep.ts +7 -6
- package/src/hub/tasks.ts +199 -40
- package/src/hub/ui.ts +1 -1
- package/src/memory/brief.ts +6 -5
- package/src/memory/capture.ts +2 -1
- package/src/pi/session.ts +23 -4
- package/src/ui/index.html +35 -16
- package/src/ui/needs.ts +7 -5
- package/src/ui/office-model.ts +4 -1
- package/src/ui/task-board.ts +1 -1
- package/src/ui/task-progress.ts +6 -2
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- 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
|
-
|
|
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 =>
|
|
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
|
|
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) {
|
|
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
|
|
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
|
|
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
|
|
173
|
-
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(", ")})`);
|
|
174
197
|
const chosen = endable.filter((e) => picked.has(e));
|
|
175
|
-
if (!chosen.length) { io
|
|
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
|
|
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
|
|
180
|
-
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)}`);
|
|
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
|
|
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
|
|
192
|
-
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];
|
|
193
216
|
}
|
|
194
|
-
if (!project) return io
|
|
195
|
-
const scope = ((await
|
|
196
|
-
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");
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
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
|
|
262
|
+
else human(io, "the runner could not be stopped; nothing was cancelled");
|
|
237
263
|
} });
|
|
238
|
-
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");
|
|
239
265
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
io.
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
276
|
-
|
|
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
|
|
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
|
|
282
|
-
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");
|
|
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
|
|
290
|
-
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}`);
|
|
291
346
|
const end = await follow(host, io, op.id);
|
|
292
|
-
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; }
|
|
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
|
+
|