@staix/agent-hub 0.12.33 → 0.12.34

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 (100) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +5 -3
  5. package/docs/agent-notes/daemon.md +3 -3
  6. package/docs/agent-notes/tasks.md +2 -1
  7. package/docs/agent-notes/tests.md +2 -1
  8. package/docs/operations.md +20 -10
  9. package/docs/quickstart.md +4 -2
  10. package/docs/security.md +4 -1
  11. package/docs/smoke.md +1 -1
  12. package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
  13. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  14. package/docs/verified.json +29 -18
  15. package/package.json +1 -1
  16. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  17. package/plugins/agent-hub/server.js +22882 -16496
  18. package/src/adapters/codex-appserver.ts +81 -11
  19. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  20. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  21. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  22. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  23. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  24. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  25. package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
  26. package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
  27. package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
  28. package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
  29. package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
  30. package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
  31. package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
  32. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  33. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  34. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  35. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  36. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  37. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  38. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  39. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  40. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  41. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  42. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  43. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  44. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  45. package/src/adapters/definitions/native-config-policy.ts +35 -0
  46. package/src/adapters/definitions/native-context.ts +35 -0
  47. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  48. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  49. package/src/adapters/definitions/native-display.ts +18 -0
  50. package/src/adapters/definitions/native-factories.ts +48 -0
  51. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  52. package/src/adapters/definitions/native-quota.ts +29 -0
  53. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  54. package/src/adapters/definitions/native-recovery.ts +88 -0
  55. package/src/adapters/definitions/qwen.ts +8 -4
  56. package/src/adapters/registry-types.ts +97 -10
  57. package/src/adapters/registry.ts +103 -3
  58. package/src/cli/console-state.ts +5 -5
  59. package/src/cli/identity.ts +6 -5
  60. package/src/cli/jcode-mcp.ts +2 -58
  61. package/src/cli/launch.ts +14 -100
  62. package/src/cli/main.ts +35 -272
  63. package/src/cli/output.ts +3 -2
  64. package/src/cli/preview.ts +14 -125
  65. package/src/cli/recovery-guidance.ts +17 -13
  66. package/src/cli/setup.ts +2 -80
  67. package/src/cli/shell-quote.ts +3 -0
  68. package/src/cli/terminal-recovery.ts +17 -10
  69. package/src/cli/upgrade-interactive.ts +4 -2
  70. package/src/cli/upgrade-restore-state.ts +2 -1
  71. package/src/cli/upgrade-runtime.ts +92 -141
  72. package/src/cli/upgrade.ts +8 -7
  73. package/src/hub/budget.ts +6 -30
  74. package/src/hub/conductor.ts +8 -4
  75. package/src/hub/config-apply.ts +9 -13
  76. package/src/hub/config-reload.ts +7 -2
  77. package/src/hub/context-window.ts +3 -27
  78. package/src/hub/crash.ts +2 -35
  79. package/src/hub/daemon.ts +226 -232
  80. package/src/hub/execution-budget.ts +2 -1
  81. package/src/hub/hub-tools.ts +3 -3
  82. package/src/hub/peer-catalog.ts +4 -13
  83. package/src/hub/permission-mode.ts +9 -12
  84. package/src/hub/recovery-continuity.ts +6 -5
  85. package/src/hub/report.ts +9 -8
  86. package/src/hub/roster.ts +6 -7
  87. package/src/hub/routing.ts +21 -10
  88. package/src/hub/settings.ts +6 -5
  89. package/src/hub/settled-roles.ts +1 -1
  90. package/src/hub/start-mode.ts +8 -6
  91. package/src/hub/tasks.ts +13 -10
  92. package/src/hub/tooling.ts +4 -60
  93. package/src/hub/ui.ts +3 -1
  94. package/src/memory/recall.ts +4 -5
  95. package/src/models/route/signals.ts +3 -1
  96. package/src/pi/launch.ts +2 -1
  97. package/src/ui/index.html +3 -2
  98. package/src/ui/office-model.ts +3 -1
  99. package/src/ui/peer-palette.ts +16 -6
  100. package/templates/routing.toml +2 -2
@@ -1,10 +1,8 @@
1
- import { checkedSessionPath, recordedFileState } from "./recovery-session-file.ts";
2
1
  import { resolveAcceptedBinding } from "./recovery-guidance.ts";
3
- import { definitionOf } from "../adapters/registry.ts";
4
- import { qwenSessionId } from "../hub/crash.ts";
2
+ import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
5
3
  import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
6
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
7
- import { basename, join, resolve } from "node:path";
4
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
8
6
  import { createHash, randomUUID } from "node:crypto";
9
7
  import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
10
8
  import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
@@ -13,7 +11,7 @@ import { hubHome } from "../hub/project.ts";
13
11
  import { packageDigest, preserveRuntimeDependencies, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
14
12
  import { inspectSavedTerminal, inspectTerminals, closeTerminal, createTerminal, launcherOf, recordPath, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
15
13
  import { ReattachmentRequired, MissingRecordedSession, FinalRefusal, planFingerprint, registeredProjects, targetReadsWaivers, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type RecoveryPeer, type UpgradePlan } from "./upgrade.ts";
16
- import { readEvents } from "../hub/events.ts";
14
+ import { SHARED_CHANNEL_VERSION_ARGV, nativeRecordedSessionState } from "../adapters/definitions/native-recovery.ts";
17
15
  import { refreshManager } from "../hub/manager.ts";
18
16
  import { abandonRestartSnapshot, readRestartSnapshot, waiveRecoveryPeers } from "../hub/restart.ts";
19
17
  import { readRecoveryContinuity, retireRecoveryContinuity, writeRecoveryContinuity } from "../hub/recovery-continuity.ts";
@@ -34,50 +32,27 @@ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalB
34
32
  export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
35
33
  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");
36
34
 
37
- /**
38
- * Claude Code persists a transcript at projects/<slug>/<sessionId>.jsonl under its config
39
- * dir, creating the project directory only with the first persisted turn; the slug replaces
40
- * every non-alphanumeric character of the project root with '-'.
41
- */
42
- function claudeTranscriptState(binding: TerminalBinding): "found" | "missing" | "unknown" {
43
- return recordedFileState(checkedSessionPath(binding, "transcript"));
44
- }
45
- function claudeTranscriptExists(binding: TerminalBinding): boolean { return claudeTranscriptState(binding) === "found"; }
46
-
47
- /**
48
- * #215: `codex resume <id>` reads the thread's rollout from the store its launcher uses (the captured CODEX_HOME, else
49
- * the default), so an app-server thread id alone proves nothing.
50
- * ponytail: a `.jsonl` under sessions/ whose name carries the thread id is the evidence; ask Codex itself once it offers
51
- * a supported resumability query, or when its store layout changes.
52
- */
53
- const codexSessions = (binding: TerminalBinding) => checkedSessionPath(binding, "rollout");
54
- function codexTranscript(binding: TerminalBinding): "found" | "missing" | "unknown" {
55
- try { return readdirSync(codexSessions(binding), { recursive: true }).some((name) => String(name).endsWith(".jsonl") && basename(String(name)).includes(binding.sessionId)) ? "found" : "missing"; }
56
- catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; } // no store has no rollout; an unreadable one says nothing
57
- }
58
- const unresumable = (binding: TerminalBinding) => codexTranscript(binding) === "unknown"
59
- ? `codex: the session store ${codexSessions(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
60
- : `codex: thread ${binding.sessionId} has no resumable transcript under ${codexSessions(binding)}`;
35
+ const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
36
+ const state = definitionOf(binding.peer)?.session.transcriptExists?.(binding);
37
+ return state === "unknown" ? "unknown" : state === false ? "missing" : "found";
38
+ };
39
+ const sessionPath = (binding: TerminalBinding) => definitionOf(binding.peer)?.session.transcriptPath?.(binding);
40
+ export const observedSession = (peer: string, value: RecoveryPeer | undefined) => value?.[definitionOf(peer)?.session.idField ?? "sessionId"];
41
+ const nativeZeroTurn = (stateDir: string, binding: TerminalBinding, phase: "plan" | "attach" | "verify") => definitionOf(binding.peer)?.session.zeroTurn?.({ stateDir, binding, phase }) === true;
42
+ const unresumable = (binding: TerminalBinding) => sessionState(binding) === "unknown"
43
+ ? `codex: the session store ${sessionPath(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
44
+ : `codex: thread ${binding.sessionId} has no resumable transcript under ${sessionPath(binding)}`;
61
45
 
62
46
  const closeUnsettled = (binding: TerminalBinding, seen: "listed" | "unreadable") => seen === "listed"
63
47
  ? `${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`
64
48
  : `${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`;
65
49
 
66
- /**
67
- * #215: Codex writes a thread's rollout with its first message, so a thread this hub saw Codex start (`native_thread`
68
- * with `fresh`) on which no turn ran has nothing to lose. A codex turn_start belongs to the thread most recently
69
- * adopted before it; detaching forgets nothing. A thread whose start the log does not show (resumed, older hub, pruned
70
- * log) is unsure, and unsure counts as turned.
71
- */
72
- function codexZeroTurn(stateDir: string, thread: string): boolean {
73
- let current: string | undefined, started = false, turned = false;
74
- for (const e of readEvents(join(stateDir, "events.jsonl"))) {
75
- if (e.type === "native_thread" && e.peer === "codex") {
76
- current = e.thread;
77
- if (e.thread === thread && e.fresh) started = true;
78
- } else if (e.type === "turn_start" && e.peer === "codex" && current === thread) turned = true;
79
- }
80
- return started && !turned;
50
+ /** Preserve legacy source-text capability probes; only fixed definition data supplies paths and export names. */
51
+ function legacyTargetLists(root: string, probe: { file: string; exportName: string }, kind: string): boolean {
52
+ const file = join(root, probe.file);
53
+ const values = existsSync(file) ? new RegExp(`export const ${probe.exportName}\\s*=\\s*\\[([^\\]]*)\\]`).exec(readFileSync(file, "utf8"))?.[1] : undefined;
54
+ const name = kind.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ return !!values && new RegExp(`["']${name}["']`).test(values);
81
56
  }
82
57
 
83
58
  function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
@@ -89,11 +64,9 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
89
64
 
90
65
  /** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
91
66
  export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
92
- if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", ...(fresh ? [] : ["resume", binding.sessionId])];
93
- if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", ...(fresh ? [] : ["--resume", binding.sessionId])];
94
- return [process.execPath, entrypoint, "--project", projectRoot, "pi", "--mode", "tui",
95
- ...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
96
- ...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
67
+ const tail = definitionOf(binding.peer)?.session.resumeArgv?.(binding, fresh);
68
+ if (!tail) throw new Error(`${binding.peer}: no recorded terminal resume adapter`);
69
+ return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
97
70
  }
98
71
 
99
72
  const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
@@ -135,9 +108,9 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
135
108
  // The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
136
109
  if (now.state === "busy") cut = "; its turn was cut: a delivery it had not settled is held as needs_review, and its queue stays held until ahub queue resolve";
137
110
  if (binding) {
138
- if ((binding.peer === "codex" ? now.threadId : now.sessionId) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
111
+ if (observedSession(binding.peer, now) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
139
112
  // The same question the plan asked (`makeUpgradePlan`), for this one peer.
140
- const session = binding.peer === "pi" ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
113
+ const session = definitionOf(binding.peer)?.session.crashIdentity ? { sessionId: binding.sessionId, ...(now.sessionFile ? { sessionFile: now.sessionFile } : {}), ...(now.args?.backend ? { backend: now.args.backend } : {}), ...(now.args?.model ? { model: now.args.model } : {}) } : binding.sessionId;
141
114
  const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
142
115
  current = found.byPeer[binding.peer];
143
116
  if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
@@ -236,7 +209,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
236
209
  sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), projects: [], blockers: [] };
237
210
  if (kind === "upgrade") {
238
211
  try {
239
- if ((await run(["claude", "--version"], { timeoutMs: 10_000 })).code !== 0) body.blockers.push("shared Claude plugin installer is unavailable");
212
+ if ((await run([...SHARED_CHANNEL_VERSION_ARGV], { timeoutMs: 10_000 })).code !== 0) body.blockers.push("shared Claude plugin installer is unavailable");
240
213
  } catch { body.blockers.push("shared Claude plugin installer is unavailable"); }
241
214
  }
242
215
  const managerFile = join(hubHome(), "manager/status.json");
@@ -255,21 +228,19 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
255
228
  const blockers = [...source.blockers];
256
229
  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");
257
230
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
258
- const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
231
+ const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
259
232
  const reconnectOnly: string[] = [];
260
233
  for (const peer of source.peers) {
261
234
  if (peer.state === "offline") continue;
262
235
  const adapter = definitionOf(peer.id);
263
- if (peer.id === "kimi" && peer.args?.mode === "tui") {
264
- blockers.push("kimi: its pull-only terminal has no automatic restore; end it in that terminal, then make a new recovery plan (its session is not resumed by the hub)");
265
- continue;
266
- }
267
- if (peer.id === "claude") {
236
+ const refusal = adapter?.session.recoveryRefusal?.(peer);
237
+ if (refusal) { blockers.push(refusal); continue; }
238
+ if (adapter?.transport === "claude-channel") {
268
239
  // #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
269
240
  // that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
270
241
  // ponytail: the channel's hello carries no launch id, so a plain `claude` that took the peer id from a live
271
242
  // managed session reads as managed; send the launch id at hello (a protocol bump) if that case shows up.
272
- const recorded = await launcherOf("claude", project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
243
+ const recorded = await launcherOf(peer.id as TerminalBinding["peer"], project.root, { ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId });
273
244
  if (recorded?.state === "unknown") {
274
245
  // An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
275
246
  delete peer.sessionId;
@@ -279,7 +250,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
279
250
  const launcher = recorded?.state === "live" ? recorded.record! : undefined;
280
251
  if (!launcher) {
281
252
  delete peer.sessionId;
282
- if (source.protocol === PROTOCOL) reconnectOnly.push("claude");
253
+ if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
283
254
  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`);
284
255
  continue;
285
256
  }
@@ -291,28 +262,27 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
291
262
  continue;
292
263
  }
293
264
  }
294
- if (peer.id === "jcode") { blockers.push("jcode: source-pinned native load cannot prove conversation identity; end the peer and make a new recovery plan before effects"); continue; }
295
- if (peer.id === "codex" || peer.id === "claude") {
296
- const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
297
- 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`);
298
- else sessions[peer.id] = session;
299
- } else if (adapter?.session.restoreOnStart || peer.id === "jcode") {
300
- const session = adapter?.session.crashIdentity ? adapter.session.crashIdentity(peer).sessionId : qwenSessionId(peer.sessionId);
265
+ if (adapter?.session.launchRecord && (!adapter.session.restoreArgs || peer.args?.mode === "tui")) {
266
+ const session = observedSession(peer.id, peer);
267
+ if (!session) blockers.push(adapter.session.crashIdentity
268
+ ? `${peer.id}: original session ID is unknown; manual-required; next action: reconnect the original ${adapter.displayName} session and make a new recovery plan`
269
+ : `${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
270
+ else sessions[peer.id as TerminalBinding["peer"]] = adapter.session.crashIdentity
271
+ ? { sessionId: session, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args?.backend ? { backend: peer.args.backend } : {}), ...(peer.args?.model ? { model: peer.args.model } : {}) } : session;
272
+ } else if (adapter?.session.resumeById) {
273
+ const session = adapter.session.crashIdentity?.(peer).sessionId;
301
274
  if (!session) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
302
- } else if (peer.id === "pi" && peer.args?.mode === "tui") {
303
- if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required; next action: reconnect the original Pi session and make a new recovery plan");
304
- else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
305
- } else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
275
+ } else if (!adapter?.session.freshOnRestore && !adapter?.session.restoreArgs) blockers.push(`${peer.id}: no automatic recovery adapter`);
306
276
  }
307
277
  const terminals = await inspectTerminals(project.root, sessions, {
308
278
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
309
279
  });
310
280
  blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
311
281
  const freshStart: string[] = [];
312
- for (const binding of terminals.bindings) if (binding.peer === "codex") {
313
- const transcript = codexTranscript(binding);
282
+ for (const binding of terminals.bindings) if (definitionOf(binding.peer)?.session.missingEvidence === "rollout") {
283
+ const transcript = sessionState(binding);
314
284
  if (transcript === "found") continue;
315
- if (transcript === "missing" && codexZeroTurn(project.stateDir, binding.sessionId)) { freshStart.push("codex"); continue; }
285
+ if (transcript === "missing" && nativeZeroTurn(project.stateDir, binding, "plan")) { freshStart.push(binding.peer); continue; }
316
286
  blockers.push(transcript === "unknown"
317
287
  ? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
318
288
  : `${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`);
@@ -362,7 +332,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
362
332
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
363
333
  const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
364
334
  for (const binding of bindings ?? []) {
365
- const key = binding.peer === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
335
+ // Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
336
+ const key = definitionOf(binding.peer)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
366
337
  if (binding.launch.env[key]) value[key] = binding.launch.env[key];
367
338
  }
368
339
  }
@@ -394,8 +365,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
394
365
  const observed = await inspectRecovery(planned.project);
395
366
  if (observed.instanceId !== progress.instanceId) throw nativeFailure(planned, saved.peer, "target_unavailable", `${saved.peer}: daemon instance changed during terminal revalidation`, { handle: saved.handle, targetState: observed.state });
396
367
  const peer = observed.peers.find((item) => item.id === saved.peer);
397
- const session = saved.peer === "codex" ? peer?.threadId : peer?.sessionId;
398
- if (session !== saved.sessionId || saved.peer === "pi" && progress.reattachments?.pi && saved.sessionFile && peer?.sessionFile !== saved.sessionFile) throw nativeFailure(planned, saved.peer, "session_changed", `${saved.peer}: daemon session changed during terminal revalidation; manual-required`, { handle: saved.handle, expectedSession: saved.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
368
+ const session = observedSession(saved.peer, peer);
369
+ if (session !== saved.sessionId || definitionOf(saved.peer)?.session.crashIdentity && progress.reattachments?.[saved.peer] && saved.sessionFile && peer?.sessionFile !== saved.sessionFile) throw nativeFailure(planned, saved.peer, "session_changed", `${saved.peer}: daemon session changed during terminal revalidation; manual-required`, { handle: saved.handle, expectedSession: saved.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
399
370
  return current;
400
371
  };
401
372
  /** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
@@ -420,7 +391,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
420
391
  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" }) };
421
392
  }
422
393
  const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
423
- const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
394
+ const session = observedSession(peer, attached);
424
395
  if (session) return { state: "live", session };
425
396
  let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
426
397
  // #225: the launch records of every instance this operation started for the project, the current one first: a
@@ -516,11 +487,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
516
487
  if (live.state !== "running") throw nativeFailure(planned, binding.peer, "target_unavailable", `${binding.peer}: target hub reads as ${live.state} while awaiting attachment; saved terminal retained`, { handle: binding.handle, targetState: live.state });
517
488
  if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw nativeFailure(planned, binding.peer, "target_unavailable", `${binding.peer}: target identity changed while awaiting attachment`, { handle: binding.handle, targetState: live.state });
518
489
  const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
519
- const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
490
+ const session = observedSession(binding.peer, peer);
520
491
  const changed = !!session && expectedSession !== null && session !== expectedSession;
521
492
  // #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
522
493
  // at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
523
- const zeroTurn = changed && !progress.reattachments?.[binding.peer] && !progress.repairedSessions?.[binding.peer] && binding.peer === "claude" && (planned.terminals as TerminalBinding[]).some((p) => p.peer === "claude" && p.sessionId === binding.sessionId) && !claudeTranscriptExists(binding);
494
+ const zeroTurn = changed && !progress.reattachments?.[binding.peer] && !progress.repairedSessions?.[binding.peer] && (planned.terminals as TerminalBinding[]).some((p) => p.peer === binding.peer && p.sessionId === binding.sessionId) && nativeZeroTurn(planned.project.stateDir, binding, "attach");
524
495
  if (changed && !zeroTurn) throw nativeFailure(planned, binding.peer, "session_changed", `${binding.peer}: a different conversation attached while awaiting ${binding.sessionId}`, { handle: binding.handle, expectedSession: expectedSession!, observedSession: session! });
525
496
  if (now() > deadline) break;
526
497
  const launcher = await launcherOf(binding.peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
@@ -546,7 +517,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
546
517
  const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
547
518
  session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
548
519
  if (decision.action === "settle" && session) {
549
- if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { claude: "zero-turn" });
520
+ if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
550
521
  return session;
551
522
  }
552
523
  if (decision.action === "fail" || decision.action === "create") throw new AttachmentLauncherGone(decision);
@@ -569,7 +540,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
569
540
  }
570
541
  } catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
571
542
  const peer = live.peers.find((p) => p.id === binding.peer);
572
- const session = peer?.state === "offline" ? undefined : binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
543
+ const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer);
573
544
  (progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
574
545
  hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
575
546
  lastHubConnectAttempt: "unknown" };
@@ -590,7 +561,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
590
561
  return {
591
562
  ...(liveTestPeer === undefined ? {} : { liveTestPeer }),
592
563
  now, sleep, inspect: inspectRecovery,
593
- recordedSessionState: async (binding, kind) => kind === "rollout" ? codexTranscript(binding) : claudeTranscriptState(binding),
564
+ recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
594
565
  stage: async (op) => {
595
566
  if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
596
567
  const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
@@ -616,15 +587,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
616
587
  const verified = { ...target, targetFeatures };
617
588
  // Existing peer/waiver compatibility checks remain properties of the already verified bytes.
618
589
  if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
619
- if (op.plan.projects.some(p => p.source.peers.some(peer => definitionOf(peer.id)?.session.restoreOnStart && peer.state !== "offline"))) {
620
- const catalog = join(target.root, "src/hub/peer-catalog.ts");
621
- const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
622
- if (!peers || !/["']qwen["']/.test(peers)) throw new FinalRefusal("target does not support Qwen; end Qwen and make a new plan, or choose a supporting target; runtimes unchanged");
623
- }
624
- if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
625
- const catalog = join(target.root, "src/hub/peer-catalog.ts");
626
- const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
627
- if (!peers || !/["']jcode["']/.test(peers)) throw new FinalRefusal("target does not support Jcode; end it and make a new plan, or choose a supporting target; runtimes unchanged");
590
+ for (const adapter of ADAPTERS) {
591
+ const probe = adapter.session.legacyTargetSupport;
592
+ if (!probe || !op.plan.projects.some(p => p.source.peers.some(peer => peer.id === adapter.kind && peer.state !== "offline"))) continue;
593
+ if (!legacyTargetLists(target.root, probe, adapter.kind)) throw new FinalRefusal(probe.refusal);
628
594
  }
629
595
  // Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
630
596
  if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
@@ -664,7 +630,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
664
630
  progress.terminals[key] = true; save(); continue;
665
631
  }
666
632
  // #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
667
- const transcript = binding.peer === "codex" ? codexTranscript(binding) : "found";
633
+ const transcript = definitionOf(binding.peer)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
668
634
  if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
669
635
  if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
670
636
  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`);
@@ -697,36 +663,23 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
697
663
  const live = await inspectRecovery(planned.project);
698
664
  if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
699
665
  if (group === "native" && !onlyPeer) {
700
- for (const peer of planned.source.peers.filter((p) => ["kimi", "qwen", "jcode", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
666
+ for (const peer of planned.source.peers.filter(p => definitionOf(p.id)?.session.restoreArgs && p.state !== "offline" && !(definitionOf(p.id)?.session.launchRecord && p.args?.mode === "tui"))) {
701
667
  const current = live.peers.find((p) => p.id === peer.id);
702
668
  if (current && current.state !== "offline") continue;
669
+ const adapter = definitionOf(peer.id)!;
703
670
  const args = [peer.id];
704
- if (peer.id === "kimi" && peer.args?.mode === "tui") throw new Error("Kimi's pull-only terminal cannot be restored as a headless worker; end it in that terminal and make a new recovery plan");
705
- for (const key of ["mode", "backend", "model", "route"]) if (!(peer.id === "kimi" && key === "mode") && typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
706
- if (peer.id === "kimi") {
707
- // ponytail: a staged target's fixed catalog is the capability signal; replace this source check with
708
- // an explicit recovery capability once targets publish one. Older targets have only headless Kimi.
709
- const modeFile = join(op.targetRoot!, "src/hub/start-mode.ts");
710
- const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
711
- if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
712
- }
713
- // #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
714
- // one is filtered out above), so a snapshot that recorded no mode still restores it headless.
715
- if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
716
- if (definitionOf(peer.id)?.session.restoreOnStart && peer.sessionId) args.push("--session-id", peer.sessionId);
717
- if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
718
- if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
719
- if (peer.id === "pi") {
720
- const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
721
- if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
722
- else if (peer.sessionId) args.push("--session-id", peer.sessionId);
723
- }
671
+ const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
672
+ if (refusal) throw new Error(refusal);
673
+ const captured = adapter.session.restoreArgs!(peer);
674
+ for (const [key, value] of Object.entries(captured)) args.push(`--${key}`, value);
675
+ const legacy = adapter.session.legacyHeadlessArgv;
676
+ if (legacy && legacyTargetLists(op.targetRoot!, legacy, adapter.kind)) args.push(...legacy.argv);
724
677
  await command(op, args, planned.project);
725
678
  }
726
679
  }
727
680
  let claudeGroup = false;
728
681
  for (const plannedBinding of planned.terminals as TerminalBinding[]) {
729
- if ((plannedBinding.peer === "claude") !== (claudeGroup = group === "claude") || onlyPeer && plannedBinding.peer !== onlyPeer) continue;
682
+ if ((definitionOf(plannedBinding.peer)?.transport === "claude-channel") !== (claudeGroup = group !== "native") || onlyPeer && plannedBinding.peer !== onlyPeer) continue;
730
683
  const key = `restored:${plannedBinding.peer}`;
731
684
  try {
732
685
  // #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
@@ -743,17 +696,17 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
743
696
  // A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
744
697
  // start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
745
698
  const chosen = !recordedOnly && !!progress.fresh?.[original.peer];
746
- let read: ReturnType<typeof codexTranscript> | undefined;
747
- const transcript = () => original.peer !== "codex" || (chosen && !accepted) ? "found" : (read ??= codexTranscript(original));
699
+ let read: ReturnType<typeof sessionState> | undefined;
700
+ const transcript = () => definitionOf(original.peer)?.session.missingEvidence !== "rollout" || (chosen && !accepted) ? "found" : (read ??= sessionState(original));
748
701
  // An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
749
702
  // rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
750
703
  const fresh = () => recordedOnly ? false : accepted
751
- ? (original.peer === "codex" ? (chosen || !!planned.freshStart?.includes("codex")) && transcript() === "missing" : original.peer === "claude" && !claudeTranscriptExists(original))
704
+ ? (definitionOf(original.peer)?.session.missingEvidence === "rollout" ? (chosen || !!planned.freshStart?.includes(original.peer)) && transcript() === "missing" : nativeZeroTurn(planned.project.stateDir, original, "attach"))
752
705
  : chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
753
706
  // The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
754
707
  // peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
755
708
  // The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
756
- const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : original.peer === "claude" ? "zero-turn" : "fresh-start";
709
+ const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : definitionOf(original.peer)?.transport === "claude-channel" ? "zero-turn" : "fresh-start";
757
710
  const notRestored = (why: string) => new Error(fresh()
758
711
  ? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
759
712
  : `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
@@ -769,7 +722,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
769
722
  const decide = (receipt: RestoreReceipt, evidence: Awaited<ReturnType<typeof peerEvidence>>) => restoreTransition(receipt, {
770
723
  target: "running", launcher: evidence.state === "unknown" ? "unknown" : evidence.state === "gone" ? "gone" : evidence.terminal ? "live" : "older-instance",
771
724
  session: evidence.state === "live" && evidence.session ? evidence.session === original.sessionId ? "planned" : "other" : "none",
772
- allowOther: fresh() || (!recordedOnly && original.peer === "claude" && !claudeTranscriptExists(original)),
725
+ allowOther: fresh() || (!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach")),
773
726
  binding: evidence.state === "live" && evidence.terminal ? { ...original, ...evidence.terminal } : original,
774
727
  });
775
728
  const receipt = decodeRestoreReceipt(progress.terminals[key]);
@@ -790,7 +743,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
790
743
  // #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
791
744
  // succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
792
745
  // the operator or the plan accepted, counts as restored.
793
- if (attached !== original.sessionId && !fresh() && !(!recordedOnly && original.peer === "claude" && !claudeTranscriptExists(original))) {
746
+ if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
794
747
  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`);
795
748
  }
796
749
  // Validate the proposed transition before committing continuity/waiver effects or a done receipt.
@@ -862,7 +815,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
862
815
  if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
863
816
  if (transcript() === "missing" && !fresh()) {
864
817
  progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
865
- if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind: "rollout", checkedPath: checkedSessionPath(original, "rollout") });
818
+ if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind: definitionOf(original.peer)!.session.missingEvidence!, checkedPath: sessionPath(original) });
866
819
  throw notRestored(unresumable(original).slice("codex: ".length));
867
820
  }
868
821
  if (fresh()) {
@@ -918,14 +871,14 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
918
871
  // A bare pending/failed row can hide an uncertain create. Only a durable exact chosen binding
919
872
  // can become the terminal that an explicit fresh choice must close before another create.
920
873
  const binding = chosenReceipt?.state === "waiting" ? chosenReceipt.binding : undefined;
921
- if (binding && claudeTranscriptState(binding) === "missing" && (await peerEvidence(planned, progress, plannedBinding.peer)).state === "gone") {
874
+ if (binding && sessionState(binding) === "missing" && (await peerEvidence(planned, progress, plannedBinding.peer)).state === "gone") {
922
875
  // Retain the exact chosen terminal so an explicit later fresh choice closes it before creating again.
923
876
  progress.terminals[`retired:${plannedBinding.peer}`] = binding;
924
877
  delete progress.terminals[`closedRetired:${plannedBinding.peer}`];
925
878
  const marker = progress.reattachments?.[plannedBinding.peer];
926
879
  if (marker) marker.binding = binding;
927
880
  progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
928
- throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind: "transcript", checkedPath: checkedSessionPath(binding, "transcript") });
881
+ throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind: definitionOf(plannedBinding.peer)!.session.missingEvidence!, checkedPath: sessionPath(binding) });
929
882
  }
930
883
  }
931
884
  if (error instanceof ReattachmentRequired || isDatabaseBusy(error)) throw error;
@@ -934,7 +887,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
934
887
  throw error;
935
888
  }
936
889
  }
937
- if (group === "claude" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
890
+ if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
938
891
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
939
892
  const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
940
893
  while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
@@ -946,7 +899,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
946
899
  installPlugin: async (op) => {
947
900
  const configured = new Set<string>();
948
901
  for (const planned of op.plan.projects) {
949
- const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
902
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => definitionOf(t.peer)?.transport === "claude-channel");
950
903
  if (!binding) continue;
951
904
  const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
952
905
  if (configured.has(profile)) continue;
@@ -974,7 +927,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
974
927
  abandonRestartSnapshot(project.stateDir, op.id);
975
928
  const planned = op.plan.projects?.find(item => item.project.id === project.id);
976
929
  const progress = op.projects?.find(item => item.id === project.id);
977
- const source = planned?.source.peers.find(peer => peer.id === "pi" && peer.state !== "offline");
930
+ const source = planned?.source.peers.find(peer => definitionOf(peer.id)?.start.autoStart && peer.state !== "offline");
978
931
  if (source && progress) {
979
932
  // abandonRestartSnapshot is deliberately a no-op for another operation or an unreadable snapshot.
980
933
  // A continuity bookmark needs positive archival evidence, including when disposal resumes after a crash.
@@ -983,13 +936,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
983
936
  try { archived = JSON.parse(readFileSync(archive, "utf8")); } catch { return { continuity: "not-archived" }; }
984
937
  if (!archived || typeof archived !== "object" || (archived as { operationId?: unknown }).operationId !== op.id) return { continuity: "not-archived" };
985
938
  try {
986
- const binding = planned ? resolveAcceptedBinding(planned, progress, "pi") : undefined;
987
- const sessionId = binding?.sessionId ?? source.sessionId;
988
- if (!sessionId) throw new Error("Pi recorded session unavailable; archive continuity bookmark was not written");
989
- const backend = binding?.backend ?? source.args?.backend;
939
+ const continuity = definitionOf(source.id)?.session.continuityBookmark;
940
+ if (!continuity) return;
941
+ const binding = planned ? resolveAcceptedBinding(planned, progress, source.id as TerminalBinding["peer"]) : undefined;
990
942
  writeRecoveryContinuity(project.stateDir, { schema: 1, operationId: op.id, projectId: project.id, projectRoot: project.root, at: now(),
991
- pi: { sessionId, sessionFile: binding?.sessionFile ?? source.sessionFile ?? source.args?.sessionFile,
992
- ...(backend === "auto" || backend === "dgx" || backend === "mlx" ? { backend } : {}), model: binding?.model ?? source.args?.model } });
943
+ ...continuity(source, binding) });
993
944
  } catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
994
945
  }
995
946
  },
@@ -1012,21 +963,21 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1012
963
  // A #225 restart clears the old target's repair fence, never its accepted conversation.
1013
964
  const retained = retired?.state === "done" && accepted?.state === "done" && retired.binding.peer === old.id && retired.binding.projectRoot === planned.project.root && retired.binding.sessionId === accepted.binding.sessionId;
1014
965
  if ((recordedOnly || retained) && accepted?.state === "done") {
1015
- const session = old.id === "codex" ? peer.threadId : peer.sessionId;
966
+ const session = observedSession(old.id, peer);
1016
967
  if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
1017
- old.id === "pi" && (recordedOnly || old.sessionFile) && accepted.binding.sessionFile && peer.sessionFile !== accepted.binding.sessionFile) throw nativeFailure(planned, old.id, "session_changed", `${old.id}: reattached a different recorded conversation`, { handle: accepted.binding.handle, expectedSession: accepted.binding.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
968
+ definitionOf(old.id)?.session.launchRecord && definitionOf(old.id)?.session.crashIdentity && (recordedOnly || old.sessionFile) && accepted.binding.sessionFile && peer.sessionFile !== accepted.binding.sessionFile) throw nativeFailure(planned, old.id, "session_changed", `${old.id}: reattached a different recorded conversation`, { handle: accepted.binding.handle, expectedSession: accepted.binding.sessionId, ...(session === undefined ? {} : { observedSession: session }) });
1018
969
  continue;
1019
970
  }
1020
971
  if (!recordedOnly && (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id))) continue; // #215: a new session was accepted (recorded)
1021
- if (old.id === "codex" && peer.threadId !== old.threadId) throw nativeFailure(planned, old.id, "session_changed", "Codex resumed a different conversation", { ...(old.threadId ? { expectedSession: old.threadId } : {}), ...(peer.threadId ? { observedSession: peer.threadId } : {}) });
1022
- if (old.id === "claude" && peer.sessionId !== old.sessionId) {
972
+ if (definitionOf(old.id)?.session.idField === "threadId" && peer.threadId !== old.threadId) throw nativeFailure(planned, old.id, "session_changed", "Codex resumed a different conversation", { ...(old.threadId ? { expectedSession: old.threadId } : {}), ...(peer.threadId ? { observedSession: peer.threadId } : {}) });
973
+ if (definitionOf(old.id)?.transport === "claude-channel" && peer.sessionId !== old.sessionId) {
1023
974
  // #64: the restore gate already accepted a fresh session when the original never
1024
975
  // persisted a transcript. Re-derive persistence from the binding exactly like that
1025
976
  // gate did, so a first turn landing after the plan was made keeps the check strict.
1026
- const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
1027
- if (recordedOnly || !binding || binding.sessionId !== old.sessionId || claudeTranscriptExists(binding)) throw nativeFailure(planned, old.id, "session_changed", "Claude resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
977
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => definitionOf(t.peer)?.transport === "claude-channel");
978
+ if (recordedOnly || !binding || binding.sessionId !== old.sessionId || !nativeZeroTurn(planned.project.stateDir, binding, "verify")) throw nativeFailure(planned, old.id, "session_changed", "Claude resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
1028
979
  }
1029
- if (old.id === "pi" && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw nativeFailure(planned, old.id, "session_changed", "Pi resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
980
+ if (definitionOf(old.id)?.session.launchRecord && definitionOf(old.id)?.session.crashIdentity && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw nativeFailure(planned, old.id, "session_changed", "Pi resumed a different conversation", { ...(old.sessionId ? { expectedSession: old.sessionId } : {}), ...(peer.sessionId ? { observedSession: peer.sessionId } : {}) });
1030
981
  }
1031
982
  for (const original of planned.terminals as TerminalBinding[]) {
1032
983
  const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
2
3
  import { checkedSessionPath } from "./recovery-session-file.ts";
3
4
  import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
@@ -337,7 +338,7 @@ function missingRepair(op: RecoveryOperation, progress: ProjectProgress, peer: s
337
338
  function failedPeers(op: RecoveryOperation, progress: ProjectProgress, live: Record<string, Inspection | undefined> = {}): string[] {
338
339
  const planned = op.plan.projects.find((p) => p.project.id === progress.id);
339
340
  return Object.entries(progress.terminals).filter(([key, value]) => key.startsWith("restored:") && canChooseFresh(value)).map(([key]) => key.slice("restored:".length))
340
- .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));
341
+ .filter((peer) => definitionOf(peer)?.session.personFresh === true && ((!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));
341
342
  }
342
343
 
343
344
  /** Read-only scope classification; it confers no delegation authority. */
@@ -363,7 +364,7 @@ function plannedAttached(op: RecoveryOperation, progress: ProjectProgress, peer:
363
364
  ? resolveAcceptedBinding(planned, progress, peer as RecoveryNativePeer)
364
365
  : (planned?.terminals as { peer: string; sessionId: string }[] | undefined)?.find((t) => t.peer === peer);
365
366
  const current = state?.state === "running" ? state.peers.find((p) => p.id === peer && p.state !== "offline") : undefined;
366
- return !!binding && (peer === "codex" ? current?.threadId : current?.sessionId) === binding.sessionId;
367
+ return !!binding && current?.[definitionOf(peer)?.session.idField ?? "sessionId"] === binding.sessionId;
367
368
  }
368
369
 
369
370
  /** Registry reads for planning must not create a registry or run migrations. */
@@ -444,7 +445,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
444
445
  if (progress.reattachments === undefined) continue;
445
446
  if (!object(progress.reattachments)) throw new Error("invalid reattachment markers");
446
447
  for (const [peer, marker] of Object.entries(progress.reattachments)) {
447
- if (!["codex", "pi", "claude"].includes(peer) || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
448
+ if (definitionOf(peer)?.session.launchRecord !== true || !object(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
448
449
  !["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");
449
450
  const receipt = decodeRestoreReceipt({ state: "done", binding: marker.binding });
450
451
  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");
@@ -466,7 +467,7 @@ function validateReceipt(id: string, op: RecoveryOperation): void {
466
467
  }
467
468
  if (op.blockedReattachment !== undefined) {
468
469
  const selected = op.blockedReattachment;
469
- if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || !["codex", "pi", "claude"].includes(String(selected.peer)) ||
470
+ if (!object(selected) || Object.keys(selected).some(key => !["projectId", "peer"].includes(key)) || typeof selected.projectId !== "string" || definitionOf(String(selected.peer))?.session.launchRecord !== true ||
470
471
  !op.projects.some(project => project.id === selected.projectId && Object.hasOwn(project.reattachments ?? {}, selected.peer))) throw new Error("invalid blocked reattachment selector");
471
472
  }
472
473
  }
@@ -794,7 +795,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
794
795
  let op = readOperation<RecoveryOperation>(id, home);
795
796
  validateReceipt(id, op);
796
797
  const selection = () => {
797
- 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");
798
+ if (definitionOf(peer)?.session.launchRecord !== true || 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");
798
799
  const progress = op.projects.find(project => project.id === op.blockedReattachment!.projectId);
799
800
  const planned = op.plan.projects.find(project => project.project.id === progress?.id);
800
801
  const native = peer as RecoveryNativePeer;
@@ -819,7 +820,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
819
820
  };
820
821
  await target();
821
822
  attempted = true;
822
- await driver.restore(planned, progress, op, native === "claude" ? "claude" : "native", save, native);
823
+ await driver.restore(planned, progress, op, definitionOf(native)?.transport === "claude-channel" ? "claude" : "native", save, native);
823
824
  // A done receipt is written by the runtime only after authenticated attachment and exact settlement.
824
825
  // Inspection peers may be unavailable; do not invent an attachment from a test or a stale status projection.
825
826
  const restored = decodeRestoreReceipt(progress.terminals[`restored:${native}`]);
@@ -915,7 +916,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
915
916
  const next = nextActionsText(op, live);
916
917
  if ("fresh" in choice) {
917
918
  // `ahub pi` sends no fresh flag, and a restored hub refills a Pi start from its recorded resume (daemon startPeer).
918
- 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}`);
919
+ if (definitionOf(choice.fresh)?.session.personFresh === false) 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}`);
919
920
  if (op.disposition) throw new Error(`a stop-and-archive of this operation is partway; ${next}`);
920
921
  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}`);
921
922
  // The new session is launched by resume, so it helps only where resume can get past what is live.