@staix/agent-hub 0.12.32 → 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 (110) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +6 -5
  3. package/docs/agent-notes/adapters.md +7 -3
  4. package/docs/agent-notes/budget.md +5 -3
  5. package/docs/agent-notes/bus.md +2 -0
  6. package/docs/agent-notes/daemon.md +7 -5
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/agent-notes/tests.md +2 -1
  9. package/docs/operations.md +48 -14
  10. package/docs/quickstart.md +5 -3
  11. package/docs/security.md +10 -2
  12. package/docs/smoke.md +1 -1
  13. package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
  14. package/docs/specs/2026-09-20-upgrade-recovery-design.md +20 -0
  15. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  16. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  17. package/docs/verified.json +33 -19
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +22882 -16496
  21. package/src/adapters/codex-appserver.ts +81 -11
  22. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  23. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  24. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  25. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  26. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  27. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  28. package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
  29. package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
  30. package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
  31. package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
  32. package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
  33. package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
  34. package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
  35. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  36. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  37. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  38. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  39. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  40. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  41. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  42. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  43. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  44. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  45. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  46. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  47. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  48. package/src/adapters/definitions/native-config-policy.ts +35 -0
  49. package/src/adapters/definitions/native-context.ts +35 -0
  50. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  51. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  52. package/src/adapters/definitions/native-display.ts +18 -0
  53. package/src/adapters/definitions/native-factories.ts +48 -0
  54. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  55. package/src/adapters/definitions/native-quota.ts +29 -0
  56. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  57. package/src/adapters/definitions/native-recovery.ts +88 -0
  58. package/src/adapters/definitions/qwen.ts +8 -4
  59. package/src/adapters/registry-types.ts +97 -10
  60. package/src/adapters/registry.ts +103 -3
  61. package/src/cli/console-state.ts +5 -5
  62. package/src/cli/identity.ts +6 -5
  63. package/src/cli/jcode-mcp.ts +2 -58
  64. package/src/cli/launch.ts +14 -100
  65. package/src/cli/main.ts +35 -272
  66. package/src/cli/output.ts +3 -2
  67. package/src/cli/preview.ts +14 -125
  68. package/src/cli/recovery-guidance.ts +17 -13
  69. package/src/cli/reset.ts +13 -1
  70. package/src/cli/setup.ts +2 -80
  71. package/src/cli/shell-quote.ts +3 -0
  72. package/src/cli/terminal-recovery.ts +17 -10
  73. package/src/cli/upgrade-interactive.ts +4 -2
  74. package/src/cli/upgrade-presentation.ts +11 -1
  75. package/src/cli/upgrade-restore-state.ts +2 -1
  76. package/src/cli/upgrade-runtime.ts +107 -146
  77. package/src/cli/upgrade.ts +10 -7
  78. package/src/hub/budget.ts +6 -30
  79. package/src/hub/bus.ts +133 -14
  80. package/src/hub/conductor.ts +8 -4
  81. package/src/hub/config-apply.ts +9 -13
  82. package/src/hub/config-reload.ts +7 -2
  83. package/src/hub/context-window.ts +3 -27
  84. package/src/hub/control-client.ts +1 -1
  85. package/src/hub/crash.ts +2 -35
  86. package/src/hub/daemon.ts +244 -241
  87. package/src/hub/deferred-hub.ts +60 -0
  88. package/src/hub/delivery-journal.ts +6 -0
  89. package/src/hub/execution-budget.ts +2 -1
  90. package/src/hub/hub-tools.ts +3 -3
  91. package/src/hub/peer-catalog.ts +4 -13
  92. package/src/hub/permission-mode.ts +9 -12
  93. package/src/hub/recovery-continuity.ts +6 -5
  94. package/src/hub/report.ts +9 -8
  95. package/src/hub/restart.ts +12 -1
  96. package/src/hub/roster.ts +6 -7
  97. package/src/hub/routing.ts +21 -10
  98. package/src/hub/settings.ts +6 -5
  99. package/src/hub/settled-roles.ts +1 -1
  100. package/src/hub/start-mode.ts +8 -6
  101. package/src/hub/tasks.ts +14 -11
  102. package/src/hub/tooling.ts +4 -60
  103. package/src/hub/ui.ts +3 -1
  104. package/src/memory/recall.ts +4 -5
  105. package/src/models/route/signals.ts +3 -1
  106. package/src/pi/launch.ts +2 -1
  107. package/src/ui/index.html +3 -2
  108. package/src/ui/office-model.ts +3 -1
  109. package/src/ui/peer-palette.ts +16 -6
  110. 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,12 +11,13 @@ 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";
20
18
  import { recoveryLock } from "../hub/recovery-store.ts";
21
19
  import { decodeRestoreReceipt, restoreTransition, type RestoreReceipt, type RestoreEvidence, type RestoreTransition } from "./upgrade-restore-state.ts";
20
+ import { RecoveryDeferredHubError } from "../hub/deferred-hub.ts";
22
21
  import { RecoveryPreservationError, RecoveryPresentationError, type RecoveryNativeCause } from "./upgrade-presentation-data.ts";
23
22
 
24
23
  /** An unmanaged Claude's plugin retries with a backoff of at most 30 s; three of those bound the reconnect wait (#206). */
@@ -33,50 +32,27 @@ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalB
33
32
  export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
34
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");
35
34
 
36
- /**
37
- * Claude Code persists a transcript at projects/<slug>/<sessionId>.jsonl under its config
38
- * dir, creating the project directory only with the first persisted turn; the slug replaces
39
- * every non-alphanumeric character of the project root with '-'.
40
- */
41
- function claudeTranscriptState(binding: TerminalBinding): "found" | "missing" | "unknown" {
42
- return recordedFileState(checkedSessionPath(binding, "transcript"));
43
- }
44
- function claudeTranscriptExists(binding: TerminalBinding): boolean { return claudeTranscriptState(binding) === "found"; }
45
-
46
- /**
47
- * #215: `codex resume <id>` reads the thread's rollout from the store its launcher uses (the captured CODEX_HOME, else
48
- * the default), so an app-server thread id alone proves nothing.
49
- * ponytail: a `.jsonl` under sessions/ whose name carries the thread id is the evidence; ask Codex itself once it offers
50
- * a supported resumability query, or when its store layout changes.
51
- */
52
- const codexSessions = (binding: TerminalBinding) => checkedSessionPath(binding, "rollout");
53
- function codexTranscript(binding: TerminalBinding): "found" | "missing" | "unknown" {
54
- try { return readdirSync(codexSessions(binding), { recursive: true }).some((name) => String(name).endsWith(".jsonl") && basename(String(name)).includes(binding.sessionId)) ? "found" : "missing"; }
55
- catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; } // no store has no rollout; an unreadable one says nothing
56
- }
57
- const unresumable = (binding: TerminalBinding) => codexTranscript(binding) === "unknown"
58
- ? `codex: the session store ${codexSessions(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
59
- : `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)}`;
60
45
 
61
46
  const closeUnsettled = (binding: TerminalBinding, seen: "listed" | "unreadable") => seen === "listed"
62
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`
63
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`;
64
49
 
65
- /**
66
- * #215: Codex writes a thread's rollout with its first message, so a thread this hub saw Codex start (`native_thread`
67
- * with `fresh`) on which no turn ran has nothing to lose. A codex turn_start belongs to the thread most recently
68
- * adopted before it; detaching forgets nothing. A thread whose start the log does not show (resumed, older hub, pruned
69
- * log) is unsure, and unsure counts as turned.
70
- */
71
- function codexZeroTurn(stateDir: string, thread: string): boolean {
72
- let current: string | undefined, started = false, turned = false;
73
- for (const e of readEvents(join(stateDir, "events.jsonl"))) {
74
- if (e.type === "native_thread" && e.peer === "codex") {
75
- current = e.thread;
76
- if (e.thread === thread && e.fresh) started = true;
77
- } else if (e.type === "turn_start" && e.peer === "codex" && current === thread) turned = true;
78
- }
79
- 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);
80
56
  }
81
57
 
82
58
  function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
@@ -88,11 +64,9 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
88
64
 
89
65
  /** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
90
66
  export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
91
- if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", ...(fresh ? [] : ["resume", binding.sessionId])];
92
- if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", ...(fresh ? [] : ["--resume", binding.sessionId])];
93
- return [process.execPath, entrypoint, "--project", projectRoot, "pi", "--mode", "tui",
94
- ...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
95
- ...(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];
96
70
  }
97
71
 
98
72
  const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
@@ -134,9 +108,9 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
134
108
  // The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
135
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";
136
110
  if (binding) {
137
- 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}`);
138
112
  // The same question the plan asked (`makeUpgradePlan`), for this one peer.
139
- 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;
140
114
  const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
141
115
  current = found.byPeer[binding.peer];
142
116
  if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
@@ -172,12 +146,21 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
172
146
  const sourceProtocol = control?.protocol;
173
147
  const legacySupported = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]);
174
148
  // A stopped runtime reports whose unreleased restart snapshot it holds: only that one can start it again (#215).
175
- const snapshot = base.state === "stopped" ? readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id })?.operationId : undefined;
149
+ let snapshot: string | undefined, snapshotError: Inspection["snapshotError"];
150
+ if (base.state === "stopped") {
151
+ try { snapshot = readRestartSnapshot(project.stateDir, { projectRoot: project.root, projectId: project.id })?.operationId; }
152
+ catch (error) {
153
+ if (!(error instanceof RecoveryDeferredHubError)) throw error;
154
+ // Corruption blocks restore, but cannot erase independently confirmed shutdown or the person's archive exit.
155
+ // Keep diagnostics static: the rejected snapshot may contain forged envelope fields.
156
+ snapshotError = { code: "recovery_deferred_hub", message: "restart snapshot deferred hub notices are invalid; restore is blocked" };
157
+ }
158
+ }
176
159
  // A dead pid or a missing project directory is final whatever protocol its manifest names: probing it would read
177
160
  // "unavailable" forever and wedge abort, resume and stop-and-archive (#215).
178
161
  if (base.state === "stopped" || base.state === "missing" || (base.state !== "running" && !legacySupported)) return {
179
- 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],
180
- ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}),
162
+ state: base.state, peers: [], blockers: base.state === "stopped" ? snapshotError ? [snapshotError.message] : [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
163
+ ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}), ...(snapshot ? { snapshot } : {}), ...(snapshotError ? { snapshotError } : {}),
181
164
  };
182
165
  let status = base.status;
183
166
  if (!status && control && sourceProtocol === PROTOCOL) return {
@@ -226,7 +209,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
226
209
  sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), projects: [], blockers: [] };
227
210
  if (kind === "upgrade") {
228
211
  try {
229
- 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");
230
213
  } catch { body.blockers.push("shared Claude plugin installer is unavailable"); }
231
214
  }
232
215
  const managerFile = join(hubHome(), "manager/status.json");
@@ -245,21 +228,19 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
245
228
  const blockers = [...source.blockers];
246
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");
247
230
  if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
248
- const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
231
+ const sessions: Partial<Record<TerminalBinding["peer"], string | SessionRef>> = {};
249
232
  const reconnectOnly: string[] = [];
250
233
  for (const peer of source.peers) {
251
234
  if (peer.state === "offline") continue;
252
235
  const adapter = definitionOf(peer.id);
253
- if (peer.id === "kimi" && peer.args?.mode === "tui") {
254
- 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)");
255
- continue;
256
- }
257
- if (peer.id === "claude") {
236
+ const refusal = adapter?.session.recoveryRefusal?.(peer);
237
+ if (refusal) { blockers.push(refusal); continue; }
238
+ if (adapter?.transport === "claude-channel") {
258
239
  // #206: managed means a live `ahub claude` launcher recorded for this daemon and a session record written by
259
240
  // that launch. Anything else cannot be bound to the attached session, so its record never becomes a target.
260
241
  // ponytail: the channel's hello carries no launch id, so a plain `claude` that took the peer id from a live
261
242
  // managed session reads as managed; send the launch id at hello (a protocol bump) if that case shows up.
262
- 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 });
263
244
  if (recorded?.state === "unknown") {
264
245
  // An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
265
246
  delete peer.sessionId;
@@ -269,7 +250,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
269
250
  const launcher = recorded?.state === "live" ? recorded.record! : undefined;
270
251
  if (!launcher) {
271
252
  delete peer.sessionId;
272
- if (source.protocol === PROTOCOL) reconnectOnly.push("claude");
253
+ if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
273
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`);
274
255
  continue;
275
256
  }
@@ -281,28 +262,27 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
281
262
  continue;
282
263
  }
283
264
  }
284
- 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; }
285
- if (peer.id === "codex" || peer.id === "claude") {
286
- const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
287
- 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`);
288
- else sessions[peer.id] = session;
289
- } else if (adapter?.session.restoreOnStart || peer.id === "jcode") {
290
- 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;
291
274
  if (!session) blockers.push(`${peer.id}: saved ACP session identity unavailable; end the peer and make a new recovery plan`);
292
- } else if (peer.id === "pi" && peer.args?.mode === "tui") {
293
- 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");
294
- 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 } : {}) };
295
- } 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`);
296
276
  }
297
277
  const terminals = await inspectTerminals(project.root, sessions, {
298
278
  ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
299
279
  });
300
280
  blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
301
281
  const freshStart: string[] = [];
302
- for (const binding of terminals.bindings) if (binding.peer === "codex") {
303
- const transcript = codexTranscript(binding);
282
+ for (const binding of terminals.bindings) if (definitionOf(binding.peer)?.session.missingEvidence === "rollout") {
283
+ const transcript = sessionState(binding);
304
284
  if (transcript === "found") continue;
305
- 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; }
306
286
  blockers.push(transcript === "unknown"
307
287
  ? `${unresumable(binding)}; manual-required; next action: make that store readable, then make a new plan`
308
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`);
@@ -352,7 +332,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
352
332
  delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
353
333
  const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
354
334
  for (const binding of bindings ?? []) {
355
- 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";
356
337
  if (binding.launch.env[key]) value[key] = binding.launch.env[key];
357
338
  }
358
339
  }
@@ -384,8 +365,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
384
365
  const observed = await inspectRecovery(planned.project);
385
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 });
386
367
  const peer = observed.peers.find((item) => item.id === saved.peer);
387
- const session = saved.peer === "codex" ? peer?.threadId : peer?.sessionId;
388
- 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 }) });
389
370
  return current;
390
371
  };
391
372
  /** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
@@ -410,7 +391,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
410
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" }) };
411
392
  }
412
393
  const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
413
- const session = peer === "codex" ? attached?.threadId : attached?.sessionId;
394
+ const session = observedSession(peer, attached);
414
395
  if (session) return { state: "live", session };
415
396
  let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
416
397
  // #225: the launch records of every instance this operation started for the project, the current one first: a
@@ -506,11 +487,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
506
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 });
507
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 });
508
489
  const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
509
- const session = binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
490
+ const session = observedSession(binding.peer, peer);
510
491
  const changed = !!session && expectedSession !== null && session !== expectedSession;
511
492
  // #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
512
493
  // at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
513
- 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");
514
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! });
515
496
  if (now() > deadline) break;
516
497
  const launcher = await launcherOf(binding.peer, planned.project.root, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
@@ -536,7 +517,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
536
517
  const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
537
518
  session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
538
519
  if (decision.action === "settle" && session) {
539
- if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { claude: "zero-turn" });
520
+ if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
540
521
  return session;
541
522
  }
542
523
  if (decision.action === "fail" || decision.action === "create") throw new AttachmentLauncherGone(decision);
@@ -559,7 +540,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
559
540
  }
560
541
  } catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
561
542
  const peer = live.peers.find((p) => p.id === binding.peer);
562
- const session = peer?.state === "offline" ? undefined : binding.peer === "codex" ? peer?.threadId : peer?.sessionId;
543
+ const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer);
563
544
  (progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
564
545
  hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
565
546
  lastHubConnectAttempt: "unknown" };
@@ -580,7 +561,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
580
561
  return {
581
562
  ...(liveTestPeer === undefined ? {} : { liveTestPeer }),
582
563
  now, sleep, inspect: inspectRecovery,
583
- recordedSessionState: async (binding, kind) => kind === "rollout" ? codexTranscript(binding) : claudeTranscriptState(binding),
564
+ recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
584
565
  stage: async (op) => {
585
566
  if (packageDigest(op.sourceRoot, { ownDependencies: true }) !== op.plan.sourceDigest) throw new FinalRefusal("preserved source changed");
586
567
  const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version, { ownDependencies: true }) }
@@ -606,15 +587,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
606
587
  const verified = { ...target, targetFeatures };
607
588
  // Existing peer/waiver compatibility checks remain properties of the already verified bytes.
608
589
  if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
609
- if (op.plan.projects.some(p => p.source.peers.some(peer => definitionOf(peer.id)?.session.restoreOnStart && peer.state !== "offline"))) {
610
- const catalog = join(target.root, "src/hub/peer-catalog.ts");
611
- const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
612
- 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");
613
- }
614
- if (op.plan.projects.some(p => p.source.peers.some(peer => peer.id === "jcode" && peer.state !== "offline"))) {
615
- const catalog = join(target.root, "src/hub/peer-catalog.ts");
616
- const peers = existsSync(catalog) ? /export const OPTIONAL_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(catalog, "utf8"))?.[1] : undefined;
617
- 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);
618
594
  }
619
595
  // Reconnect-only (#206) and fresh sessions (#215) need a target hub that reads recovery-waivers.json.
620
596
  if (op.plan.projects.some((p) => p.reconnectOnly?.length || p.freshStart?.length) && !targetReadsWaivers({ targetRoot: target.root })) {
@@ -654,7 +630,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
654
630
  progress.terminals[key] = true; save(); continue;
655
631
  }
656
632
  // #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
657
- const transcript = binding.peer === "codex" ? codexTranscript(binding) : "found";
633
+ const transcript = definitionOf(binding.peer)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
658
634
  if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
659
635
  if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
660
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`);
@@ -666,8 +642,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
666
642
  throw new Error(`source preparation expired or changed while waiting for the terminal; no terminal was closed`);
667
643
  }
668
644
  // Refresh the same prepared lease immediately before the terminal effect. Preserve
669
- // the original peer roster, including any terminal already closed in this operation.
670
- await control(planned.project, "prepare", op.id, planned.source.instanceId!);
645
+ // the original peer roster and verified target features, including after a resume.
646
+ await control(planned.project, "prepare", op.id, planned.source.instanceId!, op.targetFeatures);
671
647
  progress.terminals[key] = "pending"; save();
672
648
  const result = await closeTerminal(binding, 0, terminalOptions(run));
673
649
  if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
@@ -687,36 +663,23 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
687
663
  const live = await inspectRecovery(planned.project);
688
664
  if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
689
665
  if (group === "native" && !onlyPeer) {
690
- 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"))) {
691
667
  const current = live.peers.find((p) => p.id === peer.id);
692
668
  if (current && current.state !== "offline") continue;
669
+ const adapter = definitionOf(peer.id)!;
693
670
  const args = [peer.id];
694
- 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");
695
- 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]!);
696
- if (peer.id === "kimi") {
697
- // ponytail: a staged target's fixed catalog is the capability signal; replace this source check with
698
- // an explicit recovery capability once targets publish one. Older targets have only headless Kimi.
699
- const modeFile = join(op.targetRoot!, "src/hub/start-mode.ts");
700
- const modes = existsSync(modeFile) ? /export const START_MODE_PEERS\s*=\s*\[([^\]]*)\]/.exec(readFileSync(modeFile, "utf8"))?.[1] : undefined;
701
- if (modes && /["']kimi["']/.test(modes)) args.push("--mode", "headless");
702
- }
703
- // #269: `ahub pi` without a mode now takes Pi's start mode (tui by default). This Pi ran headless (a terminal
704
- // one is filtered out above), so a snapshot that recorded no mode still restores it headless.
705
- if (peer.id === "pi" && typeof peer.args?.mode !== "string") args.push("--mode", "headless");
706
- if (definitionOf(peer.id)?.session.restoreOnStart && peer.sessionId) args.push("--session-id", peer.sessionId);
707
- if (peer.id === "jcode" && peer.sessionId) args.push("--session-id", peer.sessionId);
708
- if (peer.id === "jcode" && peer.args?.providerProfile) args.push("--provider-profile", peer.args.providerProfile);
709
- if (peer.id === "pi") {
710
- const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
711
- if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
712
- else if (peer.sessionId) args.push("--session-id", peer.sessionId);
713
- }
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);
714
677
  await command(op, args, planned.project);
715
678
  }
716
679
  }
717
680
  let claudeGroup = false;
718
681
  for (const plannedBinding of planned.terminals as TerminalBinding[]) {
719
- 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;
720
683
  const key = `restored:${plannedBinding.peer}`;
721
684
  try {
722
685
  // #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
@@ -733,17 +696,17 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
733
696
  // A new session is accepted only by the operator's choice (#215 dispose --fresh-session) or a planned fresh
734
697
  // start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
735
698
  const chosen = !recordedOnly && !!progress.fresh?.[original.peer];
736
- let read: ReturnType<typeof codexTranscript> | undefined;
737
- 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));
738
701
  // An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
739
702
  // rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
740
703
  const fresh = () => recordedOnly ? false : accepted
741
- ? (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"))
742
705
  : chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
743
706
  // The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
744
707
  // peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
745
708
  // The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
746
- 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";
747
710
  const notRestored = (why: string) => new Error(fresh()
748
711
  ? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
749
712
  : `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
@@ -759,7 +722,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
759
722
  const decide = (receipt: RestoreReceipt, evidence: Awaited<ReturnType<typeof peerEvidence>>) => restoreTransition(receipt, {
760
723
  target: "running", launcher: evidence.state === "unknown" ? "unknown" : evidence.state === "gone" ? "gone" : evidence.terminal ? "live" : "older-instance",
761
724
  session: evidence.state === "live" && evidence.session ? evidence.session === original.sessionId ? "planned" : "other" : "none",
762
- allowOther: fresh() || (!recordedOnly && original.peer === "claude" && !claudeTranscriptExists(original)),
725
+ allowOther: fresh() || (!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach")),
763
726
  binding: evidence.state === "live" && evidence.terminal ? { ...original, ...evidence.terminal } : original,
764
727
  });
765
728
  const receipt = decodeRestoreReceipt(progress.terminals[key]);
@@ -780,7 +743,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
780
743
  // #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
781
744
  // succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
782
745
  // the operator or the plan accepted, counts as restored.
783
- if (attached !== original.sessionId && !fresh() && !(!recordedOnly && original.peer === "claude" && !claudeTranscriptExists(original))) {
746
+ if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
784
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`);
785
748
  }
786
749
  // Validate the proposed transition before committing continuity/waiver effects or a done receipt.
@@ -852,7 +815,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
852
815
  if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
853
816
  if (transcript() === "missing" && !fresh()) {
854
817
  progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
855
- 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) });
856
819
  throw notRestored(unresumable(original).slice("codex: ".length));
857
820
  }
858
821
  if (fresh()) {
@@ -908,14 +871,14 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
908
871
  // A bare pending/failed row can hide an uncertain create. Only a durable exact chosen binding
909
872
  // can become the terminal that an explicit fresh choice must close before another create.
910
873
  const binding = chosenReceipt?.state === "waiting" ? chosenReceipt.binding : undefined;
911
- 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") {
912
875
  // Retain the exact chosen terminal so an explicit later fresh choice closes it before creating again.
913
876
  progress.terminals[`retired:${plannedBinding.peer}`] = binding;
914
877
  delete progress.terminals[`closedRetired:${plannedBinding.peer}`];
915
878
  const marker = progress.reattachments?.[plannedBinding.peer];
916
879
  if (marker) marker.binding = binding;
917
880
  progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
918
- 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) });
919
882
  }
920
883
  }
921
884
  if (error instanceof ReattachmentRequired || isDatabaseBusy(error)) throw error;
@@ -924,7 +887,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
924
887
  throw error;
925
888
  }
926
889
  }
927
- if (group === "claude" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
890
+ if (group !== "native" && !onlyPeer) for (const id of planned.reconnectOnly ?? []) {
928
891
  // #206: nothing is launched for an unmanaged session; its plugin reconnects by itself, within a bound.
929
892
  const deadline = now() + UNMANAGED_RECONNECT_WAIT_MS;
930
893
  while (!(await inspectRecovery(planned.project)).peers.some((p) => p.id === id && p.state !== "offline")) {
@@ -936,7 +899,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
936
899
  installPlugin: async (op) => {
937
900
  const configured = new Set<string>();
938
901
  for (const planned of op.plan.projects) {
939
- 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");
940
903
  if (!binding) continue;
941
904
  const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
942
905
  if (configured.has(profile)) continue;
@@ -964,7 +927,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
964
927
  abandonRestartSnapshot(project.stateDir, op.id);
965
928
  const planned = op.plan.projects?.find(item => item.project.id === project.id);
966
929
  const progress = op.projects?.find(item => item.id === project.id);
967
- 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");
968
931
  if (source && progress) {
969
932
  // abandonRestartSnapshot is deliberately a no-op for another operation or an unreadable snapshot.
970
933
  // A continuity bookmark needs positive archival evidence, including when disposal resumes after a crash.
@@ -973,13 +936,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
973
936
  try { archived = JSON.parse(readFileSync(archive, "utf8")); } catch { return { continuity: "not-archived" }; }
974
937
  if (!archived || typeof archived !== "object" || (archived as { operationId?: unknown }).operationId !== op.id) return { continuity: "not-archived" };
975
938
  try {
976
- const binding = planned ? resolveAcceptedBinding(planned, progress, "pi") : undefined;
977
- const sessionId = binding?.sessionId ?? source.sessionId;
978
- if (!sessionId) throw new Error("Pi recorded session unavailable; archive continuity bookmark was not written");
979
- 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;
980
942
  writeRecoveryContinuity(project.stateDir, { schema: 1, operationId: op.id, projectId: project.id, projectRoot: project.root, at: now(),
981
- pi: { sessionId, sessionFile: binding?.sessionFile ?? source.sessionFile ?? source.args?.sessionFile,
982
- ...(backend === "auto" || backend === "dgx" || backend === "mlx" ? { backend } : {}), model: binding?.model ?? source.args?.model } });
943
+ ...continuity(source, binding) });
983
944
  } catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
984
945
  }
985
946
  },
@@ -1002,21 +963,21 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
1002
963
  // A #225 restart clears the old target's repair fence, never its accepted conversation.
1003
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;
1004
965
  if ((recordedOnly || retained) && accepted?.state === "done") {
1005
- const session = old.id === "codex" ? peer.threadId : peer.sessionId;
966
+ const session = observedSession(old.id, peer);
1006
967
  if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
1007
- 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 }) });
1008
969
  continue;
1009
970
  }
1010
971
  if (!recordedOnly && (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id))) continue; // #215: a new session was accepted (recorded)
1011
- 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 } : {}) });
1012
- 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) {
1013
974
  // #64: the restore gate already accepted a fresh session when the original never
1014
975
  // persisted a transcript. Re-derive persistence from the binding exactly like that
1015
976
  // gate did, so a first turn landing after the plan was made keeps the check strict.
1016
- const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
1017
- 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 } : {}) });
1018
979
  }
1019
- 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 } : {}) });
1020
981
  }
1021
982
  for (const original of planned.terminals as TerminalBinding[]) {
1022
983
  const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);