@staix/agent-hub 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -5
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +1 -1
  5. package/docs/agent-notes/daemon.md +6 -4
  6. package/docs/agent-notes/tasks.md +5 -4
  7. package/docs/agent-notes/tests.md +1 -0
  8. package/docs/dogfood.md +4 -0
  9. package/docs/operations.md +131 -14
  10. package/docs/quickstart.md +14 -4
  11. package/docs/security.md +51 -8
  12. package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
  13. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  14. package/docs/verified.json +28 -16
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +182 -95
  18. package/src/adapters/channel-peer.ts +4 -2
  19. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  20. package/src/adapters/definitions/qwen.ts +2 -0
  21. package/src/adapters/registry-types.ts +2 -0
  22. package/src/adapters/registry.ts +1 -0
  23. package/src/cli/conductor-recovery.ts +3 -2
  24. package/src/cli/help.ts +7 -7
  25. package/src/cli/main.ts +62 -23
  26. package/src/cli/recovery-package.ts +94 -4
  27. package/src/cli/reset.ts +14 -4
  28. package/src/cli/runtime-version-warning.ts +28 -0
  29. package/src/cli/start-agents-run.ts +125 -0
  30. package/src/cli/task-output.ts +5 -0
  31. package/src/cli/upgrade-interactive.ts +4 -4
  32. package/src/cli/upgrade-runtime.ts +105 -57
  33. package/src/cli/upgrade.ts +90 -28
  34. package/src/hub/autonomy-defaults.ts +32 -7
  35. package/src/hub/board.ts +129 -13
  36. package/src/hub/budget.ts +3 -4
  37. package/src/hub/child-process.ts +4 -3
  38. package/src/hub/conductor.ts +3 -4
  39. package/src/hub/config-apply.ts +13 -2
  40. package/src/hub/config-edit-host.ts +1 -0
  41. package/src/hub/config-tier.ts +3 -0
  42. package/src/hub/config-trust.ts +10 -2
  43. package/src/hub/control-client.ts +30 -10
  44. package/src/hub/daemon.ts +82 -17
  45. package/src/hub/db-schema.ts +143 -0
  46. package/src/hub/delivery-journal.ts +3 -4
  47. package/src/hub/execution-budget.ts +3 -4
  48. package/src/hub/folder-trigger.ts +3 -4
  49. package/src/hub/invocations.ts +3 -4
  50. package/src/hub/lifecycle.ts +87 -29
  51. package/src/hub/manager.ts +49 -33
  52. package/src/hub/orca-env.ts +19 -0
  53. package/src/hub/project-runtime-state.ts +139 -0
  54. package/src/hub/project-runtime.ts +62 -0
  55. package/src/hub/protocol-window.ts +19 -0
  56. package/src/hub/recovery-store.ts +102 -12
  57. package/src/hub/registry.ts +5 -0
  58. package/src/hub/research-experiments.ts +33 -4
  59. package/src/hub/research-runner.ts +172 -0
  60. package/src/hub/restart.ts +9 -0
  61. package/src/hub/runtime-capture-prune.ts +415 -0
  62. package/src/hub/runtime-prune-health.ts +112 -0
  63. package/src/hub/schedules.ts +3 -4
  64. package/src/hub/sdlc-merge.ts +7 -0
  65. package/src/hub/sdlc-queue.ts +13 -1
  66. package/src/hub/settings.ts +4 -0
  67. package/src/hub/snapshots.ts +3 -4
  68. package/src/hub/start-agents.ts +44 -0
  69. package/src/hub/task-approval.ts +23 -0
  70. package/src/hub/tasks.ts +133 -34
  71. package/src/hub/usage-rollup.ts +3 -4
  72. package/src/pi/tool-receipts.ts +3 -4
package/src/cli/reset.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { constants, Database } from "bun:sqlite";
2
+ import { assertHubDbCompatible, openHubDatabase } from "../hub/db-schema.ts";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -50,21 +51,30 @@ const isEnvelope = (v: unknown) => isObject(v) && typeof v.id === "string";
50
51
  /** Valid JSON of the wrong shape is damage as well: the journal refuses it on every open, so the message says so. */
51
52
  const shaped = (ok: boolean, what: string) => { if (!ok) throw new Error(`invalid delivery journal: ${what} has the wrong shape`); };
52
53
 
54
+ /** The existing quiet archive preview owns its raw URI handle and sets its one bounded timeout at open. */
55
+ function quietResetDatabase(file: string): Database {
56
+ const db = new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI);
57
+ try {
58
+ db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
59
+ return db;
60
+ } catch (error) { db.close(); throw error; }
61
+ }
62
+
53
63
  /** What a reset would act on, read from the state directory without changing it (hub.db opened read-only). */
54
64
  export function planReset(stateDir: string, projectId: string): ResetPlan {
55
65
  const plan: ResetPlan = { queued: [], needsReview: [], inFlight: [], manualHolds: [], budgetPauses: [], conductorHolds: [],
56
66
  sessionPointers: sessionPointers(stateDir).filter((name) => existsSync(join(stateDir, name))), entries: existsSync(stateDir) ? readdirSync(stateDir).length : 0 };
57
67
  const file = join(stateDir, "hub.db");
58
68
  if (!existsSync(file)) return plan;
69
+ assertHubDbCompatible(file); // A normal readonly guard sees WAL metadata before the quiet preview may use immutable.
59
70
  // A read-only open of a WAL database creates hub.db-wal and -shm when the last writer's close removed them (Linux),
60
71
  // and fails without them on macOS. Without them no writer is mid-transaction, so `immutable` reads hub.db alone and
61
72
  // writes nothing. ponytail: a -wal left without its -shm (deleted by hand) is ignored then, and commits still in
62
73
  // it go unlisted; open plainly in that corner if it ever matters. SQLITE_OPEN_URI is passed because Linux builds do
63
74
  // not parse `file:` names by default (the name then fails as a plain path).
64
75
  const quiet = !existsSync(`${file}-wal`) || !existsSync(`${file}-shm`);
65
- const db = quiet ? new Database(`file:${file.replace(/[%?#]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}?immutable=1`, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true });
76
+ const db = quiet ? quietResetDatabase(file) : openHubDatabase(file, { readonly: true });
66
77
  try {
67
- db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
68
78
  const has = (table: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
69
79
  const peers = (table: string) => has(table) ? (db.query(`SELECT peer FROM ${table} ORDER BY peer`).all() as { peer: string }[]).map((row) => row.peer) : [];
70
80
  plan.budgetPauses = peers("budget_pauses");
@@ -120,6 +130,7 @@ export function resetLines(plan: ResetPlan): string[] {
120
130
  */
121
131
  export function resetRuntime(stateDir: string, project: Project): { settled: string[]; plan: ResetPlan; stale: string[] } {
122
132
  const file = join(stateDir, "hub.db");
133
+ assertHubDbCompatible(file); // Refuse before journal settlement or any direct runtime-state mutation.
123
134
  const plan = planReset(stateDir, project.id);
124
135
  const settled: string[] = [];
125
136
  if (existsSync(file)) {
@@ -135,9 +146,8 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
135
146
  }
136
147
  bus.setManualPaused([]);
137
148
  } finally { bus.closeJournal(); }
138
- const db = new Database(file);
149
+ const db = openHubDatabase(file);
139
150
  try {
140
- db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
141
151
  db.transaction(() => {
142
152
  for (const table of ["budget_pauses", "conductor_holds"]) {
143
153
  if (db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) db.run(`DELETE FROM ${table}`);
@@ -0,0 +1,28 @@
1
+ import type { DoctorCheck } from "./output.ts";
2
+
3
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
4
+ const version = (value: unknown): string | undefined => typeof value === "string" && value.length <= 64 && VERSION.exec(value)?.[0] === value ? value : undefined;
5
+
6
+ /** Authenticated daemon status and the existing plugin inventory only. Suggestions never run an update. */
7
+ export function runtimeVersionWarnings(daemonVersion: unknown, cliVersion: unknown, pluginVersion: unknown): DoctorCheck[] {
8
+ const running = version(daemonVersion), cli = version(cliVersion), plugin = version(pluginVersion);
9
+ const rows: DoctorCheck[] = [];
10
+ const unknown = (name: string, detail: string) => rows.push({ section: "Hub", level: "unknown", name, detail });
11
+ if (!running) {
12
+ unknown("runtime version comparison", "authenticated daemon version unavailable; CLI and plugin age are unknown");
13
+ return rows;
14
+ }
15
+ let cliOlder = false;
16
+ if (!cli) unknown("CLI versus daemon", "this CLI version is unavailable; its age relative to the daemon is unknown");
17
+ else try {
18
+ cliOlder = Bun.semver.order(cli, running) < 0;
19
+ if (cliOlder) rows.push({ section: "Hub", level: "warn", name: "CLI versus daemon",
20
+ detail: `running daemon ${running} is newer than this CLI ${cli}; update separately: bun add --global @staix/agent-hub@${running}` });
21
+ } catch { unknown("CLI versus daemon", "CLI and daemon versions could not be compared"); }
22
+ if (!plugin) unknown("plugin versus daemon", "installed plugin version unavailable; run ahub setup with an updated CLI to inspect it");
23
+ else try {
24
+ if (Bun.semver.order(plugin, running) < 0) rows.push({ section: "Hub", level: "warn", name: "plugin versus daemon",
25
+ detail: `running daemon ${running} is newer than installed plugin ${plugin}; run ahub setup${cliOlder ? " after the CLI update" : " with a CLI at least as new as the daemon"}` });
26
+ } catch { unknown("plugin versus daemon", "plugin and daemon versions could not be compared"); }
27
+ return rows;
28
+ }
@@ -0,0 +1,125 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ControlClient } from "../hub/control-client.ts";
4
+ import { readAutonomyDefaults, writeMachineStartAgents } from "../hub/autonomy-defaults.ts";
5
+ import { configRefusal } from "../hub/config-trust.ts";
6
+ import { START_AGENTS_DEFAULT, parseStartAgents } from "../hub/start-agents.ts";
7
+ import { MAX_COMMAND_MS } from "./terminal-recovery.ts";
8
+
9
+ /**
10
+ * #608 (agent-hub#1040): the default agent set `ahub up` starts. Every start goes through the console `peer_start`, so it is
11
+ * `hubStart`'s start (the peer's own start mode, the terminal provider, `already` for an attached peer, the manual command for
12
+ * a refusal) and never a headless fallback. Nothing here reaches the daemon except that one message per listed peer.
13
+ */
14
+ export interface StartRequester { request(message: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, any>> }
15
+
16
+ /** One printable line per agent. A refusal carries the hub's own reason, which already names the manual command. */
17
+ export function startLine(id: string, reply: Record<string, any>): string {
18
+ if (reply.ok === true) {
19
+ if (reply.already === true) return `${id}: already attached`;
20
+ return `${id}: started${reply.mode ? ` ${reply.mode}` : ""}${reply.opened ? ` (${reply.opened})` : ""}${reply.mode === "tui" ? ", it attaches when its TUI is ready" : ""}`;
21
+ }
22
+ return `${id}: not started: ${String(reply.error ?? "the hub refused")}`;
23
+ }
24
+
25
+ /** Asks for every listed peer at once and returns the lines in list order. A refusal or a failed request never stops the others. */
26
+ export async function startDefaultAgents(client: StartRequester, agents: readonly string[]): Promise<string[]> {
27
+ return Promise.all(agents.map(async id => {
28
+ try { return startLine(id, await client.request({ t: "peer_start", peer: id }, MAX_COMMAND_MS + 5_000)); }
29
+ catch (error) { return `${id}: not started: ${(error as Error).message}`; }
30
+ }));
31
+ }
32
+
33
+ /** The list `ahub up` starts: the project's machine-local value, else this machine's `ahub setup` choice, else claude and codex. */
34
+ export function effectiveStartAgents(projectRoot: string, projectValue: readonly string[], home: string, roots: readonly string[] = []): { agents: string[]; source: "project" | "machine" | "default"; notice?: string } {
35
+ let set = false;
36
+ try {
37
+ const file = join(projectRoot, ".agenthub", "config.local.json");
38
+ if (existsSync(file)) { const doc = JSON.parse(readFileSync(file, "utf8")); set = Array.isArray(doc?.start?.agents); }
39
+ } catch { set = false; }
40
+ if (set) return { agents: [...projectValue], source: "project" };
41
+ const machine = readAutonomyDefaults(home, roots);
42
+ if (machine.startAgents) return { agents: [...machine.startAgents], source: "machine" };
43
+ return { agents: [...START_AGENTS_DEFAULT], source: "default", ...(machine.notice ? { notice: machine.notice } : {}) };
44
+ }
45
+
46
+ /** The dashboard manager's and `ahub up`'s common path: a console connection to the running hub, then the set. */
47
+ export async function startProjectAgents(project: { root: string; stateDir: string }, agents: readonly string[], connect?: () => Promise<ControlClient>): Promise<string[]> {
48
+ if (!agents.length) return [];
49
+ let client: ControlClient;
50
+ try { client = await (connect ?? (() => ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root }, 5_000)))(); }
51
+ catch (error) { return agents.map(id => `${id}: not started: the hub did not answer (${(error as Error).message})`); }
52
+ try { return await startDefaultAgents(client, agents); }
53
+ finally { client.close(); }
54
+ }
55
+
56
+ export interface SetupStartPorts {
57
+ home: string;
58
+ roots: readonly string[];
59
+ /** `--agents <a,b>`, already split from argv, or undefined. */
60
+ flag?: string;
61
+ yes: boolean;
62
+ ask?: (prompt: string) => Promise<string | null>;
63
+ print: (line: string) => void;
64
+ }
65
+ const show = (agents: readonly string[]) => agents.length ? agents.join(", ") : "none";
66
+
67
+ /**
68
+ * `ahub setup`'s one question (#608): which agents `ahub up` starts. `--agents` answers it, `--yes` takes this machine's current
69
+ * choice (or claude and codex), a terminal is asked once (an answer already on this machine is shown, not asked again), and a
70
+ * run with no terminal and neither flag keeps the default and says so. Writes only `start.agents` of `~/.agenthub/defaults.json`.
71
+ * Returns the refusal text for a bad `--agents`, else undefined.
72
+ */
73
+ export async function setupStartAgents(ports: SetupStartPorts): Promise<string | undefined> {
74
+ const held = readAutonomyDefaults(ports.home, ports.roots);
75
+ const current = held.startAgents ?? [...START_AGENTS_DEFAULT];
76
+ const save = (agents: string[]): void => {
77
+ try { writeMachineStartAgents(ports.home, agents, held.fingerprint, ports.roots); ports.print(`Agents ahub up starts: ${show(agents)} (saved in this machine's defaults; a new project inherits it at ahub init).`); }
78
+ catch { ports.print("Agents ahub up starts: not saved, because this machine's defaults.json is unsafe or changed; the default stays (claude, codex)."); }
79
+ };
80
+ if (ports.flag !== undefined) {
81
+ let agents: string[];
82
+ try { agents = parseStartAgents(ports.flag); } catch (error) { return (error as Error).message; }
83
+ save(agents); return undefined;
84
+ }
85
+ if (ports.yes) { if (held.startAgents) ports.print(`Agents ahub up starts: ${show(current)} (kept).`); else save(current); return undefined; }
86
+ if (held.startAgents) { ports.print(`Agents ahub up starts: ${show(current)} (change it with ahub setup --agents <a,b> or ahub settings set start.agents <a,b>).`); return undefined; }
87
+ if (!ports.ask) { ports.print(`Agents ahub up starts: ${show(current)} (the default; no terminal to ask in, so pass --agents <a,b> to choose).`); return undefined; }
88
+ for (let attempt = 0; attempt < 3; attempt++) {
89
+ const answer = await ports.ask(`Agents to start with ahub up [${show(current)}] (comma list, "none" for no agent): `);
90
+ if (answer === null || !answer.trim()) { save(current); return undefined; }
91
+ try { save(parseStartAgents(answer)); return undefined; } catch (error) { ports.print((error as Error).message); }
92
+ }
93
+ ports.print(`Agents ahub up starts: ${show(current)} (no valid answer; the default stays).`);
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * `ahub init` of a new project (#608): copies this machine's chosen set into the project's `.agenthub/config.local.json`, as the
99
+ * autonomy preset is inherited. Only when `ahub setup` chose one, never over a value the project already has, and never into a
100
+ * file git tracks. Person-only by its caller; returns the lines to print.
101
+ */
102
+ export function inheritMachineStart(projectRoot: string, home: string, roots: readonly string[]): string[] {
103
+ const machine = readAutonomyDefaults(home, roots);
104
+ if (machine.notice) return [machine.notice];
105
+ if (!machine.startAgents) return [];
106
+ const file = join(projectRoot, ".agenthub", "config.local.json");
107
+ let doc: Record<string, unknown> = {};
108
+ try {
109
+ if (existsSync(file)) {
110
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
111
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return ["start.agents not copied: .agenthub/config.local.json is not a JSON object"];
112
+ doc = parsed as Record<string, unknown>;
113
+ }
114
+ } catch { return ["start.agents not copied: .agenthub/config.local.json could not be read"]; }
115
+ const start = doc.start;
116
+ if (start !== undefined && (typeof start !== "object" || start === null || Array.isArray(start))) return ["start.agents not copied: start in .agenthub/config.local.json is not an object"];
117
+ if (start && (start as Record<string, unknown>).agents !== undefined) return [];
118
+ const why = configRefusal(projectRoot, "config.local.json");
119
+ if (why) return [`start.agents not copied: ${why}`];
120
+ const text = `${JSON.stringify({ ...doc, start: { ...(start as object | undefined), agents: machine.startAgents } }, null, 2)}\n`;
121
+ const temporary = `${file}.${process.pid}.tmp`;
122
+ try { writeFileSync(temporary, text, { mode: 0o600, flag: "wx" }); renameSync(temporary, file); }
123
+ catch (error) { try { unlinkSync(temporary); } catch { /* nothing was written */ } return [`start.agents not copied: ${(error as NodeJS.ErrnoException).code ?? "write failed"}`]; }
124
+ return [`Inherited the machine's agent set for ahub up: ${show(machine.startAgents)}.`];
125
+ }
@@ -120,6 +120,11 @@ function taskBlock(task: any, columns: number | undefined, now: number, detail:
120
120
  const refText = [refs.repo, refs.branch ? `branch ${refs.branch}` : "", refs.commit ? `commit ${short(refs.commit)}` : ""].filter(Boolean).join(", ");
121
121
  if (refText) out.push(blank, ...pair("refs", refText, columns, w, undefined, " "));
122
122
  out.push(...bullets("ref paths", list(refs.paths), columns, undefined, " "));
123
+ if (!isStub(task) && task?.approval) {
124
+ const approval = task.approval;
125
+ const binding = `head ${approval.head ?? "unknown"}; latest ${approval.latestHead ?? "unknown"}; ${approval.mismatch ? "HEAD MISMATCH, approval is stale" : approval.current ? "current" : "not current, review required"}`;
126
+ out.push(...pair("approval", binding, columns, w, approval.current ? "success" : "attention", " "));
127
+ }
123
128
  const plan = planBlock(task?.plan, columns);
124
129
  if (plan.length) out.push(blank, [s("plan", "muted")], ...plan);
125
130
  const sdlc = task?.sdlc;
@@ -36,8 +36,8 @@ export interface UpgradeHost {
36
36
  apply(plan: UpgradePlan, interrupted: () => boolean): Promise<RecoveryOperation>;
37
37
  /** End one attached agent now: close a TUI agent's terminal, or ask the hub to stop a headless one. One line of outcome. */
38
38
  endPeer(planned: PlannedProject, peer: RecoveryPeer): Promise<string>;
39
- /** The operation that holds the machine's recovery lock. */
40
- lock(): string | undefined;
39
+ /** This selected project's hold, or an authoritative retained legacy machine hold. A reset may name another project. */
40
+ lock(projectRoot?: string): string | undefined;
41
41
  read(id: string): RecoveryOperation;
42
42
  runner(id: string): number | "unknown" | undefined;
43
43
  live(op: RecoveryOperation): Promise<Record<string, Inspection | undefined>>;
@@ -215,13 +215,13 @@ export async function endAgents(host: UpgradeHost, io: ScreenIO, endable: { plan
215
215
 
216
216
  /** `ahub reset` for one project: the scope, its dry run, a confirmation, then the reset itself. */
217
217
  export async function resetFlow(host: UpgradeHost, io: ScreenIO, projects: { id: string; root: string }[]): Promise<void> {
218
- if (host.lock()) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
219
218
  let project = projects[0];
220
219
  if (projects.length > 1) {
221
220
  projects.forEach((p, i) => human(io, ` ${i + 1} ${p.id} ${p.root}`));
222
221
  project = projects[Number(await ask(io, "Reset which project? [number, Enter: none] ")) - 1];
223
222
  }
224
223
  if (!project) return human(io, "nothing was reset");
224
+ if (host.lock(project.root)) return human(io, "an operation holds the recovery lock; cancel or end it before a reset");
225
225
  const scope = ((await ask(io, "[r] runtime reset (deliveries, holds, pauses, session pointers) [a] full reset (archive the state directory) [Enter] none: ")) ?? "").toLowerCase();
226
226
  if (scope !== "r" && scope !== "a") return human(io, "nothing was reset");
227
227
  const reset = [host.entry, "--project", project.root, "reset", ...(scope === "a" ? ["--all"] : [])];
@@ -323,7 +323,7 @@ export async function planScreen(host: UpgradeHost, io: ScreenIO): Promise<void>
323
323
  if (io.presentation) emit(io, renderPlan(plan, host.version, io.presentation, planLines(plan, host.version)));
324
324
  else for (const line of planLines(plan, host.version)) human(io, line);
325
325
  const blocked = plan.blockers.length > 0 || plan.projects.some((p) => p.blockers.length > 0);
326
- 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");
326
+ if (!plan.projects.length) human(io, plan.kind === "upgrade" && plan.scope === undefined ? `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");
327
327
  const endable = plan.projects.flatMap((p) => p.source.peers.filter((peer) => ["tui", "headless"].includes(peerKind(p, peer))).map((peer) => ({ planned: p, peer })));
328
328
  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"];
329
329
  const preferred = "r"; // Enter never closes terminals, stops a hub, resumes or reattaches implicitly.