@staix/agent-hub 0.12.20 → 0.12.22
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 +32 -0
- package/README.md +17 -9
- package/docs/agent-notes/adapters.md +13 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +7 -3
- package/docs/agent-notes/daemon.md +12 -2
- package/docs/agent-notes/local-worker.md +5 -1
- package/docs/agent-notes/tasks.md +4 -2
- package/docs/agent-notes/tests.md +5 -2
- package/docs/bench.md +143 -0
- package/docs/events.md +15 -5
- package/docs/operations.md +510 -54
- package/docs/quickstart.md +16 -11
- package/docs/research.md +92 -0
- package/docs/security.md +49 -11
- package/docs/smoke.md +138 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
- package/docs/verified.json +34 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -2
- package/src/adapters/acp.ts +77 -10
- package/src/adapters/codex-appserver.ts +72 -2
- package/src/adapters/local-worker.ts +46 -11
- package/src/adapters/pi.ts +215 -22
- package/src/cli/bench.ts +287 -0
- package/src/cli/console-state.ts +226 -28
- package/src/cli/console.ts +16 -4
- package/src/cli/facts-hook.ts +50 -8
- package/src/cli/help.ts +16 -5
- package/src/cli/identity.ts +10 -4
- package/src/cli/launch.ts +272 -18
- package/src/cli/main.ts +290 -30
- package/src/cli/preview.ts +13 -3
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/status-lines.ts +2 -1
- package/src/cli/terminal-recovery.ts +83 -31
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +123 -20
- package/src/cli/upgrade.ts +108 -19
- package/src/hub/bench.ts +211 -0
- package/src/hub/bus.ts +24 -7
- package/src/hub/conductor.ts +22 -21
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +477 -86
- package/src/hub/delivery-journal.ts +6 -6
- package/src/hub/events.ts +4 -2
- package/src/hub/lifecycle.ts +8 -13
- package/src/hub/manager.ts +16 -14
- package/src/hub/peers.ts +2 -2
- package/src/hub/permission-mode.ts +44 -0
- package/src/hub/recovery-store.ts +59 -9
- package/src/hub/registry.ts +36 -22
- package/src/hub/research.ts +255 -0
- package/src/hub/tasks.ts +99 -55
- package/src/hub/tool-refusal.ts +7 -0
- package/src/hub/ui.ts +4 -1
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +34 -11
- package/src/pi/extension.ts +43 -20
- package/src/pi/process-signature.ts +23 -0
- package/src/pi/tool-receipts.ts +24 -2
- package/src/ui/index.html +98 -17
- package/src/ui/task-progress.ts +33 -0
|
@@ -2,7 +2,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { processSignature } from "../pi/process-signature.ts";
|
|
5
|
+
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
6
6
|
import { atomicPrivateJSON } from "../hub/recovery-store.ts";
|
|
7
7
|
|
|
8
8
|
/** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
|
|
@@ -130,6 +130,12 @@ export interface TerminalRecoveryOptions {
|
|
|
130
130
|
instanceId?: string;
|
|
131
131
|
/** Injectable process-start identity used to reject stale or reused launcher PIDs. */
|
|
132
132
|
processIdentity?: ProcessIdentity;
|
|
133
|
+
/**
|
|
134
|
+
* #228: the identity as releases up to 0.12.12 computed it (unpinned `ps`, the reader's TZ and locale). It defaults
|
|
135
|
+
* to that read only when `processIdentity` is the default, so an injected identity is never second-guessed by a
|
|
136
|
+
* real `ps` read of the same pid.
|
|
137
|
+
*/
|
|
138
|
+
legacyIdentity?: ProcessIdentity;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
export interface RecordedTerminalLaunch {
|
|
@@ -233,6 +239,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
|
|
|
233
239
|
stateDir: config.stateDir ?? "",
|
|
234
240
|
instanceId: config.instanceId ?? "",
|
|
235
241
|
processIdentity: config.processIdentity ?? defaultProcessIdentity,
|
|
242
|
+
legacyIdentity: config.legacyIdentity ?? (config.processIdentity ? () => undefined : unpinnedProcessIdentity),
|
|
236
243
|
};
|
|
237
244
|
}
|
|
238
245
|
|
|
@@ -272,22 +279,40 @@ export function recordPath(stateDir: string): string {
|
|
|
272
279
|
return join(stateDir, RECORD_FILE);
|
|
273
280
|
}
|
|
274
281
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
function validRecord(item: unknown): item is RecordedTerminalLaunch {
|
|
283
|
+
const row = object(item);
|
|
284
|
+
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none".
|
|
289
|
+
* #228: a row that fails validation is kept apart (`invalid`), never dropped: whatever it would answer is unknown, and
|
|
290
|
+
* `all` is the file as read, so a writer puts every such row back unchanged.
|
|
291
|
+
*/
|
|
292
|
+
type LaunchRecords = { rows: RecordedTerminalLaunch[]; invalid: unknown[]; all: unknown[] };
|
|
293
|
+
function launchRecords(stateDir: string): LaunchRecords | "unreadable" {
|
|
294
|
+
const none = { rows: [], invalid: [], all: [] };
|
|
295
|
+
if (!stateDir || !existsSync(recordPath(stateDir))) return none;
|
|
278
296
|
try {
|
|
279
297
|
const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
|
|
280
298
|
if (!Array.isArray(value)) return "unreadable";
|
|
281
|
-
return value.filter((item)
|
|
282
|
-
const row = object(item);
|
|
283
|
-
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
284
|
-
});
|
|
299
|
+
return { rows: value.filter(validRecord), invalid: value.filter((item) => !validRecord(item)), all: value };
|
|
285
300
|
} catch (error) {
|
|
286
|
-
return (error as NodeJS.ErrnoException).code === "ENOENT" ?
|
|
301
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? none : "unreadable";
|
|
287
302
|
}
|
|
288
303
|
}
|
|
289
304
|
|
|
290
|
-
|
|
305
|
+
/**
|
|
306
|
+
* #228: whether a row that cannot be evaluated may answer a question about this peer, project and instance: it does
|
|
307
|
+
* unless one of its fields is a readable string that names another one (a row for another peer or an older instance).
|
|
308
|
+
*/
|
|
309
|
+
function concerns(raw: unknown, query: { peer?: string; projectRoot: string; instanceId: string }): boolean {
|
|
310
|
+
const row = object(raw);
|
|
311
|
+
const other = (value: unknown, wanted?: string) => wanted !== undefined && typeof value === "string" && value !== wanted;
|
|
312
|
+
return !other(row.peer, query.peer) && !other(row.projectRoot, query.projectRoot) && !other(row.instanceId, query.instanceId);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function writeLaunchRecords(stateDir: string, rows: unknown[]): void {
|
|
291
316
|
mkdirSync(stateDir, { recursive: true });
|
|
292
317
|
atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
|
|
293
318
|
}
|
|
@@ -297,30 +322,50 @@ function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): v
|
|
|
297
322
|
* process holds it now; unknown when the pid exists but its identity cannot be read. Unknown is never gone.
|
|
298
323
|
*/
|
|
299
324
|
export type LauncherState = "live" | "gone" | "unknown";
|
|
300
|
-
async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity): Promise<LauncherState> {
|
|
301
|
-
let
|
|
302
|
-
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
325
|
+
async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<LauncherState> {
|
|
326
|
+
let current: string | undefined;
|
|
327
|
+
try { current = await identity(row.launcherPid) || undefined; } catch { /* unreadable: decided by the pid probe */ }
|
|
328
|
+
// #228: a launcher recorded by 0.12.12 or older hashed `ps` in its own TZ and locale (#177 pinned it in 0.12.13). Read
|
|
329
|
+
// the same unpinned way in this reader's environment, a match is that launcher; anything else stays gone (pid reuse).
|
|
330
|
+
if (current && current !== row.launcherSignature) {
|
|
331
|
+
let old: string | undefined;
|
|
332
|
+
try { old = await legacy(row.launcherPid) || undefined; } catch { /* no legacy reading: the pinned one decides */ }
|
|
333
|
+
if (old === row.launcherSignature) return "live";
|
|
334
|
+
}
|
|
335
|
+
return processLiveness(row.launcherPid, row.launcherSignature, () => current);
|
|
306
336
|
}
|
|
307
337
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
338
|
+
/** The launcher signature as releases up to 0.12.12 computed it: `ps` in the caller's own TZ and locale, unpinned. */
|
|
339
|
+
function unpinnedProcessIdentity(pid: number): string | undefined {
|
|
340
|
+
try {
|
|
341
|
+
const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
|
|
342
|
+
const text = result.exitCode === 0 ? result.stdout.toString().trim() : "";
|
|
343
|
+
return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined;
|
|
344
|
+
} catch { return undefined; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* This instance's launch records whose launcher is live, those whose launcher cannot be identified (never gone), and
|
|
349
|
+
* the rows that cannot be evaluated and may be this project's and instance's (#228: unknown for their peer).
|
|
350
|
+
*/
|
|
351
|
+
async function recordsByState(records: LaunchRecords, projectRoot: string, instanceId: string, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[]; invalid: unknown[] }> {
|
|
352
|
+
if (!instanceId) return { live: [], unknown: [], invalid: [] };
|
|
353
|
+
const candidates = records.rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
|
|
354
|
+
const states = await Promise.all(candidates.map((item) => launcherState(item, identity, legacy)));
|
|
355
|
+
return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown"),
|
|
356
|
+
invalid: records.invalid.filter((raw) => concerns(raw, { projectRoot, instanceId })) };
|
|
314
357
|
}
|
|
315
358
|
|
|
316
359
|
/** #215: the launcher recorded for `peer` on this instance (one row per peer and instance) and its state; managed means live. */
|
|
317
|
-
export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState } | undefined> {
|
|
360
|
+
export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState; invalidRow?: true } | undefined> {
|
|
318
361
|
const config = normalizeOptions(options);
|
|
319
362
|
if (!config.stateDir || !config.instanceId) return undefined;
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
363
|
+
const records = launchRecords(config.stateDir);
|
|
364
|
+
if (records === "unreadable") return { state: "unknown" };
|
|
365
|
+
// #228: a row that cannot be evaluated and may be this launcher's is unknown, never "no launcher".
|
|
366
|
+
if (records.invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) return { state: "unknown", invalidRow: true };
|
|
367
|
+
const record = records.rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
|
|
368
|
+
return record ? { record, state: await launcherState(record, config.processIdentity, config.legacyIdentity) } : undefined;
|
|
324
369
|
}
|
|
325
370
|
|
|
326
371
|
/**
|
|
@@ -353,7 +398,8 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
353
398
|
if (existing === "unreadable") {
|
|
354
399
|
throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
|
|
355
400
|
}
|
|
356
|
-
|
|
401
|
+
// #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
|
|
402
|
+
const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
|
|
357
403
|
rows.push(row);
|
|
358
404
|
writeLaunchRecords(stateDir, rows);
|
|
359
405
|
return row;
|
|
@@ -482,7 +528,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
482
528
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
483
529
|
const rows = launchRecords(config.stateDir);
|
|
484
530
|
if (rows === "unreadable") return { bindings, byPeer, blockers: [blocker("command-error", `${recordPath(config.stateDir)} cannot be read, so no terminal can be bound to its launch; inspect or move it aside, then plan or resume again`)], manualRequired: true };
|
|
485
|
-
const { live: records, unknown } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity);
|
|
531
|
+
const { live: records, unknown, invalid } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity, config.legacyIdentity);
|
|
486
532
|
|
|
487
533
|
let listed: Record<string, unknown>[];
|
|
488
534
|
try {
|
|
@@ -500,6 +546,10 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
500
546
|
blockers.push(blocker("ownership-unknown", `the ${peer} launcher recorded for terminal ${unread.handle} (pid ${unread.launcherPid}) cannot be identified, so no terminal can be bound to its launch; wait until it exits or end it, then plan or resume again`, peer, unread.handle));
|
|
501
547
|
continue;
|
|
502
548
|
}
|
|
549
|
+
if (invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) {
|
|
550
|
+
blockers.push(blocker("ownership-unknown", `${recordPath(config.stateDir)} holds a launch record that cannot be evaluated and may be this ${peer} launcher's, so no terminal can be bound to its launch; next action: inspect that row and fix it, or end that launcher and remove its row, then plan or resume again`, peer));
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
503
553
|
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
|
|
504
554
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
505
555
|
for (const listedTerminal of candidates) {
|
|
@@ -639,8 +689,10 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
639
689
|
const launches = async () => {
|
|
640
690
|
const rows = launchRecords(config.stateDir);
|
|
641
691
|
if (rows === "unreadable") return { live: undefined, gone: false, mayRun: true }; // unknown, never gone
|
|
642
|
-
|
|
643
|
-
|
|
692
|
+
// #228: a row that cannot be evaluated and may be this launch's is unknown too.
|
|
693
|
+
if (rows.invalid.some((raw) => concerns(raw, { peer: binding.peer, projectRoot: binding.projectRoot, instanceId: config.instanceId }))) return { live: undefined, gone: false, mayRun: true };
|
|
694
|
+
const possible = rows.rows.filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
|
|
695
|
+
const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity, config.legacyIdentity) })));
|
|
644
696
|
return { live: states.find((entry) => entry.state === "live")?.item, gone: states.length > 0 && states.every((entry) => entry.state === "gone"), mayRun: states.some((entry) => entry.state !== "gone") };
|
|
645
697
|
};
|
|
646
698
|
// #215: Orca types the command into a login shell that outlives it, so the terminal never exits with the launcher.
|
|
@@ -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
|
+
}
|