@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +8 -6
  3. package/docs/agent-notes/adapters.md +6 -1
  4. package/docs/agent-notes/budget.md +4 -2
  5. package/docs/agent-notes/bus.md +5 -1
  6. package/docs/agent-notes/daemon.md +11 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +15 -2
  9. package/docs/agent-notes/tests.md +5 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +97 -19
  12. package/docs/quickstart.md +8 -4
  13. package/docs/sdlc.md +100 -34
  14. package/docs/security.md +7 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +237 -7
  16. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  17. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  18. package/docs/verification/2026-10-19-0.12.36.md +63 -0
  19. package/docs/verified.json +26 -17
  20. package/package.json +1 -1
  21. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  22. package/plugins/agent-hub/server.js +15781 -14850
  23. package/src/adapters/acp.ts +3 -1
  24. package/src/adapters/claude-channel.ts +59 -29
  25. package/src/adapters/claude-print.ts +230 -0
  26. package/src/adapters/codex-appserver.ts +1 -1
  27. package/src/adapters/definitions/builtin-metadata.ts +17 -1
  28. package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
  29. package/src/adapters/definitions/native-cli-entry-codex.ts +13 -5
  30. package/src/adapters/definitions/native-cli-entry-jcode.ts +6 -2
  31. package/src/adapters/definitions/native-cli-entry-kimi.ts +18 -12
  32. package/src/adapters/definitions/native-cli-entry-local.ts +3 -2
  33. package/src/adapters/definitions/native-cli-entry-pi.ts +8 -6
  34. package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -2
  35. package/src/adapters/definitions/native-cli-entry-types.ts +3 -0
  36. package/src/adapters/definitions/native-factories.ts +7 -0
  37. package/src/adapters/definitions/native-recovery.ts +2 -2
  38. package/src/adapters/definitions/qwen-native.ts +2 -2
  39. package/src/adapters/local-worker.ts +3 -2
  40. package/src/adapters/pi.ts +3 -1
  41. package/src/adapters/registry-types.ts +11 -2
  42. package/src/adapters/registry.ts +36 -8
  43. package/src/cli/help.ts +5 -0
  44. package/src/cli/identity.ts +5 -4
  45. package/src/cli/launch.ts +45 -7
  46. package/src/cli/main.ts +136 -29
  47. package/src/cli/preview.ts +16 -5
  48. package/src/cli/recovery-guidance.ts +39 -22
  49. package/src/cli/reset.ts +4 -2
  50. package/src/cli/statusline-tee.ts +5 -2
  51. package/src/cli/terminal-recovery.ts +73 -16
  52. package/src/cli/upgrade-restore-state.ts +16 -7
  53. package/src/cli/upgrade-runtime.ts +83 -52
  54. package/src/cli/upgrade.ts +43 -27
  55. package/src/hub/board.ts +29 -7
  56. package/src/hub/budget.ts +55 -3
  57. package/src/hub/bus.ts +12 -6
  58. package/src/hub/claude-state.ts +37 -0
  59. package/src/hub/conductor.ts +16 -10
  60. package/src/hub/config-apply.ts +13 -2
  61. package/src/hub/config-reload.ts +11 -1
  62. package/src/hub/config-trust.ts +1 -1
  63. package/src/hub/control-client.ts +1 -1
  64. package/src/hub/cost-governance.ts +76 -0
  65. package/src/hub/crash.ts +14 -5
  66. package/src/hub/daemon.ts +473 -194
  67. package/src/hub/execution-budget.ts +3 -3
  68. package/src/hub/hub-tools.ts +36 -6
  69. package/src/hub/instance-budget.ts +73 -0
  70. package/src/hub/instance-config.ts +47 -0
  71. package/src/hub/instance-execution.ts +50 -0
  72. package/src/hub/instances.ts +114 -0
  73. package/src/hub/lifecycle-schema.ts +7 -3
  74. package/src/hub/permission-mode.ts +7 -7
  75. package/src/hub/review-brief.ts +175 -0
  76. package/src/hub/roster.ts +7 -6
  77. package/src/hub/routing.ts +97 -18
  78. package/src/hub/sdlc-queue.ts +2 -2
  79. package/src/hub/sdlc.ts +286 -39
  80. package/src/hub/settings.ts +34 -6
  81. package/src/hub/snapshots.ts +40 -9
  82. package/src/hub/start-mode.ts +7 -4
  83. package/src/hub/status-roster.ts +18 -0
  84. package/src/hub/task-sweep.ts +143 -0
  85. package/src/hub/tasks.ts +595 -47
  86. package/src/hub/text-projection.ts +7 -3
  87. package/src/hub/usage.ts +57 -20
  88. package/src/hub/workflow.ts +30 -52
  89. package/src/pi/launch.ts +4 -3
  90. package/src/ui/index.html +1 -1
  91. 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 = "codex" | "claude" | "pi";
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>): TerminalPeer | undefined {
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 tail = definitionOf(peer)?.session.resumeArgv?.(binding, false);
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
- if (!rootMatches(root, binding.projectRoot)) return blocker("project-root-mismatch", "terminal belongs to a different project root; refusing to mutate it", binding.peer, binding.handle);
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, 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, projectRoot)) continue;
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" || !definitionOf(value.peer)?.session.launchRecord) malformed();
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
- for (const key of ["sessionFile", "backend", "model"]) if (value[key] !== undefined && typeof value[key] !== "string") malformed();
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 {