@staix/agent-hub 0.12.34 → 0.12.36
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 +27 -0
- package/README.md +8 -6
- package/docs/agent-notes/adapters.md +6 -1
- package/docs/agent-notes/budget.md +4 -2
- package/docs/agent-notes/bus.md +5 -1
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/tasks.md +15 -2
- package/docs/agent-notes/tests.md +5 -1
- package/docs/gsd-collaboration-analysis.md +5 -1
- package/docs/operations.md +97 -19
- package/docs/quickstart.md +8 -4
- package/docs/sdlc.md +100 -34
- package/docs/security.md +7 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
- package/docs/verification/2026-10-18-0.12.35.md +49 -0
- package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verified.json +26 -17
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +15781 -14850
- package/src/adapters/acp.ts +3 -1
- package/src/adapters/claude-channel.ts +59 -29
- package/src/adapters/claude-print.ts +230 -0
- package/src/adapters/codex-appserver.ts +1 -1
- package/src/adapters/definitions/builtin-metadata.ts +17 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
- package/src/adapters/definitions/native-factories.ts +7 -0
- package/src/adapters/definitions/native-recovery.ts +2 -2
- package/src/adapters/definitions/qwen-native.ts +2 -2
- package/src/adapters/local-worker.ts +3 -2
- package/src/adapters/pi.ts +3 -1
- package/src/adapters/registry-types.ts +11 -2
- package/src/adapters/registry.ts +36 -8
- package/src/cli/help.ts +5 -0
- package/src/cli/identity.ts +5 -4
- package/src/cli/launch.ts +45 -7
- package/src/cli/main.ts +136 -29
- package/src/cli/preview.ts +16 -5
- package/src/cli/recovery-guidance.ts +39 -22
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +73 -16
- package/src/cli/upgrade-restore-state.ts +16 -7
- package/src/cli/upgrade-runtime.ts +83 -52
- package/src/cli/upgrade.ts +43 -27
- package/src/hub/board.ts +29 -7
- package/src/hub/budget.ts +55 -3
- package/src/hub/bus.ts +12 -6
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/conductor.ts +16 -10
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-reload.ts +11 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/crash.ts +14 -5
- package/src/hub/daemon.ts +473 -194
- package/src/hub/execution-budget.ts +3 -3
- package/src/hub/hub-tools.ts +36 -6
- package/src/hub/instance-budget.ts +73 -0
- package/src/hub/instance-config.ts +47 -0
- package/src/hub/instance-execution.ts +50 -0
- package/src/hub/instances.ts +114 -0
- package/src/hub/lifecycle-schema.ts +7 -3
- package/src/hub/permission-mode.ts +7 -7
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/roster.ts +7 -6
- package/src/hub/routing.ts +97 -18
- package/src/hub/sdlc-queue.ts +2 -2
- package/src/hub/sdlc.ts +286 -39
- package/src/hub/settings.ts +34 -6
- package/src/hub/snapshots.ts +40 -9
- package/src/hub/start-mode.ts +7 -4
- package/src/hub/status-roster.ts +18 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +595 -47
- package/src/hub/text-projection.ts +7 -3
- package/src/hub/usage.ts +57 -20
- package/src/hub/workflow.ts +30 -52
- package/src/pi/launch.ts +4 -3
- package/src/ui/index.html +1 -1
- package/templates/AGENTS.block.md +2 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
2
|
+
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
2
3
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
4
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
3
5
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
4
6
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
5
7
|
import { join, resolve } from "node:path";
|
|
@@ -32,13 +34,16 @@ const attachedBinding = (binding: TerminalBinding, sessionId: string): TerminalB
|
|
|
32
34
|
export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
|
|
33
35
|
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");
|
|
34
36
|
|
|
37
|
+
/** Definitions are resolved against the owning project's current trusted declarations, never a global instance map. */
|
|
38
|
+
const projectDefinition = (peer: string, projectRoot: string) => definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
|
|
39
|
+
|
|
35
40
|
const sessionState = (binding: TerminalBinding): "found" | "missing" | "unknown" => {
|
|
36
|
-
const state =
|
|
41
|
+
const state = projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptExists?.(binding);
|
|
37
42
|
return state === "unknown" ? "unknown" : state === false ? "missing" : "found";
|
|
38
43
|
};
|
|
39
|
-
const sessionPath = (binding: TerminalBinding) =>
|
|
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") =>
|
|
44
|
+
const sessionPath = (binding: TerminalBinding) => projectDefinition(binding.peer, binding.projectRoot)?.session.transcriptPath?.(binding);
|
|
45
|
+
export const observedSession = (peer: string, value: RecoveryPeer | undefined, projectRoot?: string) => value?.[(projectRoot ? projectDefinition(peer, projectRoot) : definitionOf(peer))?.session.idField ?? "sessionId"];
|
|
46
|
+
const nativeZeroTurn = (stateDir: string, binding: TerminalBinding, phase: "plan" | "attach" | "verify") => projectDefinition(binding.peer, binding.projectRoot)?.session.zeroTurn?.({ stateDir, binding, phase }) === true;
|
|
42
47
|
const unresumable = (binding: TerminalBinding) => sessionState(binding) === "unknown"
|
|
43
48
|
? `codex: the session store ${sessionPath(binding)} cannot be read, so whether thread ${binding.sessionId} can resume is unknown`
|
|
44
49
|
: `codex: thread ${binding.sessionId} has no resumable transcript under ${sessionPath(binding)}`;
|
|
@@ -64,7 +69,9 @@ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions
|
|
|
64
69
|
|
|
65
70
|
/** `fresh` (#215: the operator's choice, or a planned fresh start) starts Codex or Claude without resuming its recorded session. */
|
|
66
71
|
export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding, fresh = false): string[] {
|
|
67
|
-
const
|
|
72
|
+
const adapter = projectDefinition(binding.peer, binding.projectRoot);
|
|
73
|
+
const nativeTail = adapter?.session.resumeArgv?.(binding, fresh);
|
|
74
|
+
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(binding.peer !== adapter.kind ? ["--as", binding.peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
68
75
|
if (!tail) throw new Error(`${binding.peer}: no recorded terminal resume adapter`);
|
|
69
76
|
return [process.execPath, entrypoint, "--project", projectRoot, ...tail];
|
|
70
77
|
}
|
|
@@ -108,9 +115,9 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
108
115
|
// The hub holds what a peer that goes offline mid-turn was working on (`uncertain` in bus.ts).
|
|
109
116
|
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";
|
|
110
117
|
if (binding) {
|
|
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}`);
|
|
118
|
+
if (observedSession(binding.peer, now, binding.projectRoot) !== binding.sessionId) return refused(`the session attached now is not the one the plan bound to terminal ${binding.handle}`);
|
|
112
119
|
// The same question the plan asked (`makeUpgradePlan`), for this one peer.
|
|
113
|
-
const session =
|
|
120
|
+
const session = projectDefinition(binding.peer, binding.projectRoot)?.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;
|
|
114
121
|
const found = await inspectTerminals(planned.project.root, { [binding.peer]: session }, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: planned.source.instanceId });
|
|
115
122
|
current = found.byPeer[binding.peer];
|
|
116
123
|
if (found.manualRequired || !current) return refused(`its terminal cannot be bound again: ${found.blockers[0]?.message ?? "no terminal was found"}`);
|
|
@@ -135,7 +142,20 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
135
142
|
}
|
|
136
143
|
}
|
|
137
144
|
|
|
138
|
-
|
|
145
|
+
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
146
|
+
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
147
|
+
const declared = Object.keys(readInstanceConfig(projectRoot).instances ?? {});
|
|
148
|
+
if (!declared.length) return undefined;
|
|
149
|
+
if (Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("peer_instances")) return undefined;
|
|
150
|
+
if (observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers)) {
|
|
151
|
+
const peers = Object.entries(observedPeers);
|
|
152
|
+
if (peers.every(([, peer]) => !!peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") &&
|
|
153
|
+
!peers.some(([id, peer]) => declared.includes(id) && (peer as { state: string }).state !== "offline")) return undefined;
|
|
154
|
+
}
|
|
155
|
+
return "source lacks peer_instances support for this project's declared instances; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end any extra native sessions and temporarily remove the instance declarations, upgrade the source, then declare them again; legacy peer identities were not reinterpreted";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
|
|
139
159
|
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
140
160
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
141
161
|
// caller reads that as not inspected, never as stopped.
|
|
@@ -181,6 +201,8 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
181
201
|
}
|
|
182
202
|
}
|
|
183
203
|
if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
|
|
204
|
+
const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
|
|
205
|
+
if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
|
|
184
206
|
let response: any;
|
|
185
207
|
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
|
|
186
208
|
catch (error) {
|
|
@@ -232,7 +254,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
232
254
|
const reconnectOnly: string[] = [];
|
|
233
255
|
for (const peer of source.peers) {
|
|
234
256
|
if (peer.state === "offline") continue;
|
|
235
|
-
const adapter =
|
|
257
|
+
const adapter = projectDefinition(peer.id, project.root);
|
|
236
258
|
const refusal = adapter?.session.recoveryRefusal?.(peer);
|
|
237
259
|
if (refusal) { blockers.push(refusal); continue; }
|
|
238
260
|
if (adapter?.transport === "claude-channel") {
|
|
@@ -244,26 +266,26 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
244
266
|
if (recorded?.state === "unknown") {
|
|
245
267
|
// An unreadable launcher is never gone (#215): whether the session is managed cannot be told.
|
|
246
268
|
delete peer.sessionId;
|
|
247
|
-
blockers.push(
|
|
269
|
+
blockers.push(`${peer.id}: ${recorded.record ? `the launcher recorded in terminal ${recorded.record.handle}` : recorded.invalidRow ? "a launch record in terminal-recovery.json that may be its launcher's" : "the launcher record (terminal-recovery.json)"} cannot be read, so whether the attached session is managed is unknown; manual-required; next action: make it readable (or end that session and close its terminal), then make a new plan`);
|
|
248
270
|
continue;
|
|
249
271
|
}
|
|
250
272
|
const launcher = recorded?.state === "live" ? recorded.record! : undefined;
|
|
251
273
|
if (!launcher) {
|
|
252
274
|
delete peer.sessionId;
|
|
253
275
|
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
254
|
-
else blockers.push(
|
|
276
|
+
else blockers.push(`${peer.id}: 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`);
|
|
255
277
|
continue;
|
|
256
278
|
}
|
|
257
279
|
let record: { instanceId?: unknown; launchId?: unknown; sessionId?: unknown } | undefined;
|
|
258
|
-
try { record = JSON.parse(readFileSync(join(project.stateDir, "claude-session.
|
|
280
|
+
try { record = JSON.parse(readFileSync(join(project.stateDir, claudeStateFile("claude-session", peer.id)), "utf8")); } catch { /* no record: the id check below reports it */ }
|
|
259
281
|
if (peer.sessionId && (record?.instanceId !== source.instanceId || record?.launchId !== launcher.launchId || record?.sessionId !== peer.sessionId)) {
|
|
260
282
|
delete peer.sessionId;
|
|
261
|
-
blockers.push(
|
|
283
|
+
blockers.push(`${peer.id}: the recorded session was not written by the live launcher in terminal ${launcher.handle}; manual-required; next action: close any other Claude session in this project, send one message in that terminal, then make a new plan`);
|
|
262
284
|
continue;
|
|
263
285
|
}
|
|
264
286
|
}
|
|
265
287
|
if (adapter?.session.launchRecord && (!adapter.session.restoreArgs || peer.args?.mode === "tui")) {
|
|
266
|
-
const session = observedSession(peer.id, peer);
|
|
288
|
+
const session = observedSession(peer.id, peer, project.root);
|
|
267
289
|
if (!session) blockers.push(adapter.session.crashIdentity
|
|
268
290
|
? `${peer.id}: original session ID is unknown; manual-required; next action: reconnect the original ${adapter.displayName} session and make a new recovery plan`
|
|
269
291
|
: `${peer.id}: original conversation ID is unknown; manual-required; next action: reconnect the original native session and make a new recovery plan`);
|
|
@@ -279,7 +301,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
279
301
|
});
|
|
280
302
|
blockers.push(...terminals.blockers.map((b) => `${b.message}${b.terminalReference ? ` (terminal ${b.terminalReference})` : ""}${b.nextAction ? `; next action: ${b.nextAction}` : ""}`));
|
|
281
303
|
const freshStart: string[] = [];
|
|
282
|
-
for (const binding of terminals.bindings) if (
|
|
304
|
+
for (const binding of terminals.bindings) if (projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout") {
|
|
283
305
|
const transcript = sessionState(binding);
|
|
284
306
|
if (transcript === "found") continue;
|
|
285
307
|
if (transcript === "missing" && nativeZeroTurn(project.stateDir, binding, "plan")) { freshStart.push(binding.peer); continue; }
|
|
@@ -319,7 +341,8 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
|
|
|
319
341
|
export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">): RecoveryDriver {
|
|
320
342
|
const now = clock?.now ?? (() => Date.now());
|
|
321
343
|
const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
322
|
-
|
|
344
|
+
/** `only`: one binding's native profile, for a command run once per profile (#450 I3: Claude instances may differ). */
|
|
345
|
+
const env = (op: RecoveryOperation, project?: Project, only?: TerminalBinding): NodeJS.ProcessEnv => {
|
|
323
346
|
const value = { ...process.env };
|
|
324
347
|
// Do not carry one project's gateway, auth overrides or unattended mode into another.
|
|
325
348
|
for (const key of Object.keys(value)) if (key.startsWith("AGENTHUB_") || key.startsWith("OMNIROUTE_") || key.startsWith("CF_ACCESS_") ||
|
|
@@ -331,17 +354,17 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
331
354
|
value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
|
|
332
355
|
delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
|
|
333
356
|
const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
|
|
334
|
-
for (const binding of bindings ?? []) {
|
|
357
|
+
for (const binding of only ? [only] : bindings ?? []) {
|
|
335
358
|
// Preserve the captured legacy profile for terminal bindings without a dedicated native config variable.
|
|
336
|
-
const key =
|
|
359
|
+
const key = projectDefinition(binding.peer, binding.projectRoot)?.session.configDirEnv ?? "CLAUDE_CONFIG_DIR";
|
|
337
360
|
if (binding.launch.env[key]) value[key] = binding.launch.env[key];
|
|
338
361
|
}
|
|
339
362
|
}
|
|
340
363
|
return value;
|
|
341
364
|
};
|
|
342
|
-
const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
|
|
365
|
+
const command = async (op: RecoveryOperation, args: string[], project?: Project, only?: TerminalBinding) => {
|
|
343
366
|
const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
|
|
344
|
-
{ env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
367
|
+
{ env: env(op, project, only), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
345
368
|
if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
|
|
346
369
|
return result;
|
|
347
370
|
};
|
|
@@ -365,8 +388,8 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
365
388
|
const observed = await inspectRecovery(planned.project);
|
|
366
389
|
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 });
|
|
367
390
|
const peer = observed.peers.find((item) => item.id === saved.peer);
|
|
368
|
-
const session = observedSession(saved.peer, peer);
|
|
369
|
-
if (session !== saved.sessionId ||
|
|
391
|
+
const session = observedSession(saved.peer, peer, saved.projectRoot);
|
|
392
|
+
if (session !== saved.sessionId || projectDefinition(saved.peer, saved.projectRoot)?.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 }) });
|
|
370
393
|
return current;
|
|
371
394
|
};
|
|
372
395
|
/** Whether Orca's inventory lists the terminal; a failed, not-ok, truncated or non-JSON answer shows nothing. */
|
|
@@ -391,7 +414,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
391
414
|
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" }) };
|
|
392
415
|
}
|
|
393
416
|
const attached = target.peers.find((p) => p.id === peer && p.state !== "offline");
|
|
394
|
-
const session = observedSession(peer, attached);
|
|
417
|
+
const session = observedSession(peer, attached, planned.project.root);
|
|
395
418
|
if (session) return { state: "live", session };
|
|
396
419
|
let endedTerminal: Pick<TerminalBinding, "handle" | "incarnationId" | "worktreeId"> | undefined;
|
|
397
420
|
// #225: the launch records of every instance this operation started for the project, the current one first: a
|
|
@@ -487,7 +510,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
487
510
|
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 });
|
|
488
511
|
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 });
|
|
489
512
|
const peer = live.peers.find((p) => p.id === binding.peer && p.state !== "offline");
|
|
490
|
-
const session = observedSession(binding.peer, peer);
|
|
513
|
+
const session = observedSession(binding.peer, peer, binding.projectRoot);
|
|
491
514
|
const changed = !!session && expectedSession !== null && session !== expectedSession;
|
|
492
515
|
// #64: only the original zero-turn Claude may change identity without an operator choice. Re-read persistence
|
|
493
516
|
// at attachment, not at the plan or wait start; an already accepted replacement is bound to its own id.
|
|
@@ -540,7 +563,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
540
563
|
}
|
|
541
564
|
} catch { /* unavailable metadata stays unknown; raw output/errors never enter the receipt */ }
|
|
542
565
|
const peer = live.peers.find((p) => p.id === binding.peer);
|
|
543
|
-
const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer);
|
|
566
|
+
const session = peer?.state === "offline" ? undefined : observedSession(binding.peer, peer, binding.projectRoot);
|
|
544
567
|
(progress.attachmentTimeouts ??= {})[binding.peer] = { at: now(), terminal,
|
|
545
568
|
hub: { state: live.state, ...(live.instanceId ? { instanceId: live.instanceId } : {}), ...(peer ? { peerState: peer.state } : {}), ...(session ? { sessionId: session } : {}) },
|
|
546
569
|
lastHubConnectAttempt: "unknown" };
|
|
@@ -572,10 +595,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
572
595
|
}
|
|
573
596
|
// Reuse capability proof only after verifying the cached root and digest still identify these exact bytes.
|
|
574
597
|
const cached = op.targetDigest === target.digest && op.targetRoot === target.root;
|
|
575
|
-
|
|
576
|
-
//
|
|
598
|
+
// Once every source committed, compatibility cannot be re-applied using mutable project declarations.
|
|
599
|
+
// Package identity/hash and live-test authority above still bind these retained bytes.
|
|
600
|
+
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) {
|
|
601
|
+
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
602
|
+
}
|
|
603
|
+
if (cached && op.targetFeatures !== undefined) {
|
|
604
|
+
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
605
|
+
return { ...target, targetFeatures: [...op.targetFeatures] };
|
|
606
|
+
}
|
|
577
607
|
// A prepared commitSent receipt remains uncertain and must still obtain proof before a possible commit.
|
|
578
|
-
if (cached && op.projects?.every(project => ["stopped", "started", "peers-restored", "verified"].includes(project.phase))) return target;
|
|
579
608
|
// Legacy uncommitted receipts acquire verified feature evidence before source compatibility checks.
|
|
580
609
|
const probe = await run([process.execPath, "-e", `import * as target from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(JSON.stringify({protocol: target.PROTOCOL, features: target.DAEMON_FEATURES ?? []}))`]);
|
|
581
610
|
let metadata: { protocol?: unknown; features?: unknown } | undefined;
|
|
@@ -584,6 +613,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
584
613
|
if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
|
|
585
614
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
586
615
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
616
|
+
if (!targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
587
617
|
const verified = { ...target, targetFeatures };
|
|
588
618
|
// Existing peer/waiver compatibility checks remain properties of the already verified bytes.
|
|
589
619
|
if (op.targetDigest && op.targetDigest === target.digest && op.targetRoot === target.root) return verified;
|
|
@@ -630,7 +660,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
630
660
|
progress.terminals[key] = true; save(); continue;
|
|
631
661
|
}
|
|
632
662
|
// #215: check resume viability again right before the destructive effect (a planned fresh start has none to lose).
|
|
633
|
-
const transcript =
|
|
663
|
+
const transcript = projectDefinition(binding.peer, binding.projectRoot)?.session.missingEvidence === "rollout" ? sessionState(binding) : "found";
|
|
634
664
|
if (transcript === "unknown") throw new Error(`${unresumable(binding)}; no terminal was closed; make that store readable first`);
|
|
635
665
|
if (transcript === "missing" && !planned.freshStart?.includes("codex")) {
|
|
636
666
|
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`);
|
|
@@ -663,11 +693,11 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
663
693
|
const live = await inspectRecovery(planned.project);
|
|
664
694
|
if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
|
|
665
695
|
if (group === "native" && !onlyPeer) {
|
|
666
|
-
for (const peer of planned.source.peers.filter(p =>
|
|
696
|
+
for (const peer of planned.source.peers.filter(p => projectDefinition(p.id, planned.project.root)?.session.restoreArgs && p.state !== "offline" && !(projectDefinition(p.id, planned.project.root)?.session.launchRecord && p.args?.mode === "tui"))) {
|
|
667
697
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
668
698
|
if (current && current.state !== "offline") continue;
|
|
669
|
-
const adapter =
|
|
670
|
-
const args = [peer.id];
|
|
699
|
+
const adapter = projectDefinition(peer.id, planned.project.root)!;
|
|
700
|
+
const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
|
|
671
701
|
const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
|
|
672
702
|
if (refusal) throw new Error(refusal);
|
|
673
703
|
const captured = adapter.session.restoreArgs!(peer);
|
|
@@ -679,7 +709,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
679
709
|
}
|
|
680
710
|
let claudeGroup = false;
|
|
681
711
|
for (const plannedBinding of planned.terminals as TerminalBinding[]) {
|
|
682
|
-
if ((
|
|
712
|
+
if ((projectDefinition(plannedBinding.peer, plannedBinding.projectRoot)?.transport === "claude-channel") !== (claudeGroup = group !== "native") || onlyPeer && plannedBinding.peer !== onlyPeer) continue;
|
|
683
713
|
const key = `restored:${plannedBinding.peer}`;
|
|
684
714
|
try {
|
|
685
715
|
// #225: after a restart, the stopped target's terminal for this peer goes first; a session it had accepted
|
|
@@ -697,16 +727,16 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
697
727
|
// start of a Codex thread with nothing to lose that still has no rollout; an unreadable store is neither.
|
|
698
728
|
const chosen = !recordedOnly && !!progress.fresh?.[original.peer];
|
|
699
729
|
let read: ReturnType<typeof sessionState> | undefined;
|
|
700
|
-
const transcript = () =>
|
|
730
|
+
const transcript = () => projectDefinition(original.peer, original.projectRoot)?.session.missingEvidence !== "rollout" || (chosen && !accepted) ? "found" : (read ??= sessionState(original));
|
|
701
731
|
// An accepted session starts new again only when it has nothing to resume: a Codex thread that never got a
|
|
702
732
|
// rollout, or a Claude session with no transcript (`--resume` could never succeed, #21).
|
|
703
733
|
const fresh = () => recordedOnly ? false : accepted
|
|
704
|
-
? (
|
|
734
|
+
? (projectDefinition(original.peer, original.projectRoot)?.session.missingEvidence === "rollout" ? (chosen || !!planned.freshStart?.includes(original.peer)) && transcript() === "missing" : nativeZeroTurn(planned.project.stateDir, original, "attach"))
|
|
705
735
|
: chosen || (!!planned.freshStart?.includes(original.peer) && transcript() === "missing");
|
|
706
736
|
// The runner ends this with the choices `status` shows (nextActions); resume comes first and launches a failed
|
|
707
737
|
// peer again, which helps once its cause (a transient Orca create failure, a fixed store) is gone.
|
|
708
738
|
// The waiver's audit label: the operator's choice, a Claude with nothing to resume, or the plan's Codex fresh start.
|
|
709
|
-
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" :
|
|
739
|
+
const waiver = () => progress.fresh?.[original.peer] ? "fresh-session" : projectDefinition(original.peer, original.projectRoot)?.transport === "claude-channel" ? "zero-turn" : "fresh-start";
|
|
710
740
|
const notRestored = (why: string) => new Error(fresh()
|
|
711
741
|
? `${original.peer}: ${why}; read that terminal in Orca for the launcher's error and fix its cause`
|
|
712
742
|
: `${original.peer}: ${why}; session ${original.sessionId} was not restored; resume launches it again once the cause is fixed`);
|
|
@@ -807,7 +837,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
807
837
|
// The planned session already attached is the restoration (the table's row for it), not a reason to block.
|
|
808
838
|
const evidence = await peerEvidence(planned, progress, original.peer);
|
|
809
839
|
if (evidence.state === "live" && evidence.session === original.sessionId) {
|
|
810
|
-
if (repair) await settle(evidence.session);
|
|
840
|
+
if (repair) await settle(evidence.session!); // equality with the recorded string session id above proves this value
|
|
811
841
|
else { progress.terminals[key] = { state: "done", binding: await revalidateTerminal(planned, progress, original, false) } satisfies RestoreReceipt; save(); }
|
|
812
842
|
continue;
|
|
813
843
|
}
|
|
@@ -815,7 +845,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
815
845
|
if (transcript() === "unknown") throw new Error(`${unresumable(original)}; no terminal was created; make that store readable first`);
|
|
816
846
|
if (transcript() === "missing" && !fresh()) {
|
|
817
847
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
818
|
-
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind:
|
|
848
|
+
if (recordedOnly) throw new MissingRecordedSession({ projectId: planned.project.id, peer: original.peer, sessionId: original.sessionId, kind: projectDefinition(original.peer, original.projectRoot)!.session.missingEvidence!, checkedPath: sessionPath(original) });
|
|
819
849
|
throw notRestored(unresumable(original).slice("codex: ".length));
|
|
820
850
|
}
|
|
821
851
|
if (fresh()) {
|
|
@@ -878,7 +908,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
878
908
|
const marker = progress.reattachments?.[plannedBinding.peer];
|
|
879
909
|
if (marker) marker.binding = binding;
|
|
880
910
|
progress.terminals[key] = { state: "failed" } satisfies RestoreReceipt; save();
|
|
881
|
-
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind:
|
|
911
|
+
throw new MissingRecordedSession({ projectId: planned.project.id, peer: plannedBinding.peer, sessionId: binding.sessionId, kind: projectDefinition(plannedBinding.peer, plannedBinding.projectRoot)!.session.missingEvidence!, checkedPath: sessionPath(binding) });
|
|
882
912
|
}
|
|
883
913
|
}
|
|
884
914
|
if (error instanceof ReattachmentRequired || isDatabaseBusy(error)) throw error;
|
|
@@ -899,12 +929,13 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
899
929
|
installPlugin: async (op) => {
|
|
900
930
|
const configured = new Set<string>();
|
|
901
931
|
for (const planned of op.plan.projects) {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
932
|
+
// Every Claude binding (the builtin and each instance), once per distinct profile, each under its own profile.
|
|
933
|
+
for (const binding of (planned.terminals as TerminalBinding[]).filter((t) => projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel")) {
|
|
934
|
+
const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
|
|
935
|
+
if (configured.has(profile)) continue;
|
|
936
|
+
await command(op, ["setup", "--yes"], planned.project, binding);
|
|
937
|
+
configured.add(profile);
|
|
938
|
+
}
|
|
908
939
|
}
|
|
909
940
|
if (!configured.size) await command(op, ["setup", "--yes"]);
|
|
910
941
|
},
|
|
@@ -936,7 +967,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
936
967
|
try { archived = JSON.parse(readFileSync(archive, "utf8")); } catch { return { continuity: "not-archived" }; }
|
|
937
968
|
if (!archived || typeof archived !== "object" || (archived as { operationId?: unknown }).operationId !== op.id) return { continuity: "not-archived" };
|
|
938
969
|
try {
|
|
939
|
-
const continuity =
|
|
970
|
+
const continuity = projectDefinition(source.id, project.root)?.session.continuityBookmark;
|
|
940
971
|
if (!continuity) return;
|
|
941
972
|
const binding = planned ? resolveAcceptedBinding(planned, progress, source.id as TerminalBinding["peer"]) : undefined;
|
|
942
973
|
writeRecoveryContinuity(project.stateDir, { schema: 1, operationId: op.id, projectId: project.id, projectRoot: project.root, at: now(),
|
|
@@ -963,21 +994,21 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
963
994
|
// A #225 restart clears the old target's repair fence, never its accepted conversation.
|
|
964
995
|
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;
|
|
965
996
|
if ((recordedOnly || retained) && accepted?.state === "done") {
|
|
966
|
-
const session = observedSession(old.id, peer);
|
|
997
|
+
const session = observedSession(old.id, peer, planned.project.root);
|
|
967
998
|
if (session !== accepted.binding.sessionId || repair && accepted.binding.sessionId !== repair.binding.sessionId ||
|
|
968
|
-
|
|
999
|
+
projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.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 }) });
|
|
969
1000
|
continue;
|
|
970
1001
|
}
|
|
971
1002
|
if (!recordedOnly && (progress.fresh?.[old.id] || planned.freshStart?.includes(old.id))) continue; // #215: a new session was accepted (recorded)
|
|
972
|
-
if (
|
|
973
|
-
if (
|
|
1003
|
+
if (projectDefinition(old.id, planned.project.root)?.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 } : {}) });
|
|
1004
|
+
if (projectDefinition(old.id, planned.project.root)?.transport === "claude-channel" && peer.sessionId !== old.sessionId) {
|
|
974
1005
|
// #64: the restore gate already accepted a fresh session when the original never
|
|
975
1006
|
// persisted a transcript. Re-derive persistence from the binding exactly like that
|
|
976
1007
|
// gate did, so a first turn landing after the plan was made keeps the check strict.
|
|
977
|
-
const binding = (planned.terminals as TerminalBinding[]).find((t) =>
|
|
1008
|
+
const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === old.id && projectDefinition(t.peer, t.projectRoot)?.transport === "claude-channel");
|
|
978
1009
|
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 } : {}) });
|
|
979
1010
|
}
|
|
980
|
-
if (
|
|
1011
|
+
if (projectDefinition(old.id, planned.project.root)?.session.launchRecord && projectDefinition(old.id, planned.project.root)?.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 } : {}) });
|
|
981
1012
|
}
|
|
982
1013
|
for (const original of planned.terminals as TerminalBinding[]) {
|
|
983
1014
|
const saved = decodeRestoreReceipt(progress.terminals[`restored:${original.peer}`]);
|