@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,6 +1,9 @@
|
|
|
1
1
|
import { shellQuote } from "./shell-quote.ts";
|
|
2
2
|
export { shellQuote } from "./shell-quote.ts";
|
|
3
|
-
import { definitionOf } from "../adapters/registry.ts";
|
|
3
|
+
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
4
|
+
import { readInstanceConfig, readInstanceRoleConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import { effectiveInstanceRoles } from "../hub/instances.ts";
|
|
6
|
+
import { resolveInstanceExecutionCwd } from "../hub/instance-execution.ts";
|
|
4
7
|
import { isAbsolute, join, resolve } from "node:path";
|
|
5
8
|
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
@@ -28,7 +31,7 @@ export type CommandRunner = (argv: readonly string[]) => Promise<CommandResult>;
|
|
|
28
31
|
/** Stable process identity hook. It returns a digest, never raw process metadata. */
|
|
29
32
|
export type ProcessIdentity = (pid: number) => string | undefined | Promise<string | undefined>;
|
|
30
33
|
|
|
31
|
-
export type TerminalPeer =
|
|
34
|
+
export type TerminalPeer = string;
|
|
32
35
|
export interface SessionRef { sessionId: string; sessionFile?: string; backend?: string; model?: string; }
|
|
33
36
|
|
|
34
37
|
export interface LaunchMetadata {
|
|
@@ -48,6 +51,10 @@ export interface TerminalBinding {
|
|
|
48
51
|
incarnationId: string;
|
|
49
52
|
worktreeId: string;
|
|
50
53
|
projectRoot: string;
|
|
54
|
+
/** Authenticated declared-extra execution root; canonical projectRoot remains the authority. */
|
|
55
|
+
executionCwd?: string;
|
|
56
|
+
/** Verified Orca grouping root, only canonical project or the authorized execution worktree. */
|
|
57
|
+
terminalRoot?: string;
|
|
51
58
|
sessionId: string;
|
|
52
59
|
sessionFile?: string;
|
|
53
60
|
backend?: string;
|
|
@@ -129,6 +136,8 @@ export interface TerminalCreateResult {
|
|
|
129
136
|
|
|
130
137
|
export interface TerminalRecoveryOptions {
|
|
131
138
|
runner?: CommandRunner;
|
|
139
|
+
/** Exact execution root returned by authenticated extra startup; checked against the current declaration. */
|
|
140
|
+
executionCwd?: string;
|
|
132
141
|
/** Defaults to `orca`; callers in a managed session may pin another executable. */
|
|
133
142
|
orcaExecutable?: string;
|
|
134
143
|
/** Absolute path to this package's JS entrypoint. */
|
|
@@ -150,6 +159,8 @@ export interface TerminalRecoveryOptions {
|
|
|
150
159
|
export interface RecordedTerminalLaunch {
|
|
151
160
|
peer: TerminalPeer;
|
|
152
161
|
projectRoot: string;
|
|
162
|
+
executionCwd?: string;
|
|
163
|
+
terminalRoot?: string;
|
|
153
164
|
stateDir: string;
|
|
154
165
|
instanceId: string;
|
|
155
166
|
launcherPid: number;
|
|
@@ -243,6 +254,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
|
|
|
243
254
|
runner: config.runner ?? defaultRunner(orcaExecutable),
|
|
244
255
|
orcaExecutable,
|
|
245
256
|
packageEntrypoint: config.packageEntrypoint ?? defaultEntrypoint,
|
|
257
|
+
executionCwd: config.executionCwd ?? "",
|
|
246
258
|
stateDir: config.stateDir ?? "",
|
|
247
259
|
instanceId: config.instanceId ?? "",
|
|
248
260
|
processIdentity: config.processIdentity ?? defaultProcessIdentity,
|
|
@@ -312,9 +324,35 @@ export function recordPath(stateDir: string): string {
|
|
|
312
324
|
return join(stateDir, RECORD_FILE);
|
|
313
325
|
}
|
|
314
326
|
|
|
327
|
+
/** Extra terminal ownership is supported only for the native proxy transport; other I2 extras stay headless. */
|
|
328
|
+
function executionRoot(peer: string, projectRoot: string, captured?: string): string {
|
|
329
|
+
const builtin = definitionOf(peer);
|
|
330
|
+
if (builtin?.session.launchRecord) return projectRoot;
|
|
331
|
+
const { instances: declarations, roles } = readInstanceRoleConfig(projectRoot);
|
|
332
|
+
const definition = definitionOf(peer, declarations);
|
|
333
|
+
if (!definition?.session.launchRecord) throw new Error(`${peer}: no managed terminal recovery adapter`);
|
|
334
|
+
if (!declarations?.[peer] || definition.transport !== "codex-appserver" && definition.transport !== "claude-channel") throw new Error(`${peer}: extra instance has no managed terminal support`);
|
|
335
|
+
// #450 I3: a Claude instance reviews or plans in the canonical checkout; it never owns an execution worktree.
|
|
336
|
+
if (definition.transport === "claude-channel") {
|
|
337
|
+
if (captured !== undefined && captured !== projectRoot) throw new Error(`${peer}: recorded execution root changed; manual recovery required`);
|
|
338
|
+
return projectRoot;
|
|
339
|
+
}
|
|
340
|
+
const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
|
|
341
|
+
if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
|
|
342
|
+
return expected;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function savedTerminalRoot(binding: Pick<TerminalBinding, "peer" | "projectRoot" | "executionCwd" | "terminalRoot">): string {
|
|
346
|
+
const expected = executionRoot(binding.peer, binding.projectRoot, binding.executionCwd ?? binding.projectRoot);
|
|
347
|
+
if (definitionOf(binding.peer)) return binding.projectRoot;
|
|
348
|
+
const root = binding.terminalRoot ?? binding.projectRoot;
|
|
349
|
+
if (root !== binding.projectRoot && root !== expected) throw new Error(`${binding.peer}: recorded Orca grouping root is not authorized`);
|
|
350
|
+
return root;
|
|
351
|
+
}
|
|
352
|
+
|
|
315
353
|
function validRecord(item: unknown): item is RecordedTerminalLaunch {
|
|
316
354
|
const row = object(item);
|
|
317
|
-
return typeof row.peer === "string" && typeof row.projectRoot === "string" && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
355
|
+
return typeof row.peer === "string" && typeof row.projectRoot === "string" && (row.executionCwd === undefined || typeof row.executionCwd === "string") && (row.terminalRoot === undefined || typeof row.terminalRoot === "string") && typeof row.instanceId === "string" && typeof row.handle === "string" && typeof row.incarnationId === "string" && typeof row.worktreeId === "string" && typeof row.launcherPid === "number" && typeof row.launcherSignature === "string" && row.launcherSignature.length > 0 && typeof row.launchId === "string" && row.launchId.length > 0;
|
|
318
356
|
}
|
|
319
357
|
|
|
320
358
|
/**
|
|
@@ -410,13 +448,15 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
410
448
|
const handle = process.env.ORCA_TERMINAL_HANDLE;
|
|
411
449
|
const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
|
|
412
450
|
if (!handle || !worktreeIdFromEnv) return undefined;
|
|
451
|
+
if (!definitionOf(peer) && !config.executionCwd) throw new Error(`${peer}: recording an extra terminal requires its authenticated startup execution root`);
|
|
452
|
+
const expectedRoot = executionRoot(peer, projectRoot, config.executionCwd || undefined);
|
|
413
453
|
const value = await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]);
|
|
414
454
|
const terminal = terminalObject(value);
|
|
415
455
|
const actualRoot = nestedString(terminal, ["worktreePath", "projectRoot"]);
|
|
416
456
|
const actualHandle = nestedString(terminal, ["handle"]);
|
|
417
457
|
const actualWorktree = nestedString(terminal, ["worktreeId"]);
|
|
418
458
|
const incarnationId = nestedString(terminal, ["incarnationId"]);
|
|
419
|
-
if (actualHandle !== handle || !actualRoot || !rootMatches(actualRoot, projectRoot) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
|
|
459
|
+
if (actualHandle !== handle || !actualRoot || !(rootMatches(actualRoot, projectRoot) || !definitionOf(peer) && rootMatches(actualRoot, expectedRoot)) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
|
|
420
460
|
throw new OrcaCommandError(blocker("project-root-mismatch", "Orca launch terminal failed canonical root/worktree readback", peer, handle));
|
|
421
461
|
}
|
|
422
462
|
const launcherSignature = await config.processIdentity(process.pid);
|
|
@@ -424,7 +464,7 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
|
|
|
424
464
|
const launchId = randomUUID();
|
|
425
465
|
process.env.AGENTHUB_LAUNCH_ID = launchId;
|
|
426
466
|
process.env.AGENTHUB_INSTANCE_ID = instanceId;
|
|
427
|
-
const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
467
|
+
const row: RecordedTerminalLaunch = { peer, projectRoot, ...(!definitionOf(peer) ? { executionCwd: expectedRoot, terminalRoot: actualRoot } : {}), stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
|
|
428
468
|
const existing = launchRecords(stateDir);
|
|
429
469
|
// #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
|
|
430
470
|
// then read those launchers as gone.
|
|
@@ -470,12 +510,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
|
|
|
470
510
|
return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
|
|
471
511
|
}
|
|
472
512
|
|
|
473
|
-
function identityFrom(terminal: Record<string, unknown
|
|
513
|
+
function identityFrom(terminal: Record<string, unknown>, instances?: KindMap): TerminalPeer | undefined {
|
|
474
514
|
const wait = object(terminal.agentWait);
|
|
475
515
|
const session = object(terminal.session);
|
|
476
516
|
const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
|
|
477
517
|
for (const candidate of candidates) {
|
|
478
|
-
if (typeof candidate === "string" && definitionOf(candidate)?.session.launchRecord) return candidate as TerminalPeer;
|
|
518
|
+
if (typeof candidate === "string" && definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
|
|
479
519
|
}
|
|
480
520
|
return undefined;
|
|
481
521
|
}
|
|
@@ -513,7 +553,9 @@ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, o
|
|
|
513
553
|
const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
|
|
514
554
|
incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
|
|
515
555
|
...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
516
|
-
const
|
|
556
|
+
const adapter = definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
|
|
557
|
+
const nativeTail = adapter?.session.resumeArgv?.(binding, false);
|
|
558
|
+
const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
|
|
517
559
|
if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
|
|
518
560
|
const argv = [...launch.argv, ...tail];
|
|
519
561
|
const command = [...prefix, ...argv.map(shellQuote)].join(" ");
|
|
@@ -538,7 +580,10 @@ function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBin
|
|
|
538
580
|
const root = nestedString(actual, ["worktreePath", "projectRoot"]);
|
|
539
581
|
const worktree = nestedString(actual, ["worktreeId"]);
|
|
540
582
|
if (handle !== binding.handle || incarnation !== binding.incarnationId || worktree !== binding.worktreeId) return blocker("stale-terminal", "captured terminal identity changed; refusing to mutate it", binding.peer, binding.handle);
|
|
541
|
-
|
|
583
|
+
let expectedRoot: string;
|
|
584
|
+
try { expectedRoot = savedTerminalRoot(binding); }
|
|
585
|
+
catch { return blocker("project-root-mismatch", "declared terminal execution root changed or is unavailable; refusing to mutate it", binding.peer, binding.handle); }
|
|
586
|
+
if (!rootMatches(root, expectedRoot)) return blocker("project-root-mismatch", "terminal belongs to a different project root; refusing to mutate it", binding.peer, binding.handle);
|
|
542
587
|
return undefined;
|
|
543
588
|
}
|
|
544
589
|
|
|
@@ -560,6 +605,7 @@ export async function inspectSavedTerminal(binding: TerminalBinding, options?: C
|
|
|
560
605
|
const completeIdentity = (value: Record<string, unknown>) =>
|
|
561
606
|
[nestedString(value, ["handle"]), nestedString(value, ["incarnationId"]), nestedString(value, ["worktreeId"]), nestedString(value, ["worktreePath", "projectRoot"])].every(Boolean);
|
|
562
607
|
try {
|
|
608
|
+
savedTerminalRoot(binding);
|
|
563
609
|
const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]));
|
|
564
610
|
if (!completeIdentity(shown)) return unknown();
|
|
565
611
|
const issue = verifyBindingData(shown, binding);
|
|
@@ -603,6 +649,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
603
649
|
const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
|
|
604
650
|
const requested = (Object.entries(sessions) as [TerminalPeer, string | SessionRef | undefined][]).map(([peer, value]) => [peer, typeof value === "string" ? { sessionId: value } : value] as const).filter((entry): entry is [TerminalPeer, SessionRef] => !!entry[1]?.sessionId);
|
|
605
651
|
if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
|
|
652
|
+
const instances = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot).instances : undefined;
|
|
606
653
|
// #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
|
|
607
654
|
// CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
|
|
608
655
|
const rows = launchRecords(config.stateDir);
|
|
@@ -619,6 +666,15 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
619
666
|
|
|
620
667
|
for (const [peer, ref] of requested) {
|
|
621
668
|
const sessionId = ref.sessionId;
|
|
669
|
+
const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
|
|
670
|
+
if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver" && adapter.transport !== "claude-channel") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
|
|
671
|
+
const ownRecord = records.find(item => item.peer === peer);
|
|
672
|
+
let expectedRoot: string, expectedTerminalRoot: string;
|
|
673
|
+
try {
|
|
674
|
+
expectedRoot = executionRoot(peer, projectRoot, ownRecord?.executionCwd ?? projectRoot);
|
|
675
|
+
expectedTerminalRoot = savedTerminalRoot({ peer, projectRoot, executionCwd: ownRecord?.executionCwd, terminalRoot: ownRecord?.terminalRoot });
|
|
676
|
+
}
|
|
677
|
+
catch { blockers.push(blocker("ownership-unknown", `${peer}: declared execution worktree changed or is unverified`, peer)); continue; }
|
|
622
678
|
// #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
|
|
623
679
|
const unread = unknown.find((item) => item.peer === peer);
|
|
624
680
|
if (unread) {
|
|
@@ -629,7 +685,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
629
685
|
blockers.push(blocker("ownership-unknown", `${recordPath(config.stateDir)} holds a launch record that cannot be evaluated and may be this ${peer} launcher's, so no terminal can be bound to its launch; next action: inspect that row and fix it, or end that launcher and remove its row, then plan or resume again`, peer));
|
|
630
686
|
continue;
|
|
631
687
|
}
|
|
632
|
-
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot,
|
|
688
|
+
const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot));
|
|
633
689
|
const peerCandidates: Record<string, unknown>[] = [];
|
|
634
690
|
for (const listedTerminal of candidates) {
|
|
635
691
|
const handle = nestedString(listedTerminal, ["handle"]);
|
|
@@ -642,16 +698,16 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
642
698
|
continue;
|
|
643
699
|
}
|
|
644
700
|
// A show readback is required; list can be stale between recovery steps.
|
|
645
|
-
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot,
|
|
646
|
-
const identity = identityFrom(terminal);
|
|
701
|
+
if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
|
|
702
|
+
const identity = identityFrom(terminal, instances);
|
|
647
703
|
const terminalSession = sessionFrom(terminal);
|
|
648
704
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
649
705
|
// A recorded launcher proves ownership; the daemon's supplied session id is the
|
|
650
706
|
// authoritative session id when Orca omits it from terminal metadata.
|
|
651
|
-
if ((identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
707
|
+
if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
|
|
652
708
|
}
|
|
653
709
|
if (!peerCandidates.length) {
|
|
654
|
-
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
710
|
+
const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances) === undefined && !records.some((item) => item.handle === terminal.handle));
|
|
655
711
|
blockers.push(ownershipUnknown
|
|
656
712
|
? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
|
|
657
713
|
: blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
|
|
@@ -676,9 +732,9 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
676
732
|
const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
|
|
677
733
|
// Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
|
|
678
734
|
const { args: _launchArgs, ...observedTerminal } = terminal;
|
|
679
|
-
const sessionFile = definitionOf(peer)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
735
|
+
const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
|
|
680
736
|
const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
|
|
681
|
-
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
737
|
+
const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, ...(extra ? { executionCwd: expectedRoot, terminalRoot: expectedTerminalRoot } : {}), sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
|
|
682
738
|
bindings.push(binding);
|
|
683
739
|
byPeer[peer] = binding;
|
|
684
740
|
}
|
|
@@ -750,6 +806,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
|
|
|
750
806
|
const config = normalizeOptions(options);
|
|
751
807
|
let made: string | undefined; // a later failure must still report the terminal it created
|
|
752
808
|
try {
|
|
809
|
+
savedTerminalRoot(binding);
|
|
753
810
|
const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
|
|
754
811
|
// The coordinator journals a pending creation before this call and reconciles
|
|
755
812
|
// it on resume. Other agents in the worktree do not imply a prior create.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
2
3
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
3
4
|
|
|
4
5
|
export type RestoreReceipt =
|
|
@@ -23,10 +24,16 @@ export type RestoreReceiptStatus = "unstarted" | "pending" | "waiting:pending" |
|
|
|
23
24
|
function malformed(): never { throw new Error("invalid restore receipt"); }
|
|
24
25
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
25
26
|
const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
26
|
-
function terminalBinding(value: unknown): TerminalBinding {
|
|
27
|
-
if (!object(value) || typeof value.peer !== "string"
|
|
27
|
+
function terminalBinding(value: unknown, historical = false): TerminalBinding {
|
|
28
|
+
if (!object(value) || typeof value.peer !== "string") malformed();
|
|
28
29
|
for (const key of ["handle", "incarnationId", "worktreeId", "projectRoot", "sessionId"]) if (!text(value[key])) malformed();
|
|
29
|
-
|
|
30
|
+
if (historical) {
|
|
31
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(value.peer)) malformed();
|
|
32
|
+
} else try {
|
|
33
|
+
const adapter = definitionOf(value.peer) ?? definitionOf(value.peer, readInstanceConfig(value.projectRoot as string).instances);
|
|
34
|
+
if (!adapter?.session.launchRecord) malformed();
|
|
35
|
+
} catch { malformed(); }
|
|
36
|
+
for (const key of ["sessionFile", "backend", "model", "executionCwd", "terminalRoot"]) if (value[key] !== undefined && typeof value[key] !== "string") malformed();
|
|
30
37
|
const launch = value.launch;
|
|
31
38
|
const validLaunch = (v: unknown) => object(v) && text(v.packageEntrypoint) && text(v.command)
|
|
32
39
|
&& Array.isArray(v.argv) && v.argv.every(arg => typeof arg === "string")
|
|
@@ -36,7 +43,9 @@ function terminalBinding(value: unknown): TerminalBinding {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** Decode legacy representations only here. Absence is unstarted, never pending or failed. */
|
|
39
|
-
export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined {
|
|
46
|
+
export function decodeRestoreReceipt(value: unknown, options: { authority?: "current" | "historical" } = {}): RestoreReceipt | undefined {
|
|
47
|
+
// Historical decoding proves receipt structure only, never current permission to launch or replace a peer.
|
|
48
|
+
const historical = options.authority === "historical";
|
|
40
49
|
if (value === undefined) return undefined;
|
|
41
50
|
if (value === "pending" || value === "failed") return { state: value };
|
|
42
51
|
if (!object(value)) malformed();
|
|
@@ -47,14 +56,14 @@ export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined
|
|
|
47
56
|
case "pending": case "failed": return { state: value.state };
|
|
48
57
|
case "waiting": {
|
|
49
58
|
if (value.origin !== "pending" && value.origin !== "failed") malformed();
|
|
50
|
-
return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding) };
|
|
59
|
+
return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding, historical) };
|
|
51
60
|
}
|
|
52
|
-
case "done": return { state: "done", binding: terminalBinding(value.binding) };
|
|
61
|
+
case "done": return { state: "done", binding: terminalBinding(value.binding, historical) };
|
|
53
62
|
default: return malformed();
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
if (Object.hasOwn(value, "attachmentReceipt")) malformed();
|
|
57
|
-
return { state: "done", binding: terminalBinding(value) };
|
|
66
|
+
return { state: "done", binding: terminalBinding(value, historical) };
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {
|