@staix/agent-hub 0.12.19 → 0.12.21

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 (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -1,15 +1,20 @@
1
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
3
+ import { basename, join, resolve } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
6
- import { inspectProject } from "../hub/lifecycle.ts";
6
+ import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
7
7
  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
- import { inspectTerminals, closeTerminal, createTerminal, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
11
- import { planFingerprint, registeredProjects, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
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";
12
+ import { readEvents } from "../hub/events.ts";
12
13
  import { refreshManager } from "../hub/manager.ts";
14
+ import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
15
+
16
+ /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
17
+ const RECONNECT_WAIT_MS = 90_000;
13
18
 
14
19
  export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
15
20
  const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
@@ -25,6 +30,42 @@ function claudeTranscriptExists(binding: TerminalBinding): boolean {
25
30
  return existsSync(join(config, "projects", slug, `${binding.sessionId}.jsonl`));
26
31
  }
27
32
 
33
+ /**
34
+ * #215: `codex resume <id>` reads the thread's rollout from the store its launcher uses (the captured CODEX_HOME, else
35
+ * the default), so an app-server thread id alone proves nothing.
36
+ * ponytail: a `.jsonl` under sessions/ whose name carries the thread id is the evidence; ask Codex itself once it offers
37
+ * a supported resumability query, or when its store layout changes.
38
+ */
39
+ const codexSessions = (binding: TerminalBinding) => join(binding.launch.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
40
+ function codexTranscript(binding: TerminalBinding): "found" | "missing" | "unknown" {
41
+ try { return readdirSync(codexSessions(binding), { recursive: true }).some((name) => String(name).endsWith(".jsonl") && basename(String(name)).includes(binding.sessionId)) ? "found" : "missing"; }
42
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; } // no store has no rollout; an unreadable one says nothing
43
+ }
44
+ const unresumable = (binding: TerminalBinding) => codexTranscript(binding) === "unknown"
45
+ ? `codex: the session store ${codexSessions(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
46
+ : `codex: thread ${binding.sessionId} has no resumable transcript under ${codexSessions(binding)}`;
47
+
48
+ const closeUnsettled = (binding: TerminalBinding, seen: "listed" | "unreadable") => seen === "listed"
49
+ ? `${binding.peer}: terminal close outcome needs manual reconciliation: Orca still lists terminal ${binding.handle}; close that terminal (the login shell it runs in) by hand in Orca first`
50
+ : `${binding.peer}: Orca's terminal list could not be read, so whether terminal ${binding.handle} is closed is unknown; check it in Orca (close it by hand, with the login shell it runs in, if it is still open) first`;
51
+
52
+ /**
53
+ * #215: Codex writes a thread's rollout with its first message, so a thread this hub saw Codex start (`native_thread`
54
+ * with `fresh`) on which no turn ran has nothing to lose. A codex turn_start belongs to the thread most recently
55
+ * adopted before it; detaching forgets nothing. A thread whose start the log does not show (resumed, older hub, pruned
56
+ * log) is unsure, and unsure counts as turned.
57
+ */
58
+ function codexZeroTurn(stateDir: string, thread: string): boolean {
59
+ let current: string | undefined, started = false, turned = false;
60
+ for (const e of readEvents(join(stateDir, "events.jsonl"))) {
61
+ if (e.type === "native_thread" && e.peer === "codex") {
62
+ current = e.thread;
63
+ if (e.thread === thread && e.fresh) started = true;
64
+ } else if (e.type === "turn_start" && e.peer === "codex" && current === thread) turned = true;
65
+ }
66
+ return started && !turned;
67
+ }
68
+
28
69
  function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
29
70
  return { orcaExecutable: orcaExecutable(), runner: async (args) => {
30
71
  const result = await run([orcaExecutable(), ...args], { timeoutMs: 610_000 });
@@ -32,9 +73,10 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
32
73
  } };
33
74
  }
34
75
 
35
- export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding): string[] {
36
- if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", "resume", binding.sessionId];
37
- if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", "--resume", binding.sessionId];
76
+ /** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
77
+ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
78
+ if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", ...(fresh ? [] : ["resume", binding.sessionId])];
79
+ if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", ...(fresh ? [] : ["--resume", binding.sessionId])];
38
80
  return [process.execPath, entrypoint, "--project", projectRoot, "pi", "--mode", "tui",
39
81
  ...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
40
82
  ...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
@@ -50,13 +92,21 @@ async function rpc(project: Project, message: Record<string, unknown>, protocol
50
92
  }
51
93
 
52
94
  export async function inspectRecovery(project: Project): Promise<Inspection> {
53
- const base = await inspectProject(project);
95
+ // #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
96
+ // pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
97
+ // caller reads that as not inspected, never as stopped.
98
+ const claim = registeredProjects().find((p) => p.id === project.id);
99
+ const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null });
54
100
  const control = readControl(project.stateDir);
55
101
  const sourceProtocol = control?.protocol;
56
102
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
57
- if (base.state !== "running" && !legacySupported) return {
103
+ // A stopped runtime reports whose unreleased restart snapshot it holds: only that one can start it again (#215).
104
+ const snapshot = base.state === "stopped" ? readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id })?.operationId : undefined;
105
+ // A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
106
+ // "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
107
+ if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) return {
58
108
  state: base.state, peers: [], blockers: base.state === "stopped" ? [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
59
- ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}),
109
+ ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
60
110
  };
61
111
  let status = base.status;
62
112
  if (!status && legacySupported) {
@@ -114,11 +164,39 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
114
164
  catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
115
165
  if (source.state === "stopped" || source.state === "missing") continue;
116
166
  const blockers = [...source.blockers];
117
- if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14 or protocol-15 source is required");
167
+ if (!RECOVERY_SOURCE_PROTOCOLS.includes(source.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated protocol-9, protocol-10, protocol-11, protocol-12, protocol-13, protocol-14, protocol-15 or protocol-16 source is required");
118
168
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
119
169
  const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
170
+ const reconnectOnly: string[] = [];
120
171
  for (const peer of source.peers) {
121
172
  if (peer.state === "offline") continue;
173
+ if (peer.id === "claude") {
174
+ // #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
175
+ // that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
176
+ // ponytail: the channel's hello carries no launch id, so a plain `claude` that took the peer id from a live
177
+ // managed session reads as managed; send the launch id at hello (a protocol bump) if that case shows up.
178
+ const recorded = await launcherOf("claude", project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
179
+ if (recorded?.state === "unknown") {
180
+ // An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
181
+ delete peer.sessionId;
182
+ 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`);
183
+ continue;
184
+ }
185
+ const launcher = recorded?.state === "live" ? recorded.record! : undefined;
186
+ if (!launcher) {
187
+ delete peer.sessionId;
188
+ if (source.protocol === PROTOCOL) reconnectOnly.push("claude");
189
+ else blockers.push(`claude: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
190
+ continue;
191
+ }
192
+ let record: { instanceId?: unknown; launchId?: unknown; sessionId?: unknown } | undefined;
193
+ try { record = JSON.parse(readFileSync(join(project.stateDir, "claude-session.json"), "utf8")); } catch { /* no record: the id check below reports it */ }
194
+ if (peer.sessionId && (record?.instanceId !== source.instanceId || record?.launchId !== launcher.launchId || record?.sessionId !== peer.sessionId)) {
195
+ delete peer.sessionId;
196
+ blockers.push(`claude: the recorded session was not written by the live launcher in terminal ${launcher.handle}; manual-required; next action: close any other Claude session in this project, send one message in that terminal, then make a new plan`);
197
+ continue;
198
+ }
199
+ }
122
200
  if (peer.id === "codex" || peer.id === "claude") {
123
201
  const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
124
202
  if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
@@ -132,7 +210,16 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
132
210
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
133
211
  });
134
212
  blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
135
- body.projects.push({ project, source, terminals: terminals.bindings, blockers });
213
+ const freshStart: string[] = [];
214
+ for (const binding of terminals.bindings) if (binding.peer === "codex") {
215
+ const transcript = codexTranscript(binding);
216
+ if (transcript === "found") continue;
217
+ if (transcript === "missing" && codexZeroTurn(project.stateDir, binding.sessionId)) { freshStart.push("codex"); continue; }
218
+ blockers.push(transcript === "unknown"
219
+ ? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
220
+ : `${unresumable(binding)}, and the hub cannot show that no turn ran on it, so its conversation may not come back after the restart; manual-required; next action: end that Codex session and close its Orca terminal ${binding.handle} (ahub codex starts a new one later), then make a new plan`);
221
+ }
222
+ body.projects.push({ project, source, terminals: terminals.bindings, blockers, ...(reconnectOnly.length ? { reconnectOnly } : {}), ...(freshStart.length ? { freshStart } : {}) });
136
223
  }
137
224
  if (!body.projects.length) body.blockers.push("no running registered projects in scope");
138
225
  return { ...body, fingerprint: planFingerprint(body) };
@@ -208,14 +295,96 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
208
295
  if (session !== saved.sessionId) throw new Error(`${saved.peer}: daemon session changed during terminal revalidation; manual-required`);
209
296
  return current;
210
297
  };
298
+ /** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
299
+ const listed = async (binding: TerminalBinding): Promise<"listed" | "absent" | "unreadable"> => {
300
+ const result = await run([orcaExecutable(), "terminal", "list", "--json"]);
301
+ let inventory: any;
302
+ try { inventory = JSON.parse(result.stdout); } catch { return "unreadable"; }
303
+ const rows = inventory?.result?.terminals;
304
+ if (result.code !== 0 || inventory?.ok !== true || !Array.isArray(rows) || inventory.result?.truncated) return "unreadable";
305
+ return rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId) ? "listed" : "absent";
306
+ };
307
+ /**
308
+ * #215: what runs for a peer on the target, the one place restore reads it. Only a running target answering as the
309
+ * expected instance can report "gone"; an unreadable target or launcher is unknown and blocks, never gone.
310
+ */
311
+ const peerEvidence = async (planned: PlannedProject, progress: ProjectProgress, peer: TerminalBinding["peer"]):
312
+ Promise<{ state: "live"; session?: string; handle?: string; attached?: true } | { state: "gone" } | { state: "unknown"; why: string; step?: string }> => {
313
+ const target = await inspectRecovery(planned.project);
314
+ if (target.state !== "running" || target.instanceId !== progress.instanceId) {
315
+ // Only a hub that may still answer is waited for; a stopped, missing or other one is settled by the runner's list.
316
+ const settled = ["running", "stopped", "missing"].includes(target.state);
317
+ return { state: "unknown", why: `the target hub reads as ${target.state === "running" ? `instance ${target.instanceId}, not ${progress.instanceId}` : target.state}`, ...(settled ? {} : { step: "wait until the target answers" }) };
318
+ }
319
+ const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
320
+ const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
321
+ if (session) return { state: "live", session };
322
+ // #225: the launch records of every instance this operation started for the project, the current one first: a
323
+ // launcher a dead instance recorded may still run (read before the check below, so a block names its terminal).
324
+ for (const instanceId of [progress.instanceId, ...(progress.restarts ?? []).map((r) => r.instanceId)]) {
325
+ if (!instanceId) continue;
326
+ const launch = await launcherOf(peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId });
327
+ if (launch?.state === "live") return { state: "live", handle: launch.record!.handle, ...(attached ? { attached: true as const } : {}) };
328
+ if (launch?.state === "unknown") {
329
+ 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}` }
330
+ : 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` }
331
+ : { 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)` };
332
+ }
333
+ }
334
+ // #225: attached with no provable session id (a Claude of a dead instance whose plugin reconnected, or one that has
335
+ // not reported its id yet) is a session of unknown identity, never "nothing attached".
336
+ 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` };
337
+ return { state: "gone" };
338
+ };
339
+ /**
340
+ * #225: the terminal of a done restoration of a target instance that died is closed under the close receipt rules
341
+ * (`closedRetired:<peer>` `pending` until Orca no longer lists it; an unreadable or truncated inventory is unknown and
342
+ * changes nothing) or found absent, before its peer is relaunched against the new instance.
343
+ */
344
+ const closeRetired = async (progress: ProjectProgress, peer: string, save: () => void): Promise<void> => {
345
+ const retired = progress.terminals[`retired:${peer}`] as TerminalBinding | undefined;
346
+ const key = `closedRetired:${peer}`;
347
+ if (!retired || typeof retired !== "object" || progress.terminals[key] === true) return;
348
+ const seen = await listed(retired);
349
+ if (progress.terminals[key] === "pending" || seen !== "listed") {
350
+ if (seen !== "absent") throw new Error(closeUnsettled(retired, seen));
351
+ progress.terminals[key] = true; save(); return;
352
+ }
353
+ const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
354
+ 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`);
355
+ progress.terminals[key] = "pending"; save();
356
+ const result = await closeTerminal(retired, 0, terminalOptions(run));
357
+ if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
358
+ progress.terminals[key] = true; save();
359
+ };
360
+ const attachedId = async (planned: PlannedProject, peer: string, op: RecoveryOperation): Promise<string> => {
361
+ for (const deadline = now() + RECONNECT_WAIT_MS; ;) {
362
+ const current = (await inspectRecovery(planned.project)).peers.find((p) => p.id === peer && p.state !== "offline");
363
+ const id = peer === "codex" ? current?.threadId : current?.sessionId;
364
+ if (id) return id;
365
+ if (now() >= deadline) throw new Error(`${peer}: the new session reported no id within ${RECONNECT_WAIT_MS / 1000} s; wait until it attaches first`);
366
+ await sleep(1000);
367
+ }
368
+ };
211
369
  return {
212
370
  now, sleep, inspect: inspectRecovery,
213
371
  stage: async (op) => {
214
- if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new Error("preserved source changed");
372
+ if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
215
373
  const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version) }
216
374
  : await stageRelease(op.plan.version, op.plan.integrity!, run);
375
+ // #215: the protocol and waiver checks are properties of these bytes. Once they passed (the runner recorded the
376
+ // digest), a resume does not check again, so a transient probe failure cannot refuse an operation for good;
377
+ // changed bytes are the runner's final "staged target changed".
378
+ if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return target;
217
379
  const protocol = await run([process.execPath, "-e", `import { PROTOCOL } from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(PROTOCOL)`]);
218
- if (protocol.code !== 0 || Number(protocol.stdout.trim()) !== PROTOCOL) throw new Error("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
380
+ const read = protocol.code === 0 ? Number.parseInt(protocol.stdout.trim(), 10) : NaN;
381
+ // A probe that did not answer (killed, a null exit code maps to 1) says nothing about the target: retry.
382
+ if (!Number.isInteger(read)) throw new Error(`the staged target's control protocol could not be read (probe exit ${protocol.code}); staged package retained, runtimes unchanged; resume again once it can run`);
383
+ if (read !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
384
+ // Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
385
+ if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
386
+ throw new FinalRefusal(`target ${op.plan.version} predates recovery waivers, so its hub could never accept a reconnect-only Claude or a fresh Codex start; staged package retained, runtimes unchanged; choose a newer target once this operation is cancelled or ended`);
387
+ }
219
388
  return target;
220
389
  },
221
390
  prepare: (p, id, instance) => control(p, "prepare", id, instance),
@@ -227,35 +396,33 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
227
396
  const key = `closed:${binding.peer}`;
228
397
  if (progress.terminals[key] === true) continue;
229
398
  if (progress.terminals[key] === "pending") {
230
- const result = await run([orcaExecutable(), "terminal", "list", "--json"]);
231
- const inventory = JSON.parse(result.stdout);
232
- const rows = inventory.result?.terminals;
233
- if (result.code !== 0 || inventory.ok !== true || !Array.isArray(rows) || inventory.result?.truncated ||
234
- rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId)) {
235
- throw new Error(`${binding.peer}: terminal close outcome needs manual reconciliation`);
236
- }
399
+ const seen = await listed(binding);
400
+ if (seen !== "absent") throw new Error(closeUnsettled(binding, seen)); // the receipt stays pending
237
401
  progress.terminals[key] = true; save(); continue;
238
402
  }
239
403
  // A peer whose TUI already exited left nothing to close: the source reports it
240
404
  // detached, and inventory silence is the same proof the pending-reconcile path
241
405
  // accepts. No mutation is issued for a terminal that no longer exists (#21).
242
- const attached = (await inspectRecovery(planned.project)).peers.find((item) => item.id === binding.peer);
406
+ const sourceNow = await inspectRecovery(planned.project);
407
+ const attached = sourceNow.peers.find((item) => item.id === binding.peer);
243
408
  if (attached?.state === "offline") {
244
- const result = await run([orcaExecutable(), "terminal", "list", "--json"]);
245
- const inventory = JSON.parse(result.stdout);
246
- const rows = inventory.result?.terminals;
247
- if (result.code !== 0 || inventory.ok !== true || !Array.isArray(rows) || inventory.result?.truncated ||
248
- rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId)) {
249
- throw new Error(`${binding.peer}: terminal close outcome needs manual reconciliation`);
250
- }
409
+ const seen = await listed(binding);
410
+ if (seen !== "absent") throw new Error(closeUnsettled(binding, seen));
251
411
  progress.terminals[key] = true; save(); continue;
252
412
  }
413
+ // #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
414
+ const transcript = binding.peer === "codex" ? codexTranscript(binding) : "found";
415
+ if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
416
+ if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
417
+ throw new Error(`${unresumable(binding)}; no terminal was closed. To continue without its conversation, end that Codex session and close its Orca terminal ${binding.handle} first, then resume`);
418
+ }
253
419
  const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
254
- if (!idle.satisfied) throw new Error(`${binding.peer}: terminal is not verified idle; source retained`);
420
+ if (!idle.satisfied) throw new Error(`${binding.peer}: terminal is not verified idle; source retained; finish or cancel its turn first`);
255
421
  const source = await inspectRecovery(planned.project);
256
422
  if (source.instanceId !== planned.source.instanceId || source.recovery?.operationId !== op.id || !source.recovery.ready) {
257
- throw new Error("source preparation expired or changed while waiting for the terminal; no terminal was closed");
258
- } // Refresh the same prepared lease immediately before the terminal effect. Preserve
423
+ throw new Error(`source preparation expired or changed while waiting for the terminal; no terminal was closed`);
424
+ }
425
+ // Refresh the same prepared lease immediately before the terminal effect. Preserve
259
426
  // the original peer roster, including any terminal already closed in this operation.
260
427
  await control(planned.project, "prepare", op.id, planned.source.instanceId!);
261
428
  progress.terminals[key] = "pending"; save();
@@ -269,6 +436,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
269
436
  if (checkpoint.projectId !== p.id || checkpoint.projectRoot !== p.root || checkpoint.operationId !== op.id) {
270
437
  throw new Error("committed restart snapshot is missing or belongs to another operation");
271
438
  }
439
+ const reconnect = op.plan.projects.find((planned) => planned.project.id === p.id)?.reconnectOnly ?? [];
440
+ if (reconnect.length) waiveRecoveryPeers(p.stateDir, op.id, Object.fromEntries(reconnect.map((peer) => [peer, "reconnect-only"])));
272
441
  await command(op, ["up"], p);
273
442
  },
274
443
  restore: async (planned, progress, op, group, save) => {
@@ -288,42 +457,127 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
288
457
  await command(op, args, planned.project);
289
458
  }
290
459
  }
291
- for (const original of planned.terminals as TerminalBinding[]) {
292
- if ((original.peer === "claude") !== (group === "claude")) continue;
293
- const key = `restored:${original.peer}`;
294
- if (progress.terminals[key] && progress.terminals[key] !== "pending") {
295
- progress.terminals[key] = await revalidateTerminal(planned, progress, progress.terminals[key] as TerminalBinding, true); save();
460
+ for (const plannedBinding of planned.terminals as TerminalBinding[]) {
461
+ if ((plannedBinding.peer === "claude") !== (group === "claude")) continue;
462
+ const key = `restored:${plannedBinding.peer}`;
463
+ // #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
464
+ // instead of the planned one (fresh choice, fresh start, zero-turn Claude) is relaunched by that id, which the
465
+ // operation-fenced waiver already covers.
466
+ await closeRetired(progress, plannedBinding.peer, save);
467
+ const retired = progress.terminals[`retired:${plannedBinding.peer}`] as TerminalBinding | undefined;
468
+ const accepted = retired && typeof retired === "object" && retired.sessionId !== plannedBinding.sessionId ? retired.sessionId : undefined;
469
+ const original = accepted ? { ...plannedBinding, sessionId: accepted } : plannedBinding;
470
+ // A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
471
+ // start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
472
+ const chosen = !!progress.fresh?.[original.peer];
473
+ let read: ReturnType<typeof codexTranscript> | undefined;
474
+ const transcript = () => original.peer !== "codex" || (chosen && !accepted) ? "found" : (read ??= codexTranscript(original));
475
+ // An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
476
+ // rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
477
+ const fresh = () => accepted
478
+ ? (original.peer === "codex" ? (chosen || !!planned.freshStart?.includes("codex")) && transcript() === "missing" : original.peer === "claude" && !claudeTranscriptExists(original))
479
+ : chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
480
+ // The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
481
+ // peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
482
+ // The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
483
+ const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
484
+ const notRestored = (why: string) => new Error(fresh()
485
+ ? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
486
+ : `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
487
+ // Anything live or unknown blocks a create and keeps the receipt: never decide on evidence that could not be read.
488
+ const blocked = (evidence: Exclude<Awaited<ReturnType<typeof peerEvidence>>, { state: "gone" }>) => new Error(evidence.state === "unknown"
489
+ ? `${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` : ""}`
490
+ : evidence.session
491
+ ? `${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`
492
+ : `${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`);
493
+ const receipt = progress.terminals[key];
494
+ if (receipt && receipt !== "pending" && receipt !== "failed") {
495
+ progress.terminals[key] = await revalidateTerminal(planned, progress, receipt as TerminalBinding, true); save();
296
496
  continue;
297
497
  }
298
- if (progress.terminals[key] === "pending") {
299
- const observed = await inspectRecovery(planned.project);
300
- const peer = observed.peers.find((p) => p.id === original.peer);
301
- const attached = original.peer === "codex" ? peer?.threadId : peer?.sessionId;
302
- let expected = original;
303
- if (attached !== original.sessionId) {
304
- // #21: a Claude session that never got a first turn has no transcript on disk,
305
- // so `claude --resume S` can never succeed and nothing it stood for is lost by
306
- // accepting the fresh attach. A session WITH a transcript keeps the strict
307
- // identity check. Codex stays strict too: its rollout jsonl is written when the
308
- // thread starts, so an unrestorable thread should not exist.
309
- if (original.peer !== "claude" || !peer || peer.state === "offline" || !attached || claudeTranscriptExists(original)) {
310
- throw new Error(`${original.peer}: terminal creation outcome is uncertain; attach the original session manually, then resume`);
311
- }
312
- expected = { ...original, sessionId: attached };
498
+ // #215: the pending row of the receipt table, used wherever a pending launch meets an attached session.
499
+ const settle = async (attached: string) => {
500
+ // #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
501
+ // succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
502
+ // the operator or the plan accepted, counts as restored.
503
+ if (attached !== original.sessionId && !fresh() && !(original.peer === "claude" && !claudeTranscriptExists(original))) {
504
+ 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`);
505
+ }
506
+ // The planned session came back after all: a fresh session chosen for it lost nothing. Compared with the plan,
507
+ // never with a session accepted after a restart (#225), whose choice must stay on record.
508
+ if (attached === plannedBinding.sessionId && progress.fresh?.[original.peer]) {
509
+ delete progress.fresh[original.peer];
510
+ if (!Object.keys(progress.fresh).length) delete progress.fresh;
313
511
  }
314
- const existing = await revalidateTerminal(planned, progress, expected, false);
315
- progress.terminals[key] = existing; save(); continue;
512
+ // Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
513
+ // a launch writes the waiver first, but this must not rely on that.
514
+ // The planned session needs no waiver: the target's snapshot already expects it.
515
+ if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
516
+ progress.terminals[key] = await revalidateTerminal(planned, progress, { ...original, sessionId: attached }, false); save();
517
+ };
518
+ // #215: a pending or failed receipt is settled by what is live now (the receipt table in the recovery spec).
519
+ if (receipt === "pending" || receipt === "failed") {
520
+ const evidence = await peerEvidence(planned, progress, original.peer);
521
+ if (evidence.state === "live" && evidence.session) { await settle(evidence.session); continue; }
522
+ if (evidence.state !== "gone") throw blocked(evidence);
523
+ if (receipt === "pending") {
524
+ progress.terminals[key] = "failed"; save();
525
+ throw notRestored(`its launcher no longer runs and no ${original.peer} session attached`);
526
+ }
527
+ // failed, nothing live: launched again below; the receipt stays `failed` until `pending` replaces it, so a
528
+ // refusal on the way keeps the failed-restoration choices
529
+ }
530
+ // Never create beside a live launch, nor on evidence that could not be read: read now, not at restore start.
531
+ // The planned session already attached is the restoration (the table's row for it), not a reason to block.
532
+ const evidence = await peerEvidence(planned, progress, original.peer);
533
+ if (evidence.state === "live" && evidence.session === original.sessionId) {
534
+ progress.terminals[key] = await revalidateTerminal(planned, progress, original, false); save();
535
+ continue;
536
+ }
537
+ if (evidence.state !== "gone") throw blocked(evidence);
538
+ if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
539
+ if (transcript() === "missing" && !fresh()) {
540
+ progress.terminals[key] = "failed"; save();
541
+ throw notRestored(unresumable(original).slice("codex: ".length));
542
+ }
543
+ if (fresh()) {
544
+ if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
545
+ // The restored daemon must accept the new session instead of the recorded one.
546
+ waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
316
547
  }
317
548
  const entrypoint = join(op.targetRoot!, "src/cli/main.js");
318
- const argv = restoredTerminalArgv(entrypoint, planned.project.root, original);
549
+ const argv = restoredTerminalArgv(entrypoint, planned.project.root, original, fresh());
319
550
  const assignments = { ...original.launch.env, AGENTHUB_HOME: hubHome(), AGENTHUB_RECOVERY_OPERATION: op.id };
320
551
  const launch = { ...original.launch, packageEntrypoint: entrypoint, argv,
321
552
  command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
322
553
  const binding = { ...original, launch, launchMetadata: launch };
323
554
  progress.terminals[key] = "pending"; save();
324
- const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
325
- if (restored.manualRequired || !restored.newBinding) throw new Error(`${original.peer}: original session restoration needs manual verification`);
326
- progress.terminals[key] = restored.newBinding; save();
555
+ const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
556
+ const exited = restored.blockers.find((b) => b.code === "launcher-exited");
557
+ if (exited) {
558
+ // #215: the launcher's exit alone is not the peer's state (a target that died or flapped also ends it): only
559
+ // nothing live makes it failed; an attached session is settled as the pending row settles it; anything else
560
+ // stays pending.
561
+ const after = await peerEvidence(planned, progress, original.peer);
562
+ if (after.state === "live" && after.session) { await settle(after.session); continue; }
563
+ if (after.state !== "gone") throw blocked(after);
564
+ progress.terminals[key] = "failed"; save();
565
+ throw notRestored(exited.message);
566
+ }
567
+ if (restored.manualRequired || !restored.newBinding) {
568
+ const handle = restored.binding?.handle ?? restored.blockers[0]?.handle;
569
+ throw new Error(`${original.peer}: original session restoration needs manual verification${restored.blockers[0] ? ` (${restored.blockers[0].message})` : ""}; wait until it attaches, or end that launch and close its terminal${handle ? ` ${handle}` : ""} first`);
570
+ }
571
+ // The daemon's id is authoritative: a fresh session is recorded under the id it reports once attached.
572
+ progress.terminals[key] = fresh() ? { ...restored.newBinding, sessionId: await attachedId(planned, original.peer, op) } : restored.newBinding; save();
573
+ }
574
+ if (group === "claude") for (const id of planned.reconnectOnly ?? []) {
575
+ // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
576
+ const deadline = now() + RECONNECT_WAIT_MS;
577
+ while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
578
+ if (now() >= deadline) throw new Error(`${id}: the unmanaged session did not reconnect within ${RECONNECT_WAIT_MS / 1000} s; if that Claude session was closed, start Claude in the project again (its plugin reconnects) first`);
579
+ await sleep(1000);
580
+ }
327
581
  }
328
582
  },
329
583
  installPlugin: async (op) => {
@@ -344,6 +598,18 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
344
598
  const readback = await run(["ahub", "--version"], { env: env(op) });
345
599
  if (readback.code !== 0 || readback.stdout.trim() !== op.plan.version) throw new Error("global CLI version was not verified");
346
600
  },
601
+ stopAndArchive: async (project, op, instance) => {
602
+ if (instance) {
603
+ // #215: a target an older coordinator started speaks its own protocol, which the lifecycle stop's inspection
604
+ // refuses. Ask it to stop at that protocol, fenced by its instance (the daemon refuses another), then wait as
605
+ // the lifecycle stop does until its manifest and registry claim are gone.
606
+ const sourceProtocol = readControl(project.stateDir)?.protocol;
607
+ const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
608
+ await rpc(project, { t: "kill", instanceId: instance }, protocol);
609
+ await awaitStopped(project, instance);
610
+ }
611
+ abandonRestartSnapshot(project.stateDir, op.id);
612
+ },
347
613
  refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
348
614
  verify: async (planned, progress, op) => {
349
615
  const live = await inspectRecovery(planned.project);
@@ -351,6 +617,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
351
617
  for (const old of planned.source.peers.filter((p) => p.state !== "offline")) {
352
618
  const peer = live.peers.find((p) => p.id === old.id);
353
619
  if (!peer || !["idle", "busy", "paused"].includes(peer.state)) throw new Error(`${old.id}: peer reattachment not verified`);
620
+ if (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id)) continue; // #215: a new session was accepted (recorded)
354
621
  if (old.id === "codex" && peer.threadId !== old.threadId) throw new Error("Codex resumed a different conversation");
355
622
  if (old.id === "claude" && peer.sessionId !== old.sessionId) {
356
623
  // #64: the restore gate already accepted a fresh session when the original never
@@ -363,7 +630,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver
363
630
  }
364
631
  for (const original of planned.terminals as TerminalBinding[]) {
365
632
  const saved = progress.terminals[`restored:${original.peer}`];
366
- if (!saved || saved === "pending") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
633
+ if (!saved || typeof saved === "string") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
367
634
  await revalidateTerminal(planned, progress, saved as TerminalBinding, true);
368
635
  }
369
636
  const snapshot = await rpc(planned.project, { t: "ui_snapshot", after: 0 });