@staix/agent-hub 0.12.30 → 0.12.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +29 -9
  3. package/docs/agent-notes/adapters.md +9 -5
  4. package/docs/agent-notes/budget.md +1 -1
  5. package/docs/agent-notes/bus.md +5 -0
  6. package/docs/agent-notes/daemon.md +14 -8
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +7 -1
  9. package/docs/agent-notes/tests.md +6 -2
  10. package/docs/cooperbench.md +1 -1
  11. package/docs/gsd-collaboration-analysis.md +1 -1
  12. package/docs/memory.md +2 -2
  13. package/docs/operations.md +236 -37
  14. package/docs/quickstart.md +24 -5
  15. package/docs/security.md +42 -10
  16. package/docs/smoke.md +34 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +241 -3
  18. package/docs/specs/2026-09-20-upgrade-recovery-design.md +275 -10
  19. package/docs/verification/2026-10-17-0.12.31.md +71 -0
  20. package/docs/verification/2026-10-17-418-probes.md +21 -0
  21. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  22. package/docs/verified.json +20 -15
  23. package/package.json +2 -2
  24. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  25. package/plugins/agent-hub/server.js +1361 -44
  26. package/src/adapters/acp.ts +5 -6
  27. package/src/adapters/claude-channel.ts +29 -11
  28. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  29. package/src/adapters/definitions/kimi-native.ts +4 -0
  30. package/src/adapters/definitions/qwen-native.ts +24 -0
  31. package/src/adapters/definitions/qwen.ts +71 -0
  32. package/src/adapters/local-worker.ts +2 -2
  33. package/src/adapters/pi.ts +11 -2
  34. package/src/adapters/registry-types.ts +174 -0
  35. package/src/adapters/registry.ts +36 -0
  36. package/src/cli/attention.ts +5 -1
  37. package/src/cli/console-state.ts +130 -17
  38. package/src/cli/console.ts +54 -8
  39. package/src/cli/help.ts +3 -1
  40. package/src/cli/identity.ts +16 -5
  41. package/src/cli/jcode-mcp.ts +1 -1
  42. package/src/cli/launch.ts +1 -6
  43. package/src/cli/main.ts +208 -40
  44. package/src/cli/output.ts +15 -2
  45. package/src/cli/preview.ts +8 -5
  46. package/src/cli/recovery-guidance.ts +137 -0
  47. package/src/cli/recovery-presentation-options.ts +8 -0
  48. package/src/cli/recovery-session-file.ts +18 -0
  49. package/src/cli/terminal-recovery.ts +48 -0
  50. package/src/cli/upgrade-interactive.ts +129 -74
  51. package/src/cli/upgrade-labels.ts +30 -0
  52. package/src/cli/upgrade-presentation-data.ts +41 -0
  53. package/src/cli/upgrade-presentation-flags.ts +31 -0
  54. package/src/cli/upgrade-presentation.ts +217 -0
  55. package/src/cli/upgrade-restore-state.ts +137 -0
  56. package/src/cli/upgrade-runtime.ts +430 -175
  57. package/src/cli/upgrade.ts +325 -49
  58. package/src/hub/board.ts +82 -24
  59. package/src/hub/bus.ts +89 -29
  60. package/src/hub/check-runner.ts +514 -0
  61. package/src/hub/child-process.ts +1 -0
  62. package/src/hub/cohorts.ts +2 -2
  63. package/src/hub/config-apply.ts +242 -0
  64. package/src/hub/config-reload.ts +120 -0
  65. package/src/hub/config-trust.ts +11 -2
  66. package/src/hub/control-client.ts +5 -1
  67. package/src/hub/crash.ts +8 -6
  68. package/src/hub/daemon.ts +931 -209
  69. package/src/hub/delivery-hints.ts +30 -0
  70. package/src/hub/delivery-journal.ts +185 -12
  71. package/src/hub/delivery-superseded.ts +52 -0
  72. package/src/hub/envelope.ts +1 -1
  73. package/src/hub/events.ts +2 -2
  74. package/src/hub/host-executable.ts +11 -0
  75. package/src/hub/hub-tools.ts +9 -1
  76. package/src/hub/manager.ts +1 -1
  77. package/src/hub/peer-catalog.ts +13 -13
  78. package/src/hub/peers.ts +12 -2
  79. package/src/hub/permission-mode.ts +5 -3
  80. package/src/hub/recovery-continuity.ts +152 -0
  81. package/src/hub/recovery-store.ts +10 -4
  82. package/src/hub/routing.ts +35 -16
  83. package/src/hub/sdlc.ts +5 -1
  84. package/src/hub/settings.ts +52 -37
  85. package/src/hub/settled-roles.ts +35 -0
  86. package/src/hub/spec-read.ts +120 -0
  87. package/src/hub/supervision.ts +24 -8
  88. package/src/hub/task-sweep.ts +7 -6
  89. package/src/hub/tasks.ts +199 -40
  90. package/src/hub/ui.ts +1 -1
  91. package/src/memory/brief.ts +6 -5
  92. package/src/memory/capture.ts +2 -1
  93. package/src/pi/session.ts +23 -4
  94. package/src/ui/index.html +35 -16
  95. package/src/ui/needs.ts +7 -5
  96. package/src/ui/office-model.ts +4 -1
  97. package/src/ui/task-board.ts +1 -1
  98. package/src/ui/task-progress.ts +6 -2
  99. package/templates/AGENTS.block.md +1 -1
  100. package/templates/profiles/general.json +1 -1
  101. package/templates/profiles/research.json +1 -1
  102. package/templates/profiles/writing.json +1 -1
@@ -1,3 +1,6 @@
1
+ import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
2
+ import { checkedSessionPath } from "./recovery-session-file.ts";
3
+ import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
1
4
  import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
2
5
  import { Database } from "bun:sqlite";
3
6
  import { createHash, randomUUID } from "node:crypto";
@@ -6,6 +9,9 @@ import { join } from "node:path";
6
9
  import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
7
10
  import { hubHome } from "../hub/project.ts";
8
11
  import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
12
+ import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
13
+ import type { TerminalBinding } from "./terminal-recovery.ts";
14
+ import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
9
15
 
10
16
  export interface RecoveryPeer {
11
17
  id: string;
@@ -53,6 +59,10 @@ export interface ProjectProgress {
53
59
  phase: "pending" | "prepared" | "stopped" | "started" | "peers-restored" | "verified";
54
60
  instanceId?: string;
55
61
  terminals: Record<string, unknown>;
62
+ /** One durable repair allowance per peer, retained after an attempt to prevent a second relaunch. */
63
+ reattachments?: Partial<Record<RecoveryNativePeer, ReattachmentMarker>>;
64
+ /** Recorded-session-only continuity, independent of a target incarnation's repair attempt allowance. */
65
+ repairedSessions?: Partial<Record<RecoveryNativePeer, true>>;
56
66
  /** #414: last bounded native-attachment timeout, metadata only; never terminal output or task text. */
57
67
  attachmentTimeouts?: Record<string, {
58
68
  at: number;
@@ -63,7 +73,7 @@ export interface ProjectProgress {
63
73
  }>;
64
74
  releaseRequested?: boolean;
65
75
  /** #215: peers the operator moved to a fresh session, with the session or thread id whose continuity is lost. */
66
- fresh?: Record<string, { lost: string; reason: string; at: number }>;
76
+ fresh?: Record<string, { lost: string; reason: string; at: number; repaired?: true }>;
67
77
  /** #215: set before the commit request is sent; from then on the source may have committed (abort is not offered). */
68
78
  commitSent?: boolean;
69
79
  /**
@@ -72,6 +82,12 @@ export interface ProjectProgress {
72
82
  */
73
83
  restarts?: { instanceId: string; at: number }[];
74
84
  }
85
+ export interface ReattachmentMarker {
86
+ state: "ready" | "attempted";
87
+ at: number;
88
+ cause: "detached after restore";
89
+ binding: TerminalBinding;
90
+ }
75
91
  export interface RecoveryOperation {
76
92
  /**
77
93
  * #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
@@ -87,14 +103,24 @@ export interface RecoveryOperation {
87
103
  sourceRoot: string;
88
104
  targetRoot?: string;
89
105
  targetDigest?: string;
106
+ /** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
107
+ targetFeatures?: readonly string[];
90
108
  projects: ProjectProgress[];
91
109
  pluginInstalled?: boolean;
92
110
  globalInstalled?: boolean;
93
111
  error?: string;
112
+ /** Presentation observations only: old receipts without these fields have unknown timings and cause. */
113
+ waits?: Record<string, RecoveryWaitTiming>;
114
+ presentationCause?: RecoveryPresentationCause;
94
115
  failure?: { code: "database_busy"; retryable: true; site?: string[] };
116
+ /** A typed verify outcome, never inferred from an error message or a peer's current state. */
117
+ blockedReattachment?: { projectId: string; peer: RecoveryNativePeer };
118
+ /** Positive typed store observation; disposal rechecks it before accepting a repaired-session loss. */
119
+ missingRecordedSession?: { projectId: string; peer: RecoveryNativePeer; sessionId: string; kind: "rollout" | "transcript"; checkedPath?: string };
120
+ freshOverride?: { peer: RecoveryNativePeer; evidence: NonNullable<RecoveryOperation["missingRecordedSession"]>; checked: "missing"; at: number };
95
121
  /** #215: every human disposition, in order. Operator reasons only; never task or message text. */
96
122
  audit?: { at: number; action: "fresh-session" | "stop-and-archive"; reason: string; peer?: string; projects: string[] }[];
97
- disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string> };
123
+ disposition?: { choice: "stop-and-archive"; at: number; projects: Record<string, string>; continuity?: Record<string, "not-archived" | "unavailable"> };
98
124
  /** #215: a refusal no resume can get past was recorded (FinalRefusal); `next` no longer offers resume. */
99
125
  final?: boolean;
100
126
  }
@@ -102,6 +128,16 @@ export interface RecoveryOperation {
102
128
  /** #215: a refusal that holds for every resume of the operation (its staged target or preserved source is fixed). */
103
129
  export class FinalRefusal extends Error {}
104
130
 
131
+ export class ReattachmentRequired extends Error {
132
+ constructor(public readonly projectId: string, public readonly peer: RecoveryNativePeer, message: string, public readonly presentationError?: RecoveryPresentationError) { super(message); }
133
+ }
134
+
135
+ export class MissingRecordedSession extends Error {
136
+ readonly code = "recorded_session_missing";
137
+ constructor(readonly evidence: NonNullable<RecoveryOperation["missingRecordedSession"]>) {
138
+ super(`${evidence.peer}: recorded session ${evidence.sessionId} has no resumable transcript (positively missing ${evidence.kind}${evidence.checkedPath ? ` checked at ${JSON.stringify(evidence.checkedPath)}` : ""}); choose dispose --fresh-session ${evidence.peer} --reason <text> to accept its loss, or stop-and-archive`);
139
+ }
140
+ }
105
141
  export { recoveryCommand };
106
142
  const STOP = "--stop-and-archive --reason <text>";
107
143
  /**
@@ -127,7 +163,7 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
127
163
 
128
164
  /**
129
165
  * #225: record the restart of a target that stopped after it started, before the new one starts: the dead instance and
130
- * the time stay for status and audit. Each done `restored:<peer>` binding of the dead instance is retired
166
+ * the time stay for status and audit. Each done or waiting `restored:<peer>` binding of the dead instance is retired
131
167
  * (`retired:<peer>`): restore closes its terminal (or finds it absent) before that peer is relaunched, and never
132
168
  * revalidates it against the new instance. `closed:<peer>`, `commitSent`, `fresh`, waivers and the install flags stay.
133
169
  * The project goes back to `stopped`, whose path starts the target from this operation's snapshot.
@@ -135,12 +171,19 @@ export function coordinatorRestarts(op: { sourceRoot?: string }): boolean {
135
171
  export function recordRestart(progress: ProjectProgress, at: number): void {
136
172
  (progress.restarts ??= []).push({ instanceId: progress.instanceId ?? "unknown", at });
137
173
  for (const [key, value] of Object.entries(progress.terminals)) {
138
- if (!key.startsWith("restored:") || !value || typeof value !== "object") continue;
174
+ if (!key.startsWith("restored:")) continue;
175
+ const receipt = decodeRestoreReceipt(value);
176
+ if (receipt?.state !== "done" && receipt?.state !== "waiting") continue;
139
177
  const peer = key.slice("restored:".length);
140
- progress.terminals[`retired:${peer}`] = value;
178
+ progress.terminals[`retired:${peer}`] = receipt.binding;
141
179
  delete progress.terminals[`closedRetired:${peer}`]; // a newer binding to close: its own receipt starts over
142
180
  delete progress.terminals[key];
181
+ // Older receipts may have only the attempt marker. Retiring that binding must not retire its continuity rule.
182
+ if (Object.hasOwn(progress.reattachments ?? {}, peer)) (progress.repairedSessions ??= {})[peer as RecoveryNativePeer] = true;
183
+ // The allowance belongs to the target whose exact binding was retired, not every later #225 restart.
184
+ if (progress.reattachments) delete progress.reattachments[peer as RecoveryNativePeer];
143
185
  }
186
+ if (progress.reattachments && !Object.keys(progress.reattachments).length) delete progress.reattachments;
144
187
  delete progress.instanceId;
145
188
  progress.phase = "stopped";
146
189
  }
@@ -215,7 +258,7 @@ export function disposeRefusal(op: RecoveryOperation, live: Record<string, Inspe
215
258
 
216
259
  /** #272: one entry of `next`. `text` is the line status prints; a screen runs `kind` through `recoveryArgv`. */
217
260
  export interface NextChoice {
218
- kind: "resume" | "abort" | "fresh" | "stop" | "wait";
261
+ kind: "resume" | "abort" | "fresh" | "stop" | "wait" | "reattach";
219
262
  /** `fresh` only: the peer that starts as a new session. */
220
263
  peer?: string;
221
264
  text: string;
@@ -240,9 +283,13 @@ export function nextChoices(receipt: RecoveryOperation, runner?: number | "unkno
240
283
  const old = !!op.sourceRoot && !coordinatorCurrent(op.sourceRoot);
241
284
  const abortable = !abortRefusal(op, live);
242
285
  const stuck = resumeBlocked(op, live);
286
+ const selected = op.phase === "blocked" ? op.blockedReattachment : undefined;
287
+ const repair = selected && op.projects.find(project => project.id === selected.projectId)?.reattachments?.[selected.peer];
288
+ const reattach = selected && repair && (repair.state === "ready" || repairWaiting(op, selected.projectId, selected.peer));
243
289
  return [
244
290
  ...(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)` : ""}` }]),
245
291
  ...(abortable ? [{ kind: "abort" as const, text: recoveryCommand(op, "abort") }] : []),
292
+ ...(selected && reattach ? [{ kind: "reattach" as const, peer: selected.peer, text: recoveryCommand(op, "reattach", selected.peer) }] : []),
246
293
  ...(targetReadsWaivers(op) && !stuck ? failed.map((peer) => ({ kind: "fresh" as const, peer, text: recoveryCommand(op, "dispose", `--fresh-session ${peer} --reason <text>`) })) : []),
247
294
  stop,
248
295
  ];
@@ -273,6 +320,13 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
273
320
  return Object.fromEntries(await Promise.all(open.map(async (p) => [p.project.id, await inspect(p.project).catch(() => undefined)] as const)));
274
321
  }
275
322
 
323
+ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: string): boolean {
324
+ const evidence = op.missingRecordedSession;
325
+ if (!evidence || evidence.projectId !== progress.id || evidence.peer !== peer || (!progress.repairedSessions?.[evidence.peer] && !Object.hasOwn(progress.reattachments ?? {}, peer))) return false;
326
+ const planned = op.plan.projects.find(project => project.project.id === progress.id);
327
+ if (!planned) return false;
328
+ try { return resolveAcceptedBinding(planned, progress, evidence.peer)?.sessionId === evidence.sessionId; } catch { return false; }
329
+ }
276
330
  /**
277
331
  * Peers of a project whose restoration failed and for which a fresh session is a real choice: not Pi (a restored hub
278
332
  * resumes its recorded session), not one already chosen, and not a planned fresh start (nothing to lose; resume
@@ -280,14 +334,32 @@ export async function liveProjects(op: RecoveryOperation, inspect: (project: Pro
280
334
  */
281
335
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
282
336
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
283
- return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && value === "failed").map(([key]) => key.slice("restored:".length))
284
- .filter((peer) => peer !== "pi" && !progress.fresh?.[peer] && !planned?.freshStart?.includes(peer) && !plannedAttached(op, progress, peer, live));
337
+ return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
338
+ .filter((peer) => peer !== "pi" && ((!Object.hasOwn(progress.reattachments ?? {}, peer) && !Object.hasOwn(progress.repairedSessions ?? {}, peer)) || missingRepair(op, progress, peer)) && !progress.fresh?.[peer] && (!planned?.freshStart?.includes(peer) || missingRepair(op, progress, peer)) && !plannedAttached(op, progress, peer, live));
339
+ }
340
+
341
+ /** Read-only scope classification; it confers no delegation authority. */
342
+ export function repairedFreshSession(op: RecoveryOperation, peer: string): boolean | undefined {
343
+ try {
344
+ validateReceipt(op.id, op);
345
+ if (!op.plan.projects.some(planned => planned.source.peers.some(row => row.id === peer) ||
346
+ planned.terminals.some(value => !!value && typeof value === "object" && (value as { peer?: unknown }).peer === peer))) return undefined;
347
+ return op.projects.some(progress => failedPeers(op, progress).includes(peer) && missingRepair(op, progress, peer));
348
+ } catch { return undefined; } // Unknown scope never becomes an ordinary delegated disposition.
349
+ }
350
+
351
+ function repairWaiting(op: RecoveryOperation, projectId: string, peer: RecoveryNativePeer): boolean {
352
+ try { return decodeRestoreReceipt(op.projects.find(project => project.id === projectId)?.terminals[`restored:${peer}`])?.state === "waiting"; }
353
+ catch { return false; }
285
354
  }
286
355
 
287
356
  /** #215: the inspection shows the planned session attached for `peer`, so resume records it as restored: nothing is lost. */
288
357
  function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer: string, live: Record<string, Inspection | undefined>): boolean {
289
358
  const state = live[progress.id];
290
- const binding = (op.plan.projects.find((p) => p.project.id === progress.id)?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
359
+ const planned = op.plan.projects.find((p) => p.project.id === progress.id);
360
+ const binding = planned && Object.hasOwn(progress.repairedSessions ?? {}, peer)
361
+ ? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
362
+ : (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
291
363
  const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
292
364
  return !!binding && (peer === "codex" ? current?.threadId : current?.sessionId) === binding.sessionId;
293
365
  }
@@ -341,29 +413,84 @@ function abandoned(op: RecoveryOperation): boolean {
341
413
 
342
414
  function validateReceipt(id: string, op: RecoveryOperation): void {
343
415
  if (!(op.schema === 1 || (op.schema === 2 && op.disposition)) || op.id !== id) throw new Error("unsupported operation receipt");
416
+ if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
344
417
  const { fingerprint, ...reviewed } = op.plan;
345
418
  if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
346
419
  op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
420
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
421
+ for (const [index, progress] of op.projects.entries()) {
422
+ if (progress.repairedSessions !== undefined) {
423
+ if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
424
+ for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
425
+ const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
426
+ const bindings = [progress.reattachments?.[peer as RecoveryNativePeer]?.binding,
427
+ restored?.state === "done" || restored?.state === "waiting" ? restored.binding : undefined,
428
+ progress.terminals[`retired:${peer}`]];
429
+ if (protectedSession !== true || !bindings.some(binding => {
430
+ if (binding === undefined) return false;
431
+ const receipt = decodeRestoreReceipt({ state: "done", binding });
432
+ return receipt?.state === "done" && receipt.binding.peer === peer && receipt.binding.projectRoot === op.plan.projects[index]?.project.root;
433
+ })) throw new Error("repaired session provenance does not match its retained peer and project");
434
+ }
435
+ }
436
+ for (const [peer, choice] of Object.entries(progress.fresh ?? {})) {
437
+ if (choice.repaired === undefined) continue;
438
+ const planned = op.plan.projects[index]!;
439
+ if (choice.repaired !== true || !progress.repairedSessions?.[peer as RecoveryNativePeer] ||
440
+ resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)?.sessionId !== choice.lost) throw new Error("invalid one-use repaired-session choice");
441
+ }
442
+ if (progress.reattachments === undefined) continue;
443
+ if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
444
+ for (const [peer, marker] of Object.entries(progress.reattachments)) {
445
+ if (!["codex", "pi", "claude"].includes(peer) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
446
+ !["ready", "attempted"].includes(String(marker.state)) || marker.cause !== "detached after restore" || typeof marker.at !== "number" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid reattachment marker");
447
+ const receipt = decodeRestoreReceipt({ state: "done", binding: marker.binding });
448
+ if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== op.plan.projects[index]?.project.root) throw new Error("reattachment binding does not match its peer and project");
449
+ }
450
+ }
451
+ if (op.missingRecordedSession !== undefined) {
452
+ const evidence = op.missingRecordedSession;
453
+ if (!object(evidence) || Object.keys(evidence).some(key => !["projectId", "peer", "sessionId", "kind", "checkedPath"].includes(key)) ||
454
+ typeof evidence.projectId !== "string" || typeof evidence.peer !== "string" || typeof evidence.sessionId !== "string" ||
455
+ evidence.checkedPath !== undefined && (typeof evidence.checkedPath !== "string" || !evidence.checkedPath) ||
456
+ !["rollout", "transcript"].includes(evidence.kind) || !op.projects.some(progress => missingRepair(op, progress, evidence.peer))) throw new Error("invalid recorded-session missing evidence");
457
+ }
458
+ if (op.freshOverride !== undefined) {
459
+ const override = op.freshOverride, evidence = override.evidence;
460
+ if (!object(override) || Object.keys(override).some(key => !["peer", "evidence", "checked", "at"].includes(key)) || !object(evidence) ||
461
+ Object.keys(evidence).some(key => !["projectId", "peer", "sessionId", "kind", "checkedPath"].includes(key)) || override.checked !== "missing" ||
462
+ !Number.isFinite(override.at) || override.at < 0 || override.peer !== evidence.peer || !["rollout", "transcript"].includes(evidence.kind) ||
463
+ !op.audit?.some(entry => entry.action === "fresh-session" && entry.peer === override.peer && entry.at === override.at && entry.projects.includes(evidence.projectId))) throw new Error("invalid fresh override evidence");
464
+ }
465
+ if (op.blockedReattachment !== undefined) {
466
+ const selected = op.blockedReattachment;
467
+ if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || !["codex", "pi", "claude"].includes(String(selected.peer)) ||
468
+ !op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
469
+ }
347
470
  }
348
471
 
349
472
  export interface RecoveryDriver {
350
- stage(op: RecoveryOperation): Promise<{ root: string; digest: string }>;
351
- inspect(project: Project): Promise<Inspection>;
352
- prepare(project: Project, operation: string, instance: string): Promise<void>;
473
+ stage(op: RecoveryOperation): Promise<{ root: string; digest: string; targetFeatures?: readonly string[] }>;
474
+ inspect(project: Project, targetFeatures?: readonly string[]): Promise<Inspection>;
475
+ prepare(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
353
476
  abort(project: Project, operation: string, instance: string): Promise<void>;
354
- commit(project: Project, operation: string, instance: string): Promise<void>;
477
+ commit(project: Project, operation: string, instance: string, targetFeatures?: readonly string[]): Promise<void>;
355
478
  closeTerminals(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save: () => void): Promise<void>;
356
479
  start(project: Project, op: RecoveryOperation): Promise<void>;
357
- restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
480
+ restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void, onlyPeer?: RecoveryNativePeer): Promise<void>;
358
481
  installPlugin(op: RecoveryOperation): Promise<void>;
359
482
  installGlobal(op: RecoveryOperation): Promise<void>;
360
483
  refreshManager?(op: RecoveryOperation): Promise<void>;
361
484
  release(project: Project, operation: string, instance: string): Promise<void>;
362
- verify(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation): Promise<void>;
485
+ verify(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save?: () => void): Promise<void>;
486
+ /** Read-only positive store evidence; absence of the port means unknown. */
487
+ recordedSessionState?(binding: TerminalBinding, kind: "rollout" | "transcript"): Promise<"found" | "missing" | "unknown">;
363
488
  /** #215 stop-and-archive: stop this operation's unreleased target (when given) and archive its committed snapshot. */
364
- stopAndArchive(project: Project, op: RecoveryOperation, targetInstance?: string): Promise<void>;
489
+ stopAndArchive(project: Project, op: RecoveryOperation, targetInstance?: string): Promise<void | { continuity: "not-archived" | "unavailable" }>;
365
490
  sleep(ms: number): Promise<void>;
366
491
  now(): number;
492
+ /** Trusted per-invocation delegation, supplied only by the CLI's identity preflight. */
493
+ readonly liveTestPeer?: string;
367
494
  }
368
495
 
369
496
  /** Resumable state machine. Driver actions are preceded by receipts and followed by live readback. */
@@ -383,6 +510,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
383
510
  return op;
384
511
  }
385
512
  } catch (error) { releaseRunner(); throw error; }
513
+ let targetFeatures: readonly string[] = [];
386
514
  const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
387
515
  const step = (value: string) => { op.step = value; save(); };
388
516
  // #225: one restart per project per resume, so a target that keeps dying never spins unattended.
@@ -421,9 +549,20 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
421
549
  };
422
550
  // Prepare, or after an expired lease re-prepare, the planned source and wait until it is quiet.
423
551
  const prepareSource = async (planned: PlannedProject, progress: ProjectProgress, again = false) => {
552
+ const saveTiming = () => {
553
+ try { save(); }
554
+ catch { console.warn("Recovery timing observation could not be saved; recovery continues"); }
555
+ };
424
556
  const project = planned.project, instance = planned.source.instanceId!;
425
- await driver.prepare(project, id, instance);
426
- const deadline = driver.now() + idleTimeoutMs;
557
+ await driver.prepare(project, id, instance, targetFeatures);
558
+ const startedAt = driver.now(), deadline = startedAt + idleTimeoutMs;
559
+ const waitStep = op.step;
560
+ if (waitStep) {
561
+ (op.waits ??= {})[waitStep] = { waitStartedAt: new Date(startedAt).toISOString(), deadlineAt: new Date(deadline).toISOString() };
562
+ saveTiming();
563
+ }
564
+ let waitFailed = false;
565
+ try {
427
566
  for (;;) {
428
567
  const live = await driver.inspect(project);
429
568
  if (live.instanceId !== instance) throw new Error(`${project.id}: source daemon changed during preparation`);
@@ -439,26 +578,43 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
439
578
  }
440
579
  await driver.sleep(250);
441
580
  }
581
+ } catch (error) { waitFailed = true; throw error; }
582
+ finally {
583
+ const finish = () => {
584
+ if (!waitStep) return;
585
+ op.waits![waitStep] = { ...op.waits![waitStep]!, waitEndedAt: new Date(driver.now()).toISOString() };
586
+ saveTiming();
587
+ };
588
+ // A best-effort end observation must not replace the error that stopped the actual wait.
589
+ if (waitFailed) { try { finish(); } catch { /* original wait failure remains authoritative */ } }
590
+ else finish();
591
+ }
442
592
  };
443
593
  try {
444
594
  if (op.disposition) {
445
595
  // The stop-and-archive in progress keeps its own cause; resume never runs an operation being abandoned.
446
596
  op.error ??= `a stop-and-archive of this operation is partway; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
447
597
  delete op.failure;
598
+ delete op.presentationCause;
448
599
  op.phase = "blocked"; save();
449
600
  return op;
450
601
  }
451
- op.phase = "running"; delete op.error; delete op.failure; save();
602
+ op.phase = "running"; delete op.error; delete op.failure; delete op.blockedReattachment; delete op.presentationCause;
603
+ delete op.waits; // Timing is the current attempt's observation, never a prior attempt's countdown.
604
+ save();
452
605
  step("stage");
453
606
  const target = await driver.stage(op);
454
607
  if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new FinalRefusal("staged target changed since operation started");
455
- op.targetRoot = target.root; op.targetDigest = target.digest; save();
608
+ targetFeatures = target.targetFeatures ?? op.targetFeatures ?? [];
609
+ op.targetRoot = target.root; op.targetDigest = target.digest;
610
+ if (target.targetFeatures !== undefined) op.targetFeatures = [...target.targetFeatures];
611
+ save();
456
612
 
457
- // Check every untouched source before stopping even the first project.
613
+ // Pending sources receive the verified target features before holds or terminal/runtime effects.
458
614
  for (let i = 0; i < op.projects.length; i++) {
459
615
  const progress = op.projects[i]!, planned = op.plan.projects[i]!;
460
616
  if (progress.phase !== "pending") continue;
461
- const live = await driver.inspect(planned.project);
617
+ const live = await driver.inspect(planned.project, targetFeatures);
462
618
  if (!["running", "stopped", "missing"].includes(live.state)) throw new Error(`${planned.project.id}: the source reads as ${live.state}; wait until it answers`);
463
619
  if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
464
620
  throw new Error(`${planned.project.id}: source runtime changed`);
@@ -486,7 +642,14 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
486
642
  }
487
643
  if (progress.phase === "prepared") {
488
644
  step(`commit:${project.id}`);
489
- let live = await driver.inspect(project);
645
+ let live: Inspection;
646
+ try { live = await driver.inspect(project); }
647
+ catch (error) {
648
+ if (!progress.commitSent || !isDatabaseBusy(error)) throw error;
649
+ live = { state: "unavailable", peers: [], blockers: ["database_busy"] };
650
+ }
651
+ const resumedShutdown = progress.commitSent && live.state === "unavailable";
652
+ let awaitShutdown = resumedShutdown;
490
653
  if (live.state === "running" && live.instanceId === planned.source.instanceId) {
491
654
  if (live.recovery?.operationId !== id || !live.recovery.ready) {
492
655
  const other = live.recovery?.operationId;
@@ -503,8 +666,22 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
503
666
  await driver.closeTerminals(planned, progress, op, save);
504
667
  // Durable, unlike `step`: from here the source may have committed, which status and abort must not forget.
505
668
  progress.commitSent = true; save();
506
- await driver.commit(project, id, planned.source.instanceId!);
669
+ await driver.commit(project, id, planned.source.instanceId!, targetFeatures);
670
+ awaitShutdown = true;
671
+ } else if (live.state === "running") {
672
+ throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
673
+ } else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
674
+ // #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
675
+ // prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
676
+ throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
677
+ }
678
+ if (awaitShutdown) {
507
679
  const deadline = driver.now() + 30_000;
680
+ // An already-sent commit can be between its final reply and the source closing its control server.
681
+ if (resumedShutdown) {
682
+ if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
683
+ await driver.sleep(100);
684
+ }
508
685
  do {
509
686
  try { live = await driver.inspect(project); }
510
687
  catch (error) {
@@ -517,12 +694,6 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
517
694
  if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error(`${project.id}: another daemon started during shutdown`);
518
695
  await driver.sleep(100);
519
696
  } while (driver.now() < deadline);
520
- } else if (live.state === "running") {
521
- throw new Error(`${project.id}: the source daemon was replaced by instance ${live.instanceId ?? "unknown"}; refusing to prepare, close or stop it (ending this operation leaves that daemon running)`);
522
- } else if (live.state === "stopped" && !progress.commitSent && (!op.sourceRoot || coordinatorCurrent(op.sourceRoot))) {
523
- // #215: this coordinator records a commit request before sending it, so a stopped source with none crashed while
524
- // prepared: no snapshot was committed and there is nothing to start from. The phase stays `prepared`.
525
- throw new Error(`${project.id}: the source stopped while prepared and no commit was requested, so there is nothing to restore`);
526
697
  }
527
698
  if (live.state === "missing") throw new Error(`${project.id}: the project directory is missing`);
528
699
  if (live.state !== "stopped") throw new Error(`${project.id}: old shutdown is not verified (the source reads as ${live.state}); not starting a second daemon; check ${recoveryCommand(op, "status")} once it answers`);
@@ -561,7 +732,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
561
732
  }
562
733
  step(`restore-claude:${planned.project.id}`);
563
734
  await driver.restore(planned, progress, op, "claude", save);
564
- await driver.verify(planned, progress, op);
735
+ await driver.verify(planned, progress, op, save);
565
736
  step(`release:${planned.project.id}`);
566
737
  progress.releaseRequested = true; save();
567
738
  await driver.release(planned.project, id, progress.instanceId!);
@@ -574,27 +745,106 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
574
745
  releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
575
746
  } catch (error) {
576
747
  // #215: every runner error ends with the choices status shows, read from every open source, never only this one.
748
+ const presentationError = error instanceof RecoveryPresentationError ? error
749
+ : error instanceof ReattachmentRequired && error.presentationError instanceof RecoveryPresentationError ? error.presentationError : undefined;
750
+ if (presentationError) op.presentationCause = presentationError.presentationCause;
751
+ else delete op.presentationCause;
752
+ if (error instanceof MissingRecordedSession) op.missingRecordedSession = error.evidence;
753
+ if (error instanceof ReattachmentRequired) op.blockedReattachment = { projectId: error.projectId, peer: error.peer };
754
+ else delete op.blockedReattachment;
577
755
  if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
578
756
  if (error instanceof FinalRefusal) op.final = true;
757
+ op.phase = "blocked";
579
758
  const next = nextActionsText(op, await liveProjects(op, driver.inspect));
580
- op.phase = "blocked"; op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
759
+ op.error = `${error instanceof Error ? error.message : "recovery failed"}; ${next}`; save();
581
760
  } finally { releaseRunner(); }
582
761
  return op;
583
762
  }
584
763
 
585
764
  /** Receipts and runner state only (#215): ids, phases and effects, never task or message text. */
586
765
  export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unknown", live: Record<string, Inspection | undefined> = {}) {
587
- const effect = (value: unknown) => value === "pending" || value === "failed" ? value : "done";
588
- const next = nextActions(op, runnerPid, live);
766
+ const effect = (key: string, value: unknown) => key.startsWith("restored:") ? restoreReceiptStatus(value) : value === "pending" || value === "failed" ? value : "done";
767
+ const choices = nextChoices(op, runnerPid, live), next = choices.map(choice => choice.text);
589
768
  return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
590
769
  runner: runnerPid === "unknown" ? { state: "unknown" } : runnerPid ? { state: "running", pid: runnerPid } : { state: "none" },
591
770
  ...(op.phase === "running" && !runnerPid ? { stale: "the receipt says running but no runner holds the operation: its last runner stopped mid-step" } : {}),
592
- 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)])) } : {}),
771
+ 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(key, value)])) } : {}),
772
+ ...(op.plan.projects.find(planned => planned.project.id === p.id) ? { recordedSessions: recordedPeerGuidance(op.plan.projects.find(planned => planned.project.id === p.id)!, p, {
773
+ ...(op.phase === "blocked" && !op.disposition ? { stopAndArchiveCommand: recoveryCommand(op, "dispose", STOP) } : {}),
774
+ reattachCommand: peer => op.phase === "blocked" && !op.disposition && !op.final && op.targetRoot && op.targetDigest && p.instanceId && ["started", "peers-restored"].includes(p.phase) &&
775
+ op.blockedReattachment?.projectId === p.id && op.blockedReattachment.peer === peer && choices.some(choice => choice.kind === "reattach" && choice.peer === peer)
776
+ ? recoveryCommand(op, "reattach", peer) : undefined,
777
+ }) } : {}),
593
778
  ...(p.fresh ? { lostContinuity: Object.fromEntries(Object.entries(p.fresh).map(([peer, f]) => [peer, f.lost])) } : {}),
594
779
  ...(p.restarts?.length ? { restarts: p.restarts.length } : {}) })),
595
780
  pluginInstalled: !!op.pluginInstalled, globalInstalled: !!op.globalInstalled,
596
781
  ...(!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` } : {}),
597
- ...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), next };
782
+ ...(op.error ? { error: op.error } : {}), ...(op.failure ? { failure: op.failure } : {}), ...(op.blockedReattachment ? { blockedReattachment: op.blockedReattachment } : {}), ...(op.disposition ? { disposition: op.disposition } : {}), ...(op.audit ? { audit: op.audit } : {}), ...(op.freshOverride ? { freshOverride: op.freshOverride } : {}), ...(op.missingRecordedSession ? { missingRecordedSession: op.missingRecordedSession } : {}),
783
+ ...(op.waits ? { waits: op.waits } : {}), ...(op.presentationCause ? { presentationCause: op.presentationCause } : {}), next };
784
+ }
785
+
786
+ /**
787
+ * Person-command entry: restore one recorded conversation under this operation's existing hold.
788
+ * The driver settles and records the selected native identity; this entry never performs global verification,
789
+ * release, installation or completion. A later normal resume owns those steps.
790
+ */
791
+ export async function reattachRecovery(id: string, peer: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
792
+ let op = readOperation<RecoveryOperation>(id, home);
793
+ validateReceipt(id, op);
794
+ const selection = () => {
795
+ if (!["codex", "pi", "claude"].includes(peer) || op.phase !== "blocked" || op.disposition || op.final || op.blockedReattachment?.peer !== peer) throw new Error("reattach is available only for the peer named by a blocked reattachment receipt");
796
+ const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
797
+ const planned = op.plan.projects.find(project => project.project.id === progress?.id);
798
+ const native = peer as RecoveryNativePeer;
799
+ const marker = progress?.reattachments?.[native];
800
+ if (!progress || !planned || !marker || !["started", "peers-restored"].includes(progress.phase) || !progress.instanceId || !op.targetRoot || !op.targetDigest ||
801
+ (marker.state !== "ready" && !repairWaiting(op, progress.id, native))) throw new Error("recorded reattachment is unavailable or its single relaunch allowance was already used; resume to inspect the saved receipt");
802
+ return { progress, planned, marker, native };
803
+ };
804
+ selection(); // Finished or unrelated operations must not acquire a new lock merely to refuse.
805
+ acquireRecoveryLock(id, home);
806
+ const releaseRunner = claimRunner(id, home);
807
+ let attempted = false;
808
+ const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
809
+ try {
810
+ op = readOperation<RecoveryOperation>(id, home);
811
+ validateReceipt(id, op);
812
+ const { progress, planned, marker, native } = selection();
813
+ const expected = structuredClone(marker.binding);
814
+ const target = async () => {
815
+ const observed = await driver.inspect(planned.project);
816
+ if (observed.state !== "running" || observed.instanceId !== progress.instanceId || observed.version !== op.plan.version || observed.recovery?.operationId !== id || observed.recovery.phase === "released") throw new Error("reattach target changed or is not held by this operation; no replacement runtime is accepted");
817
+ };
818
+ await target();
819
+ attempted = true;
820
+ await driver.restore(planned, progress, op, native === "claude" ? "claude" : "native", save, native);
821
+ // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
822
+ // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
823
+ const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
824
+ if (restored?.state !== "done" || restored.binding.peer !== native || restored.binding.projectRoot !== expected.projectRoot ||
825
+ restored.binding.sessionId !== expected.sessionId || restored.binding.sessionFile !== expected.sessionFile) throw new Error(`${native}: recorded session reattachment was not settled with its exact identity`);
826
+ await target();
827
+ delete op.blockedReattachment; delete op.failure; delete op.presentationCause;
828
+ op.phase = "blocked";
829
+ op.error = `${native}: recorded session reattached; resume to finish verification, release and any remaining installation; ${recoveryCommand(op, "resume")}`;
830
+ save();
831
+ return op;
832
+ } catch (error) {
833
+ if (attempted) {
834
+ const presentationError = error instanceof RecoveryPresentationError ? error
835
+ : error instanceof ReattachmentRequired && error.presentationError instanceof RecoveryPresentationError ? error.presentationError : undefined;
836
+ if (presentationError) op.presentationCause = presentationError.presentationCause;
837
+ else delete op.presentationCause;
838
+ if (error instanceof MissingRecordedSession) op.missingRecordedSession = error.evidence;
839
+ if (error instanceof ReattachmentRequired) op.blockedReattachment = { projectId: error.projectId, peer: error.peer };
840
+ else delete op.blockedReattachment;
841
+ if (isDatabaseBusy(error)) { op.failure = { code: "database_busy", retryable: true, site: databaseBusySite(error) }; error = new RecoveryDatabaseBusy(); }
842
+ op.phase = "blocked";
843
+ op.error = `${error instanceof Error ? error.message : "reattachment is unconfirmed"}; ${nextActionsText(op, await liveProjects(op, driver.inspect))}`;
844
+ save();
845
+ }
846
+ throw error;
847
+ } finally { releaseRunner(); }
598
848
  }
599
849
 
600
850
  /** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
@@ -624,7 +874,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
624
874
  const state = live[planned.project.id];
625
875
  if (state?.recovery?.operationId === id && state.recovery.phase !== "released") await driver.abort(planned.project, id, planned.source.instanceId!);
626
876
  }
627
- delete op.error; delete op.failure;
877
+ delete op.error; delete op.failure; delete op.blockedReattachment; delete op.presentationCause;
628
878
  op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
629
879
  writeOperation(id, op, home); releaseRecoveryLock(id, home);
630
880
  } catch (error) {
@@ -639,15 +889,18 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
639
889
  * every runtime this operation held or started is verified released or stopped.
640
890
  */
641
891
  export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
642
- let op = readOperation<RecoveryOperation>(id, home);
643
- validateReceipt(id, op);
892
+ let op: RecoveryOperation;
893
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
894
+ catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
895
+ if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
644
896
  // A finished operation owns no lock: never take one only to refuse.
645
897
  if (op.phase === "completed" || op.phase === "cancelled") throw new Error(`operation is already ${op.phase}`);
646
898
  acquireRecoveryLock(id, home);
647
899
  const releaseRunner = claimRunner(id, home);
648
900
  try {
649
- op = readOperation<RecoveryOperation>(id, home);
650
- validateReceipt(id, op);
901
+ try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op); }
902
+ catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
903
+ if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
651
904
  if (op.phase === "completed" || op.phase === "cancelled") {
652
905
  // Another runner finished it while this one waited: leave no lock behind, as resume and abort do.
653
906
  if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
@@ -660,7 +913,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
660
913
  const next = nextActionsText(op, live);
661
914
  if ("fresh" in choice) {
662
915
  // `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
663
- if (choice.fresh === "pi") throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${next}`);
916
+ if (choice.fresh === "pi") throw new Error(`pi: --fresh-session is not supported: a restored hub resumes Pi's recorded session, so a fresh one cannot be guaranteed; ${op.plan.projects.map(planned => piArchiveExit(planned, recoveryCommand(op, "dispose", STOP))).join("; ")}; ${next}`);
664
917
  if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
665
918
  if (!targetReadsWaivers(op)) throw new Error(`--fresh-session is not available: target ${op.plan.version} cannot read recovery waivers, so it could not release a new session; ${next}`);
666
919
  // The new session is launched by resume, so it helps only where resume can get past what is live.
@@ -669,19 +922,39 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
669
922
  const peer = choice.fresh;
670
923
  // Per project: a planned fresh start has nothing to lose, so only the other failed projects take the choice.
671
924
  const failed = op.projects.filter((p) => failedPeers(op, p, live).includes(peer));
672
- if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
925
+ if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && op.plan.projects.find((planned) => planned.project.id === p.id)?.freshStart?.includes(peer))) {
673
926
  throw new Error(`${peer}: its plan already restarts it as a new session (no rollout and no turn, nothing to lose), so there is no conversation to record as lost; ${next}`);
674
927
  }
675
- if (!failed.length && op.projects.some((p) => p.terminals[`restored:${peer}`] === "failed" && plannedAttached(op, p, peer, live))) {
928
+ if (!failed.length && op.projects.some((p) => canChooseFresh(p.terminals[`restored:${peer}`]) && plannedAttached(op, p, peer, live))) {
676
929
  throw new Error(`${peer}: its planned session is attached again, so nothing was lost; resume records it as restored; ${next}`);
677
930
  }
678
931
  if (!failed.length) throw new Error(`${peer}: no failed restoration of it is open to a fresh session (none failed, or one is already chosen); --fresh-session applies only then (${recoveryCommand(op, "status")})`);
932
+ // Collect every proof before changing any project, so one uncertain read cannot leave a partial choice.
933
+ const selected = [];
679
934
  for (const progress of failed) {
680
- const binding = (op.plan.projects.find((p) => p.project.id === progress.id)!.terminals as { peer: string; sessionId: string }[]).find((t) => t.peer === peer)!;
681
- (progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at };
935
+ const repaired = missingRepair(op, progress, peer);
936
+ const planned = op.plan.projects.find((p) => p.project.id === progress.id)!;
937
+ const binding = repaired ? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)!
938
+ : (planned.terminals as TerminalBinding[]).find((t) => t.peer === peer)!;
939
+ const evidence = repaired ? { ...op.missingRecordedSession!, checkedPath: checkedSessionPath(binding, op.missingRecordedSession!.kind) } : undefined;
940
+ if (evidence && (op.missingRecordedSession!.checkedPath !== undefined && op.missingRecordedSession!.checkedPath !== evidence.checkedPath ||
941
+ await driver.recordedSessionState?.(binding, evidence.kind) !== "missing")) throw new Error(`${peer}: recorded conversation is not positively missing at ${JSON.stringify(evidence.checkedPath)}; no fresh-session choice was saved`);
942
+ selected.push({ progress, binding, repaired, evidence });
943
+ }
944
+ for (const { progress, binding, repaired, evidence } of selected) {
945
+ (progress.fresh ??= {})[peer] = { lost: binding.sessionId, reason, at, ...(repaired ? { repaired: true as const } : {}) };
946
+ if (repaired) {
947
+ (progress.repairedSessions ??= {})[peer as RecoveryNativePeer] = true;
948
+ op.freshOverride = { peer: peer as RecoveryNativePeer, evidence: evidence!, checked: "missing", at };
949
+ }
950
+ if (repaired && progress.reattachments?.[peer as RecoveryNativePeer]) {
951
+ progress.terminals[`retired:${peer}`] = binding;
952
+ delete progress.terminals[`closedRetired:${peer}`];
953
+ delete progress.reattachments[peer as RecoveryNativePeer];
954
+ }
682
955
  }
683
956
  (op.audit ??= []).push({ at, action: "fresh-session", reason, peer, projects: failed.map((p) => p.id) });
684
- delete op.error; delete op.failure;
957
+ delete op.error; delete op.failure; delete op.blockedReattachment; delete op.presentationCause; delete op.missingRecordedSession;
685
958
  op.updatedAt = at; writeOperation(id, op, home);
686
959
  return op;
687
960
  }
@@ -707,7 +980,9 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
707
980
  acts.push({ planned, ...(state ? { live: state } : {}), act });
708
981
  }
709
982
  // Each outcome is recorded as it happens, so a disposition that stops partway stays true and resume refuses it.
710
- const disposition = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
983
+ const disposition: NonNullable<RecoveryOperation["disposition"]> = (op.disposition ??= { choice: "stop-and-archive", at, projects: {} });
984
+ delete op.blockedReattachment;
985
+ delete op.presentationCause;
711
986
  abandoned(op); // #227: in the same write as the disposition
712
987
  (op.audit ??= []).push({ at, action: "stop-and-archive", reason, projects: acts.map((a) => a.planned.project.id) });
713
988
  // Recorded before the first act: a crash inside a stop leaves a disposition that resume refuses to override.
@@ -717,7 +992,8 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
717
992
  for (const { planned, live, act } of acts) {
718
993
  if (act.startsWith("source hold")) await driver.abort(planned.project, id, planned.source.instanceId!);
719
994
  else if (act === "stopped" || act === "target stopped") {
720
- await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
995
+ const outcome = await driver.stopAndArchive(planned.project, op, act === "target stopped" ? live!.instanceId : undefined);
996
+ if (outcome && outcome.continuity) (disposition.continuity ??= {})[planned.project.id] = outcome.continuity;
721
997
  if ((await driver.inspect(planned.project)).state !== "stopped") throw new Error(`${planned.project.id}: shutdown of instance ${live!.instanceId ?? "unknown"} is not verified`);
722
998
  }
723
999
  disposition.projects[planned.project.id] ??= act;