@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.
- package/CHANGELOG.md +9 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +5 -3
- package/docs/agent-notes/daemon.md +3 -3
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -1
- package/docs/operations.md +20 -10
- package/docs/quickstart.md +4 -2
- package/docs/security.md +4 -1
- package/docs/smoke.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +246 -4
- package/docs/verification/2026-10-18-0.12.34.md +48 -0
- package/docs/verified.json +29 -18
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +22882 -16496
- package/src/adapters/codex-appserver.ts +81 -11
- package/src/adapters/definitions/builtin-metadata.ts +101 -7
- package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
- package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
- package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
- package/src/adapters/definitions/native-cli-doctor.ts +46 -0
- package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +26 -0
- package/src/adapters/definitions/native-cli-entry-jcode.ts +24 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +52 -0
- package/src/adapters/definitions/native-cli-entry-local.ts +14 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +51 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +20 -0
- package/src/adapters/definitions/native-cli-entry-types.ts +40 -0
- package/src/adapters/definitions/native-cli-entry.ts +19 -0
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
- package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
- package/src/adapters/definitions/native-cli-pi.ts +33 -0
- package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
- package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
- package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
- package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
- package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
- package/src/adapters/definitions/native-cli-preview.ts +16 -0
- package/src/adapters/definitions/native-config-defaults.ts +19 -0
- package/src/adapters/definitions/native-config-policy.ts +35 -0
- package/src/adapters/definitions/native-context.ts +35 -0
- package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
- package/src/adapters/definitions/native-daemon-policy.ts +18 -0
- package/src/adapters/definitions/native-display.ts +18 -0
- package/src/adapters/definitions/native-factories.ts +48 -0
- package/src/adapters/definitions/native-policy-aux.ts +130 -0
- package/src/adapters/definitions/native-quota.ts +29 -0
- package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
- package/src/adapters/definitions/native-recovery.ts +88 -0
- package/src/adapters/definitions/qwen.ts +8 -4
- package/src/adapters/registry-types.ts +97 -10
- package/src/adapters/registry.ts +103 -3
- package/src/cli/console-state.ts +5 -5
- package/src/cli/identity.ts +6 -5
- package/src/cli/jcode-mcp.ts +2 -58
- package/src/cli/launch.ts +14 -100
- package/src/cli/main.ts +35 -272
- package/src/cli/output.ts +3 -2
- package/src/cli/preview.ts +14 -125
- package/src/cli/recovery-guidance.ts +17 -13
- package/src/cli/setup.ts +2 -80
- package/src/cli/shell-quote.ts +3 -0
- package/src/cli/terminal-recovery.ts +17 -10
- package/src/cli/upgrade-interactive.ts +4 -2
- package/src/cli/upgrade-restore-state.ts +2 -1
- package/src/cli/upgrade-runtime.ts +92 -141
- package/src/cli/upgrade.ts +8 -7
- package/src/hub/budget.ts +6 -30
- package/src/hub/conductor.ts +8 -4
- package/src/hub/config-apply.ts +9 -13
- package/src/hub/config-reload.ts +7 -2
- package/src/hub/context-window.ts +3 -27
- package/src/hub/crash.ts +2 -35
- package/src/hub/daemon.ts +226 -232
- package/src/hub/execution-budget.ts +2 -1
- package/src/hub/hub-tools.ts +3 -3
- package/src/hub/peer-catalog.ts +4 -13
- package/src/hub/permission-mode.ts +9 -12
- package/src/hub/recovery-continuity.ts +6 -5
- package/src/hub/report.ts +9 -8
- package/src/hub/roster.ts +6 -7
- package/src/hub/routing.ts +21 -10
- package/src/hub/settings.ts +6 -5
- package/src/hub/settled-roles.ts +1 -1
- package/src/hub/start-mode.ts +8 -6
- package/src/hub/tasks.ts +13 -10
- package/src/hub/tooling.ts +4 -60
- package/src/hub/ui.ts +3 -1
- package/src/memory/recall.ts +4 -5
- package/src/models/route/signals.ts +3 -1
- package/src/pi/launch.ts +2 -1
- package/src/ui/index.html +3 -2
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +16 -6
- 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,
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
93
|
-
if (
|
|
94
|
-
return [process.execPath, entrypoint, "--project", projectRoot,
|
|
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
|
|
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
|
|
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([
|
|
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:
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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("
|
|
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(
|
|
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 (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 (
|
|
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 === "
|
|
313
|
-
const transcript =
|
|
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" &&
|
|
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
|
-
|
|
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
|
|
398
|
-
if (session !== saved.sessionId || saved.peer
|
|
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
|
|
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
|
|
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] &&
|
|
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, {
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
if (!
|
|
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 === "
|
|
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(
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
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
|
|
747
|
-
const transcript = () => original.peer !== "
|
|
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 === "
|
|
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 &&
|
|
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 &&
|
|
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:
|
|
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 &&
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
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
|
|
966
|
+
const session = observedSession(old.id, peer);
|
|
1016
967
|
if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
|
|
1017
|
-
old.id
|
|
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 === "
|
|
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 ||
|
|
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
|
|
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}`]);
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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" ||
|
|
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 (
|
|
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 ===
|
|
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.
|