@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -9
  3. package/docs/agent-notes/adapters.md +13 -3
  4. package/docs/agent-notes/benchmarks.md +1 -1
  5. package/docs/agent-notes/bus.md +7 -3
  6. package/docs/agent-notes/daemon.md +12 -2
  7. package/docs/agent-notes/local-worker.md +5 -1
  8. package/docs/agent-notes/tasks.md +4 -2
  9. package/docs/agent-notes/tests.md +5 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +15 -5
  12. package/docs/operations.md +510 -54
  13. package/docs/quickstart.md +16 -11
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +49 -11
  16. package/docs/smoke.md +138 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +227 -20
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +152 -18
  19. package/docs/verified.json +34 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +3 -2
  23. package/src/adapters/acp.ts +77 -10
  24. package/src/adapters/codex-appserver.ts +72 -2
  25. package/src/adapters/local-worker.ts +46 -11
  26. package/src/adapters/pi.ts +215 -22
  27. package/src/cli/bench.ts +287 -0
  28. package/src/cli/console-state.ts +226 -28
  29. package/src/cli/console.ts +16 -4
  30. package/src/cli/facts-hook.ts +50 -8
  31. package/src/cli/help.ts +16 -5
  32. package/src/cli/identity.ts +10 -4
  33. package/src/cli/launch.ts +272 -18
  34. package/src/cli/main.ts +290 -30
  35. package/src/cli/preview.ts +13 -3
  36. package/src/cli/recovery-package.ts +17 -0
  37. package/src/cli/status-lines.ts +2 -1
  38. package/src/cli/terminal-recovery.ts +83 -31
  39. package/src/cli/upgrade-interactive.ts +297 -0
  40. package/src/cli/upgrade-runtime.ts +123 -20
  41. package/src/cli/upgrade.ts +108 -19
  42. package/src/hub/bench.ts +211 -0
  43. package/src/hub/bus.ts +24 -7
  44. package/src/hub/conductor.ts +22 -21
  45. package/src/hub/config-trust.ts +1 -0
  46. package/src/hub/control-client.ts +4 -1
  47. package/src/hub/daemon.ts +477 -86
  48. package/src/hub/delivery-journal.ts +6 -6
  49. package/src/hub/events.ts +4 -2
  50. package/src/hub/lifecycle.ts +8 -13
  51. package/src/hub/manager.ts +16 -14
  52. package/src/hub/peers.ts +2 -2
  53. package/src/hub/permission-mode.ts +44 -0
  54. package/src/hub/recovery-store.ts +59 -9
  55. package/src/hub/registry.ts +36 -22
  56. package/src/hub/research.ts +255 -0
  57. package/src/hub/tasks.ts +99 -55
  58. package/src/hub/tool-refusal.ts +7 -0
  59. package/src/hub/ui.ts +4 -1
  60. package/src/local/deny.ts +48 -1
  61. package/src/local/sandbox.ts +6 -3
  62. package/src/local/tools.ts +34 -11
  63. package/src/pi/extension.ts +43 -20
  64. package/src/pi/process-signature.ts +23 -0
  65. package/src/pi/tool-receipts.ts +24 -2
  66. package/src/ui/index.html +98 -17
  67. package/src/ui/task-progress.ts +33 -0
@@ -8,10 +8,11 @@ import type { Project } from "../hub/registry.ts";
8
8
  import { hubHome } from "../hub/project.ts";
9
9
  import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
10
10
  import { inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
11
- import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
11
+ import { FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
12
12
  import { readEvents } from "../hub/events.ts";
13
13
  import { refreshManager } from "../hub/manager.ts";
14
14
  import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
15
+ import { recoveryLock } from "../hub/recovery-store.ts";
15
16
 
16
17
  /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
17
18
  const RECONNECT_WAIT_MS = 90_000;
@@ -91,8 +92,63 @@ async function rpc(project: Project, message: Record<string, unknown>, protocol
91
92
  } finally { client.close(); }
92
93
  }
93
94
 
95
+ /**
96
+ * #272: end one attached agent before an upgrade, for the person who chose it on the plan screen. The plan is what was
97
+ * drawn, so all of it is read again and any difference ends nothing: no operation holds the lock, the hub runs as the
98
+ * instance the plan read with the peer attached, and for a TUI agent the attached session is the one the plan bound and
99
+ * binds again to a terminal with the plan's handle and incarnation. `closeTerminal` itself compares only the terminal's
100
+ * handle, incarnation, worktree and root, never who runs in it. A headless agent is the hub's own process, so the hub
101
+ * is asked to stop it, which a hub older than that request cannot do. `deps` are a test's fakes.
102
+ */
103
+ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer, deps: { run?: RunCommand; inspect?: (project: Project) => Promise<Inspection>; lock?: () => string | undefined } = {}): Promise<string> {
104
+ const { run = runCommand, inspect = inspectRecovery, lock = recoveryLock } = deps;
105
+ const name = `${planned.project.id}/${peer.id}`;
106
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === peer.id);
107
+ const refused = (why: string) => `${name}: not ended (${why})`;
108
+ let cut = "", current: TerminalBinding | undefined;
109
+ try {
110
+ const owner = lock();
111
+ if (owner) return refused(`recovery operation ${owner} holds the lock`);
112
+ const hub = await inspect(planned.project);
113
+ if (hub.state !== "running" || hub.instanceId !== planned.source.instanceId) return refused(`its hub reads as ${hub.state === "running" ? `instance ${hub.instanceId}, not the ${planned.source.instanceId} of the plan` : hub.state}`);
114
+ const now = hub.peers.find((p) => p.id === peer.id && p.state !== "offline");
115
+ if (!now) return refused("it is no longer attached to its hub");
116
+ // The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
117
+ if (now.state === "busy") cut = "; its turn was cut: a delivery it had not settled is held as needs_review, and its queue stays held until ahub queue resolve";
118
+ if (binding) {
119
+ if ((binding.peer === "codex" ? now.threadId : now.sessionId) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
120
+ // The same question the plan asked (`makeUpgradePlan`), for this one peer.
121
+ const session = binding.peer === "pi" ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
122
+ const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
123
+ current = found.byPeer[binding.peer];
124
+ if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
125
+ if (current.handle !== binding.handle || current.incarnationId !== binding.incarnationId) return refused(`its session is now bound to terminal ${current.handle} (${current.incarnationId}), not the plan's ${binding.handle} (${binding.incarnationId})`);
126
+ }
127
+ // The reads above take a second or more: an operation that took the lock meanwhile still stops this.
128
+ const taken = lock();
129
+ if (taken) return refused(`recovery operation ${taken} holds the lock`);
130
+ } catch (error) { return refused((error as Error).message); } // a lock or a registry that cannot be read throws
131
+ if (current) {
132
+ const result = await closeTerminal(current, 0, terminalOptions(run));
133
+ return result.closed ? `${name}: terminal ${current.handle} closed${cut}` : `${name}: its terminal was not closed (${result.blockers[0]?.message ?? "unknown reason"}); end it in that terminal`;
134
+ }
135
+ try {
136
+ await rpc(planned.project, { t: "peer_stop", peer: peer.id }, planned.source.protocol ?? PROTOCOL);
137
+ return `${name}: stopped${cut}`;
138
+ } catch (error) {
139
+ const message = (error as Error).message;
140
+ return /does not know "peer_stop"/.test(message)
141
+ ? `${name}: hub ${planned.source.version ?? "of this version"} cannot end a headless agent by itself; it stops with the old hub at the upgrade and the new hub starts it again (ahub stop ${peer.id} ends it then)`
142
+ : `${name}: not stopped (${message})`;
143
+ }
144
+ }
145
+
94
146
  export async function inspectRecovery(project: Project): Promise<Inspection> {
95
- const base = await inspectProject(project);
147
+ // #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
148
+ // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
149
+ // caller reads that as not inspected, never as stopped.
150
+ const claim = registeredProjects().find((p) => p.id === project.id);
151
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
96
152
  const control = readControl(project.stateDir);
97
153
  const sourceProtocol = control?.protocol;
98
154
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
@@ -127,8 +183,10 @@ export async function inspectRecovery(project: Project): Promise<Inspection> {
127
183
  peers: [], blockers: ["runtime changed during recovery inspection"] };
128
184
  }
129
185
  const peers = Object.values(response.recovery?.peers ?? {}) as any[];
186
+ // #272: the hub's own words for what it waits on; shown to the person, never decided on.
187
+ const waiting = (Array.isArray(response.recovery?.blockers) ? response.recovery.blockers : []).filter((b: unknown): b is string => typeof b === "string").slice(0, 20).map((b: string) => b.slice(0, 200));
130
188
  return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
131
- recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
189
+ recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready, ...(waiting.length ? { waiting } : {}) }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
132
190
  ...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
133
191
  ...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
134
192
  ...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
@@ -175,7 +233,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
175
233
  if (recorded?.state === "unknown") {
176
234
  // An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
177
235
  delete peer.sessionId;
178
- blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
236
+ blockers.push(`claude: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
179
237
  continue;
180
238
  }
181
239
  const launcher = recorded?.state === "live" ? recorded.record! : undefined;
@@ -305,7 +363,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
305
363
  * expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
306
364
  */
307
365
  const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
308
- Promise<{ state: "live"; session?: string; handle?: string } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
366
+ Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
309
367
  const target = await inspectRecovery(planned.project);
310
368
  if (target.state !== "running" || target.instanceId !== progress.instanceId) {
311
369
  // Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
@@ -315,14 +373,44 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
315
373
  const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
316
374
  const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
317
375
  if (session) return { state: "live", session };
318
- const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
319
- if (launch?.state === "live") return { state: "live", handle: launch.record!.handle };
320
- if (launch?.state === "unknown") {
321
- return launch.record ? { state: "unknown", why: `its launcher in terminal ${launch.record.handle} cannot be read`, step: `wait until it attaches, or end it and close terminal ${launch.record.handle}` }
322
- : { state: "unknown", why: "the launcher records cannot be read", step: `inspect ${recordPath(planned.project.stateDir)} and move it aside (sessions it recorded then count as unmanaged)` };
376
+ // #225: the launch records of every instance this operation started for the project, the current one first: a
377
+ // launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
378
+ for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
379
+ if (!instanceId) continue;
380
+ const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
381
+ if (launch?.state === "live") return { state: "live", handle: launch.record!.handle, ...(attached ? { attached: true as const } : {}) };
382
+ if (launch?.state === "unknown") {
383
+ return launch.record ? { state: "unknown", why: `its launcher in terminal ${launch.record.handle} cannot be read`, step: `wait until it attaches, or end it and close terminal ${launch.record.handle}` }
384
+ : launch.invalidRow ? { state: "unknown", why: "a launch record that may be its launcher's cannot be evaluated", step: `inspect ${recordPath(planned.project.stateDir)} and fix that row, or end that launcher and remove its row` }
385
+ : { state: "unknown", why: "the launcher records cannot be read", step: `inspect ${recordPath(planned.project.stateDir)} and move it aside (sessions it recorded then count as unmanaged)` };
386
+ }
323
387
  }
388
+ // #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
389
+ // not reported its id yet) is a session of unknown identity, never "nothing attached".
390
+ if (attached) return { state: "unknown", why: `a ${peer} session is attached to the target without a session id`, step: `wait until it reports one, or end that ${peer} session` };
324
391
  return { state: "gone" };
325
392
  };
393
+ /**
394
+ * #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
395
+ * (`closedRetired:<peer>` `pending` until Orca no longer lists it; an unreadable or truncated inventory is unknown and
396
+ * changes nothing) or found absent, before its peer is relaunched against the new instance.
397
+ */
398
+ const closeRetired = async (progress: ProjectProgress, peer: string, save: () => void): Promise<void> => {
399
+ const retired = progress.terminals[`retired:${peer}`] as TerminalBinding | undefined;
400
+ const key = `closedRetired:${peer}`;
401
+ if (!retired || typeof retired !== "object" || progress.terminals[key] === true) return;
402
+ const seen = await listed(retired);
403
+ if (progress.terminals[key] === "pending" || seen !== "listed") {
404
+ if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
405
+ progress.terminals[key] = true; save(); return;
406
+ }
407
+ const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
408
+ if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
409
+ progress.terminals[key] = "pending"; save();
410
+ const result = await closeTerminal(retired, 0, terminalOptions(run));
411
+ if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
412
+ progress.terminals[key] = true; save();
413
+ };
326
414
  const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
327
415
  for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
328
416
  const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
@@ -423,17 +511,30 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
423
511
  await command(op, args, planned.project);
424
512
  }
425
513
  }
426
- for (const original of planned.terminals as TerminalBinding[]) {
427
- if ((original.peer === "claude") !== (group === "claude")) continue;
428
- const key = `restored:${original.peer}`;
514
+ for (const plannedBinding of planned.terminals as TerminalBinding[]) {
515
+ if ((plannedBinding.peer === "claude") !== (group === "claude")) continue;
516
+ const key = `restored:${plannedBinding.peer}`;
517
+ // #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
518
+ // instead of the planned one (fresh choice, fresh start, zero-turn Claude) is relaunched by that id, which the
519
+ // operation-fenced waiver already covers.
520
+ await closeRetired(progress, plannedBinding.peer, save);
521
+ const retired = progress.terminals[`retired:${plannedBinding.peer}`] as TerminalBinding | undefined;
522
+ const accepted = retired && typeof retired === "object" && retired.sessionId !== plannedBinding.sessionId ? retired.sessionId : undefined;
523
+ const original = accepted ? { ...plannedBinding, sessionId: accepted } : plannedBinding;
429
524
  // A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
430
525
  // start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
431
526
  const chosen = !!progress.fresh?.[original.peer];
432
527
  let read: ReturnType<typeof codexTranscript> | undefined;
433
- const transcript = () => original.peer !== "codex" || chosen ? "found" : (read ??= codexTranscript(original));
434
- const fresh = () => chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
528
+ const transcript = () => original.peer !== "codex" || (chosen && !accepted) ? "found" : (read ??= codexTranscript(original));
529
+ // An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
530
+ // rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
531
+ const fresh = () => accepted
532
+ ? (original.peer === "codex" ? (chosen || !!planned.freshStart?.includes("codex")) && transcript() === "missing" : original.peer === "claude" && !claudeTranscriptExists(original))
533
+ : chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
435
534
  // The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
436
535
  // peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
536
+ // The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
537
+ const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
437
538
  const notRestored = (why: string) => new Error(fresh()
438
539
  ? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
439
540
  : `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
@@ -442,7 +543,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
442
543
  ? `${original.peer}: whether a ${original.peer} session or launcher is live cannot be told (${evidence.why}); nothing was recorded or created${evidence.step ? `; ${evidence.step} first` : ""}`
443
544
  : evidence.session
444
545
  ? `${original.peer}: session ${evidence.session} is attached instead of ${original.sessionId}; no terminal was created; end that ${original.peer} session and close its terminal first`
445
- : `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
546
+ : `${original.peer}: its launcher in terminal ${evidence.handle} is running but no ${original.peer} session ${evidence.attached ? "with an id " : ""}attached yet; no terminal was created; wait until it attaches, or end it and close terminal ${evidence.handle} first`);
446
547
  const receipt = progress.terminals[key];
447
548
  if (receipt && receipt !== "pending" && receipt !== "failed") {
448
549
  progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
@@ -456,14 +557,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
456
557
  if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
457
558
  throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${attached} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
458
559
  }
459
- // The original came back after all: a fresh session chosen for it lost nothing.
460
- if (attached === original.sessionId && progress.fresh?.[original.peer]) {
560
+ // The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
561
+ // never with a session accepted after a restart (#225), whose choice must stay on record.
562
+ if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
461
563
  delete progress.fresh[original.peer];
462
564
  if (!Object.keys(progress.fresh).length) delete progress.fresh;
463
565
  }
464
566
  // Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
465
567
  // a launch writes the waiver first, but this must not rely on that.
466
- if (attached !== original.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: progress.fresh?.[original.peer] ? "fresh-session" : fresh() ? "fresh-start" : "zero-turn" });
568
+ // The planned session needs no waiver: the target's snapshot already expects it.
569
+ if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
467
570
  progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
468
571
  };
469
572
  // #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
@@ -494,7 +597,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
494
597
  if (fresh()) {
495
598
  if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
496
599
  // The restored daemon must accept the new session instead of the recorded one.
497
- waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: progress.fresh?.[original.peer] ? "fresh-session" : "fresh-start" });
600
+ waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
498
601
  }
499
602
  const entrypoint = join(op.targetRoot!, "src/cli/main.js");
500
603
  const argv = restoredTerminalArgv(entrypoint, planned.project.root, original, fresh());
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import type { Project } from "../hub/registry.ts";
5
+ import { PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
6
6
  import { hubHome } from "../hub/project.ts";
7
7
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
8
8
 
@@ -20,7 +20,8 @@ export interface Inspection {
20
20
  version?: string;
21
21
  protocol?: number;
22
22
  peers: RecoveryPeer[];
23
- recovery?: { operationId?: string; phase?: string; ready?: boolean };
23
+ /** `waiting` (#272): what the running hub says keeps it from being quiet (busy peers, pending approvals); display only. */
24
+ recovery?: { operationId?: string; phase?: string; ready?: boolean; waiting?: string[] };
24
25
  blockers: string[];
25
26
  /** #215: for a stopped runtime, the operation whose committed restart snapshot its state dir holds (unreleased). */
26
27
  snapshot?: string;
@@ -56,9 +57,18 @@ export interface ProjectProgress {
56
57
  fresh?: Record<string, { lost: string; reason: string; at: number }>;
57
58
  /** #215: set before the commit request is sent; from then on the source may have committed (abort is not offered). */
58
59
  commitSent?: boolean;
60
+ /**
61
+ * #225: each restart of a target that stopped after it started, recorded before the new one starts: the dead
62
+ * instance and when. Status and audit read it; restore reads the launch records of every instance listed here.
63
+ */
64
+ restarts?: { instanceId: string; at: number }[];
59
65
  }
60
66
  export interface RecoveryOperation {
61
- schema: 1;
67
+ /**
68
+ * #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
69
+ * abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
70
+ */
71
+ schema: 1 | 2;
62
72
  id: string;
63
73
  plan: UpgradePlan;
64
74
  createdAt: number;
@@ -94,6 +104,37 @@ export function targetReadsWaivers(op: { targetRoot?: string }): boolean {
94
104
  try { return !!op.targetRoot && readFileSync(join(op.targetRoot, "src/hub/restart.ts"), "utf8").includes("export function readRecoveryWaivers"); } catch { return false; }
95
105
  }
96
106
 
107
+ /**
108
+ * #225: whether an operation's coordinator restarts a target that stopped after it started. Resume runs the operation's
109
+ * own runner, so an older one keeps refusing and `next` must not offer resume for that row.
110
+ * ponytail: a text sniff of its upgrade.ts, like targetReadsWaivers and coordinatorCurrent; a rename reads as "cannot
111
+ * restart" (the safe side). Upgrade path: the capability list those two name.
112
+ */
113
+ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
114
+ if (!op.sourceRoot) return true;
115
+ try { return readFileSync(join(op.sourceRoot, "src/cli/upgrade.ts"), "utf8").includes("export function recordRestart"); } catch { return false; }
116
+ }
117
+
118
+ /**
119
+ * #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
120
+ * the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
121
+ * (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
122
+ * revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
123
+ * The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
124
+ */
125
+ export function recordRestart(progress: ProjectProgress, at: number): void {
126
+ (progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
127
+ for (const [key, value] of Object.entries(progress.terminals)) {
128
+ if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
129
+ const peer = key.slice("restored:".length);
130
+ progress.terminals[`retired:${peer}`] = value;
131
+ delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
132
+ delete progress.terminals[key];
133
+ }
134
+ delete progress.instanceId;
135
+ progress.phase = "stopped";
136
+ }
137
+
97
138
  /** Effects anywhere in the operation: a project past `prepared` or any terminal receipt. Abort needs none. */
98
139
  export const hasEffects = (op: RecoveryOperation) => op.projects.some((p) => !["pending", "prepared"].includes(p.phase) || Object.keys(p.terminals).length > 0);
99
140
  /**
@@ -144,9 +185,9 @@ export function resumeBlocked(op: RecoveryOperation, live: Record<string, Inspec
144
185
  if (state.state !== "stopped") continue;
145
186
  if (p.phase === "pending") return `${p.id}: the source stopped before it was prepared`;
146
187
  if (p.phase === "prepared" && !p.commitSent && !old) return `${p.id}: the source stopped while prepared, with no commit request`;
147
- // ponytail: a target that dies after it started is not restarted; that needs its peers relaunched against the new
148
- // instance (CodexPeer is created only by start, launcher records are keyed by instance). Follow-up issue.
149
- if (p.phase === "started" || p.phase === "peers-restored") return `${p.id}: the target stopped after it started; restarting it is not supported in this release`;
188
+ // #225: a target that stopped after it started is restarted from this operation's snapshot (the line below), by a
189
+ // coordinator that can; an older one's runner refuses it.
190
+ if ((p.phase === "started" || p.phase === "peers-restored") && !coordinatorRestarts(op)) return `${p.id}: the target stopped after it started; this operation's coordinator cannot restart it`;
150
191
  if (state.snapshot !== op.id) return `${p.id}: its hub stopped without this operation's restart snapshot`;
151
192
  }
152
193
  return undefined;
@@ -162,32 +203,48 @@ export function disposeRefusal(op: RecoveryOperation, live: Record<string, Inspe
162
203
  return unsettled ? { project: unsettled.id, reads: live[unsettled.id]?.state ?? "not inspected" } : undefined;
163
204
  }
164
205
 
206
+ /** #272: one entry of `next`. `text` is the line status prints; a screen runs `kind` through `recoveryArgv`. */
207
+ export interface NextChoice {
208
+ kind: "resume" | "abort" | "fresh" | "stop" | "wait";
209
+ /** `fresh` only: the peer that starts as a new session. */
210
+ peer?: string;
211
+ text: string;
212
+ }
213
+
165
214
  /**
166
215
  * #215: what the person can do now, in the receipt table's order: resume (it also launches a failed peer again),
167
216
  * abort where it can succeed, a fresh session for a failed Codex or Claude restoration, and stop-and-archive last.
168
- * `status` and every error that names choices use this one list.
217
+ * `status`, every error that names choices and the interactive screen (#272) use this one list.
169
218
  */
170
- export function nextActions(receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): string[] {
219
+ export function nextChoices(receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): NextChoice[] {
171
220
  // A receipt read without its project lists (never written so by a coordinator) still gets the general choices.
172
221
  const op: RecoveryOperation = receipt.projects ? receipt : { ...receipt, projects: [], plan: { ...receipt.plan, projects: receipt.plan?.projects ?? [] } };
173
222
  if (op.phase === "completed" || op.phase === "cancelled") return [];
174
223
  // claimRunner refuses an owner it cannot read, so resume, abort and dispose are all refused until the record reads.
175
- if (runner === "unknown") return [`${recoveryCommand(op, "status")} again: whether a runner holds the operation could not be read (resume, abort and dispose are refused until it can)`];
176
- if (runner) return [`wait: runner ${runner} is working; ${recoveryCommand(op, "status")}`];
224
+ if (runner === "unknown") return [{ kind: "wait", text: `${recoveryCommand(op, "status")} again: whether a runner holds the operation could not be read (resume, abort and dispose are refused until it can)` }];
225
+ if (runner) return [{ kind: "wait", text: `wait: runner ${runner} is working; ${recoveryCommand(op, "status")}` }];
177
226
  const unsettled = disposeRefusal(op, live);
178
- const stop = unsettled ? `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` : recoveryCommand(op, "dispose", STOP);
179
- if (op.disposition) return [unsettled ? stop : `rerun ${stop}`];
227
+ const stop: NextChoice = unsettled ? { kind: "wait", text: `wait until ${unsettled.project}'s hub settles, then ${recoveryCommand(op, "status")}` } : { kind: "stop", text: recoveryCommand(op, "dispose", STOP) };
228
+ if (op.disposition) return [unsettled ? stop : { kind: "stop", text: `rerun ${stop.text}` }];
180
229
  const failed = [...new Set(op.projects.flatMap((p) => failedPeers(op, p, live)))];
181
230
  const old = !!op.sourceRoot && !coordinatorCurrent(op.sourceRoot);
182
231
  const abortable = !abortRefusal(op, live);
183
232
  const stuck = resumeBlocked(op, live);
184
233
  return [
185
- ...(stuck ? [] : [`${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}`]),
186
- ...(abortable ? [recoveryCommand(op, "abort")] : []),
187
- ...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`)) : []),
234
+ ...(stuck ? [] : [{ kind: "resume" as const, text: `${recoveryCommand(op, "resume")}${op.error ? " (after the next action in error)" : ""}${old ? ` (runs the coordinator that started this operation, which cannot re-prepare an expired hold: if it reports "source is no longer prepared", use ${abortable ? "abort or " : ""}stop-and-archive)` : ""}` }]),
235
+ ...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
236
+ ...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => ({ kind: "fresh" as const, peer, text: recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`) })) : []),
188
237
  stop,
189
238
  ];
190
239
  }
240
+ export const nextActions = (receipt: RecoveryOperation, runner?: number | "unknown", live: Record<string, Inspection | undefined> = {}): string[] => nextChoices(receipt, runner, live).map((c) => c.text);
241
+
242
+ /**
243
+ * #272: a runner that only waits for its source to get quiet can be stopped and the operation aborted: nothing was
244
+ * closed, stopped or asked to commit. Abort decides again on what is live, so a runner that moved on meanwhile is
245
+ * refused there, never here.
246
+ */
247
+ export const cancellableWait = (op: RecoveryOperation): boolean => op.phase === "running" && op.step.startsWith("prepare:") && !op.disposition && !hasEffects(op) && !op.projects.some((p) => p.commitSent);
191
248
 
192
249
  /**
193
250
  * "next actions: ..." from nextActions, with the inspections the caller holds. Every error that lists choices ends with
@@ -231,7 +288,9 @@ export function registeredProjects(home = hubHome()): Project[] {
231
288
  if (!existsSync(path)) return [];
232
289
  const db = new Database(path, { readonly: true });
233
290
  try {
234
- return db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects ORDER BY root").all() as Project[];
291
+ // #226: a registry no current release opened yet has no signature column, and this read never migrates.
292
+ const signed = (db.query("PRAGMA table_info(projects)").all() as { name: string }[]).some((c) => c.name === "pid_signature");
293
+ return (db.query(`SELECT ${PROJECT_COLUMNS}${signed ? ", pid_signature" : ""} FROM projects ORDER BY root`).all() as Record<string, unknown>[]).map(projectFromRow);
235
294
  } finally { db.close(); }
236
295
  }
237
296
 
@@ -256,8 +315,21 @@ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hu
256
315
  return op;
257
316
  }
258
317
 
318
+ /**
319
+ * #227: a coordinator from before #215 never reads `disposition`, and an older global `ahub recovery resume` runs it.
320
+ * Every older validateReceipt refuses a schema other than 1 before the lock and the runner claim, so schema 2 keeps it
321
+ * from advancing an operation being abandoned. A #215 coordinator's runner refuses the disposition itself and keeps
322
+ * schema 1, so a 0.12.20 CLI can still rerun its dispose. True when the receipt changed (a disposition recorded at 1
323
+ * by 0.12.20 is moved to 2 by the next command of this release that holds the runner claim).
324
+ */
325
+ function abandoned(op: RecoveryOperation): boolean {
326
+ if (!op.disposition || op.schema !== 1 || !op.sourceRoot || coordinatorCurrent(op.sourceRoot)) return false;
327
+ op.schema = 2;
328
+ return true;
329
+ }
330
+
259
331
  function validateReceipt(id: string, op: RecoveryOperation): void {
260
- if (op.schema !== 1 || op.id !== id) throw new Error("unsupported operation receipt");
332
+ if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
261
333
  const { fingerprint, ...reviewed } = op.plan;
262
334
  if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
263
335
  op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
@@ -302,8 +374,12 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
302
374
  } catch (error) { releaseRunner(); throw error; }
303
375
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
304
376
  const step = (value: string) => { op.step = value; save(); };
377
+ // #225: one restart per project per resume, so a target that keeps dying never spins unattended.
378
+ const restarted = new Set<string>();
379
+ const stoppedAgain = (progress: ProjectProgress) => `the target stopped again after restart ${progress.restarts?.length ?? 0} in this resume`;
305
380
  const identity = (observed: Inspection, planned: PlannedProject, progress: ProjectProgress) => {
306
- if (observed.state === "stopped") throw new Error(progress.phase !== "stopped" ? `${planned.project.id}: the target stopped after it started (a crash or a reboot); restarting it is not supported in this release`
381
+ if (observed.state === "stopped") throw new Error(restarted.has(progress.id) ? `${planned.project.id}: ${stoppedAgain(progress)}; read its hub.log before resuming again`
382
+ : progress.phase !== "stopped" ? `${planned.project.id}: the target stopped after it started (a crash or a reboot)${observed.snapshot === id ? "; resume restarts it from this operation's snapshot" : " and this operation's restart snapshot is gone, so nothing can start it"}`
307
383
  : `${planned.project.id}: the target is stopped${observed.snapshot === id ? "" : " and this operation's restart snapshot is gone, so nothing can start it"}`);
308
384
  if (observed.state === "missing") throw new Error(`${planned.project.id}: the project directory is missing`);
309
385
  if (observed.state !== "running") throw new Error(`${planned.project.id}: the target reads as ${observed.state}; wait until it answers`);
@@ -381,6 +457,15 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
381
457
  }
382
458
  for (let i = 0; i < op.projects.length; i++) {
383
459
  const progress = op.projects[i]!, planned = op.plan.projects[i]!, project = planned.project;
460
+ if (progress.phase === "started" || progress.phase === "peers-restored") {
461
+ // #225: a target that stopped after it started, with this operation's snapshot, starts again from it.
462
+ const live = await driver.inspect(project);
463
+ if (live.state === "stopped") {
464
+ if (live.snapshot !== id) throw new Error(`${project.id}: the target stopped after it started and this operation's restart snapshot is gone, so nothing can start it`);
465
+ step(`restart:${project.id}`);
466
+ recordRestart(progress, driver.now()); restarted.add(progress.id); save();
467
+ }
468
+ }
384
469
  if (progress.phase === "verified" || progress.phase === "peers-restored") continue;
385
470
  if (progress.phase === "pending") {
386
471
  step(`prepare:${project.id}`);
@@ -486,7 +571,8 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
486
571
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
487
572
  ...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
488
573
  projects: op.projects.map((p) => ({ id: p.id, phase: p.phase, ...(Object.keys(p.terminals).length ? { effects: Object.fromEntries(Object.entries(p.terminals).map(([key, value]) => [key, effect(value)])) } : {}),
489
- ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}) })),
574
+ ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
575
+ ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
490
576
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
491
577
  ...(!targetReadsWaivers(op) && op.projects.some((p) => failedPeers(op, p).length) ? { freshSession: `not offered: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session` } : {}),
492
578
  ...(op.error ? { error: op.error } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
@@ -507,6 +593,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
507
593
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
508
594
  return;
509
595
  }
596
+ if (abandoned(op)) writeOperation(id, op, home);
510
597
  const { fingerprint, ...body } = op.plan;
511
598
  if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
512
599
  const live = await liveProjects(op, driver.inspect);
@@ -543,6 +630,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
543
630
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
544
631
  throw new Error(`operation is already ${op.phase}`);
545
632
  }
633
+ if (abandoned(op)) writeOperation(id, op, home);
546
634
  const at = driver.now();
547
635
  // Read once, as status and the runner do: every choice and refusal below is decided on it.
548
636
  const live = await liveProjects(op, driver.inspect);
@@ -596,6 +684,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
596
684
  }
597
685
  // Each outcome is recorded as it happens, so a disposition that stops partway stays true and resume refuses it.
598
686
  const disposition = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
687
+ abandoned(op); // #227: in the same write as the disposition
599
688
  (op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
600
689
  // Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
601
690
  op.updatedAt = driver.now(); writeOperation(id, op, home);