@staix/agent-hub 0.12.33 → 0.12.35

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 (126) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +9 -3
  4. package/docs/agent-notes/budget.md +7 -3
  5. package/docs/agent-notes/bus.md +2 -1
  6. package/docs/agent-notes/daemon.md +7 -3
  7. package/docs/agent-notes/local-worker.md +1 -1
  8. package/docs/agent-notes/tasks.md +6 -2
  9. package/docs/agent-notes/tests.md +2 -1
  10. package/docs/gsd-collaboration-analysis.md +5 -1
  11. package/docs/operations.md +80 -16
  12. package/docs/quickstart.md +10 -2
  13. package/docs/sdlc.md +59 -34
  14. package/docs/security.md +7 -4
  15. package/docs/smoke.md +1 -1
  16. package/docs/specs/2026-09-19-agent-hub-design.md +407 -10
  17. package/docs/verification/2026-10-18-0.12.34.md +48 -0
  18. package/docs/verification/2026-10-18-0.12.35.md +49 -0
  19. package/docs/verification/2026-10-18-493-claude-busy-channel.md +91 -0
  20. package/docs/verified.json +35 -19
  21. package/package.json +1 -1
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +23567 -16606
  24. package/src/adapters/acp.ts +3 -1
  25. package/src/adapters/claude-channel.ts +59 -29
  26. package/src/adapters/codex-appserver.ts +82 -12
  27. package/src/adapters/definitions/builtin-metadata.ts +101 -7
  28. package/src/adapters/definitions/native-cli-claude-launch.ts +90 -0
  29. package/src/adapters/definitions/native-cli-claude-setup.ts +80 -0
  30. package/src/adapters/definitions/native-cli-codex-launch.ts +14 -0
  31. package/src/adapters/definitions/native-cli-doctor.ts +46 -0
  32. package/src/adapters/definitions/native-cli-entry-claude.ts +52 -0
  33. package/src/adapters/definitions/native-cli-entry-codex.ts +34 -0
  34. package/src/adapters/definitions/native-cli-entry-jcode.ts +27 -0
  35. package/src/adapters/definitions/native-cli-entry-kimi.ts +57 -0
  36. package/src/adapters/definitions/native-cli-entry-local.ts +15 -0
  37. package/src/adapters/definitions/native-cli-entry-pi.ts +53 -0
  38. package/src/adapters/definitions/native-cli-entry-qwen.ts +21 -0
  39. package/src/adapters/definitions/native-cli-entry-types.ts +41 -0
  40. package/src/adapters/definitions/native-cli-entry.ts +19 -0
  41. package/src/adapters/definitions/native-cli-jcode-mcp.ts +58 -0
  42. package/src/adapters/definitions/native-cli-launch-common.ts +17 -0
  43. package/src/adapters/definitions/native-cli-pi.ts +33 -0
  44. package/src/adapters/definitions/native-cli-preview-claude.ts +37 -0
  45. package/src/adapters/definitions/native-cli-preview-codex.ts +20 -0
  46. package/src/adapters/definitions/native-cli-preview-common.ts +57 -0
  47. package/src/adapters/definitions/native-cli-preview-jcode.ts +20 -0
  48. package/src/adapters/definitions/native-cli-preview-kimi.ts +28 -0
  49. package/src/adapters/definitions/native-cli-preview-pi.ts +36 -0
  50. package/src/adapters/definitions/native-cli-preview-qwen.ts +22 -0
  51. package/src/adapters/definitions/native-cli-preview.ts +16 -0
  52. package/src/adapters/definitions/native-config-defaults.ts +19 -0
  53. package/src/adapters/definitions/native-config-policy.ts +35 -0
  54. package/src/adapters/definitions/native-context.ts +35 -0
  55. package/src/adapters/definitions/native-daemon-kimi.ts +5 -0
  56. package/src/adapters/definitions/native-daemon-policy.ts +18 -0
  57. package/src/adapters/definitions/native-display.ts +18 -0
  58. package/src/adapters/definitions/native-factories.ts +48 -0
  59. package/src/adapters/definitions/native-policy-aux.ts +130 -0
  60. package/src/adapters/definitions/native-quota.ts +29 -0
  61. package/src/adapters/definitions/native-recovery-continuity.ts +12 -0
  62. package/src/adapters/definitions/native-recovery.ts +88 -0
  63. package/src/adapters/definitions/qwen-native.ts +2 -2
  64. package/src/adapters/definitions/qwen.ts +8 -4
  65. package/src/adapters/local-worker.ts +3 -2
  66. package/src/adapters/pi.ts +3 -1
  67. package/src/adapters/registry-types.ts +100 -10
  68. package/src/adapters/registry.ts +127 -11
  69. package/src/cli/console-state.ts +5 -5
  70. package/src/cli/identity.ts +8 -7
  71. package/src/cli/jcode-mcp.ts +2 -58
  72. package/src/cli/launch.ts +41 -102
  73. package/src/cli/main.ts +126 -290
  74. package/src/cli/output.ts +3 -2
  75. package/src/cli/preview.ts +26 -126
  76. package/src/cli/recovery-guidance.ts +43 -22
  77. package/src/cli/setup.ts +2 -80
  78. package/src/cli/shell-quote.ts +3 -0
  79. package/src/cli/terminal-recovery.ts +81 -22
  80. package/src/cli/upgrade-interactive.ts +4 -2
  81. package/src/cli/upgrade-restore-state.ts +17 -7
  82. package/src/cli/upgrade-runtime.ts +126 -147
  83. package/src/cli/upgrade.ts +44 -27
  84. package/src/hub/budget.ts +59 -31
  85. package/src/hub/bus.ts +1 -1
  86. package/src/hub/conductor.ts +19 -9
  87. package/src/hub/config-apply.ts +22 -15
  88. package/src/hub/config-reload.ts +18 -3
  89. package/src/hub/config-trust.ts +1 -1
  90. package/src/hub/context-window.ts +3 -27
  91. package/src/hub/control-client.ts +1 -1
  92. package/src/hub/crash.ts +15 -39
  93. package/src/hub/daemon.ts +506 -341
  94. package/src/hub/execution-budget.ts +3 -2
  95. package/src/hub/hub-tools.ts +19 -8
  96. package/src/hub/instance-budget.ts +73 -0
  97. package/src/hub/instance-config.ts +47 -0
  98. package/src/hub/instance-execution.ts +50 -0
  99. package/src/hub/instances.ts +100 -0
  100. package/src/hub/lifecycle-schema.ts +7 -3
  101. package/src/hub/peer-catalog.ts +4 -13
  102. package/src/hub/permission-mode.ts +14 -17
  103. package/src/hub/recovery-continuity.ts +6 -5
  104. package/src/hub/report.ts +9 -8
  105. package/src/hub/roster.ts +9 -9
  106. package/src/hub/routing.ts +94 -19
  107. package/src/hub/sdlc-queue.ts +2 -2
  108. package/src/hub/sdlc.ts +84 -38
  109. package/src/hub/settings.ts +39 -10
  110. package/src/hub/settled-roles.ts +1 -1
  111. package/src/hub/snapshots.ts +40 -9
  112. package/src/hub/start-mode.ts +13 -8
  113. package/src/hub/status-roster.ts +8 -0
  114. package/src/hub/tasks.ts +60 -27
  115. package/src/hub/text-projection.ts +7 -3
  116. package/src/hub/tooling.ts +4 -60
  117. package/src/hub/ui.ts +3 -1
  118. package/src/hub/workflow.ts +30 -52
  119. package/src/memory/recall.ts +4 -5
  120. package/src/models/route/signals.ts +3 -1
  121. package/src/pi/launch.ts +5 -3
  122. package/src/ui/index.html +4 -3
  123. package/src/ui/office-model.ts +3 -1
  124. package/src/ui/peer-palette.ts +16 -6
  125. package/templates/AGENTS.block.md +2 -1
  126. package/templates/routing.toml +2 -2
@@ -1,3 +1,9 @@
1
+ import { shellQuote } from "./shell-quote.ts";
2
+ export { shellQuote } from "./shell-quote.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";
1
7
  import { isAbsolute, join, resolve } from "node:path";
2
8
  import { fileURLToPath } from "node:url";
3
9
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
@@ -25,7 +31,7 @@ export type CommandRunner = (argv: readonly string[]) => Promise<CommandResult>;
25
31
  /** Stable process identity hook. It returns a digest, never raw process metadata. */
26
32
  export type ProcessIdentity = (pid: number) => string | undefined | Promise<string | undefined>;
27
33
 
28
- export type TerminalPeer = "codex" | "claude" | "pi";
34
+ export type TerminalPeer = string;
29
35
  export interface SessionRef { sessionId: string; sessionFile?: string; backend?: string; model?: string; }
30
36
 
31
37
  export interface LaunchMetadata {
@@ -45,6 +51,10 @@ export interface TerminalBinding {
45
51
  incarnationId: string;
46
52
  worktreeId: string;
47
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;
48
58
  sessionId: string;
49
59
  sessionFile?: string;
50
60
  backend?: string;
@@ -126,6 +136,8 @@ export interface TerminalCreateResult {
126
136
 
127
137
  export interface TerminalRecoveryOptions {
128
138
  runner?: CommandRunner;
139
+ /** Exact execution root returned by authenticated extra startup; checked against the current declaration. */
140
+ executionCwd?: string;
129
141
  /** Defaults to `orca`; callers in a managed session may pin another executable. */
130
142
  orcaExecutable?: string;
131
143
  /** Absolute path to this package's JS entrypoint. */
@@ -147,6 +159,8 @@ export interface TerminalRecoveryOptions {
147
159
  export interface RecordedTerminalLaunch {
148
160
  peer: TerminalPeer;
149
161
  projectRoot: string;
162
+ executionCwd?: string;
163
+ terminalRoot?: string;
150
164
  stateDir: string;
151
165
  instanceId: string;
152
166
  launcherPid: number;
@@ -231,9 +245,7 @@ export class OrcaCommandError extends Error {
231
245
  }
232
246
 
233
247
  /** POSIX quoting for the single command-string argument accepted by Orca. */
234
- export function shellQuote(value: string): string {
235
- return `'${value.replace(/'/g, `'\\''`)}'`;
236
- }
248
+
237
249
 
238
250
  function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Required<TerminalRecoveryOptions> {
239
251
  const config: TerminalRecoveryOptions = typeof options === "function" ? { runner: options } : options ?? {};
@@ -242,6 +254,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
242
254
  runner: config.runner ?? defaultRunner(orcaExecutable),
243
255
  orcaExecutable,
244
256
  packageEntrypoint: config.packageEntrypoint ?? defaultEntrypoint,
257
+ executionCwd: config.executionCwd ?? "",
245
258
  stateDir: config.stateDir ?? "",
246
259
  instanceId: config.instanceId ?? "",
247
260
  processIdentity: config.processIdentity ?? defaultProcessIdentity,
@@ -311,9 +324,30 @@ export function recordPath(stateDir: string): string {
311
324
  return join(stateDir, RECORD_FILE);
312
325
  }
313
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 (definition.transport !== "codex-appserver" || !declarations?.[peer]) throw new Error(`${peer}: I2 extra instance has no managed terminal support`);
335
+ const expected = resolveInstanceExecutionCwd(projectRoot, peer, declarations, effectiveInstanceRoles(peer, declarations, roles));
336
+ if (captured !== undefined && captured !== expected) throw new Error(`${peer}: declared execution worktree changed; manual recovery required`);
337
+ return expected;
338
+ }
339
+
340
+ function savedTerminalRoot(binding: Pick<TerminalBinding, "peer" | "projectRoot" | "executionCwd" | "terminalRoot">): string {
341
+ const expected = executionRoot(binding.peer, binding.projectRoot, binding.executionCwd ?? binding.projectRoot);
342
+ if (definitionOf(binding.peer)) return binding.projectRoot;
343
+ const root = binding.terminalRoot ?? binding.projectRoot;
344
+ if (root !== binding.projectRoot && root !== expected) throw new Error(`${binding.peer}: recorded Orca grouping root is not authorized`);
345
+ return root;
346
+ }
347
+
314
348
  function validRecord(item: unknown): item is RecordedTerminalLaunch {
315
349
  const row = object(item);
316
- 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;
350
+ 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;
317
351
  }
318
352
 
319
353
  /**
@@ -409,13 +443,15 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
409
443
  const handle = process.env.ORCA_TERMINAL_HANDLE;
410
444
  const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
411
445
  if (!handle || !worktreeIdFromEnv) return undefined;
446
+ if (!definitionOf(peer) && !config.executionCwd) throw new Error(`${peer}: recording an extra terminal requires its authenticated startup execution root`);
447
+ const expectedRoot = executionRoot(peer, projectRoot, config.executionCwd || undefined);
412
448
  const value = await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]);
413
449
  const terminal = terminalObject(value);
414
450
  const actualRoot = nestedString(terminal, ["worktreePath", "projectRoot"]);
415
451
  const actualHandle = nestedString(terminal, ["handle"]);
416
452
  const actualWorktree = nestedString(terminal, ["worktreeId"]);
417
453
  const incarnationId = nestedString(terminal, ["incarnationId"]);
418
- if (actualHandle !== handle || !actualRoot || !rootMatches(actualRoot, projectRoot) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
454
+ if (actualHandle !== handle || !actualRoot || !(rootMatches(actualRoot, projectRoot) || !definitionOf(peer) && rootMatches(actualRoot, expectedRoot)) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
419
455
  throw new OrcaCommandError(blocker("project-root-mismatch", "Orca launch terminal failed canonical root/worktree readback", peer, handle));
420
456
  }
421
457
  const launcherSignature = await config.processIdentity(process.pid);
@@ -423,7 +459,7 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
423
459
  const launchId = randomUUID();
424
460
  process.env.AGENTHUB_LAUNCH_ID = launchId;
425
461
  process.env.AGENTHUB_INSTANCE_ID = instanceId;
426
- const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
462
+ 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 }) };
427
463
  const existing = launchRecords(stateDir);
428
464
  // #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
429
465
  // then read those launchers as gone.
@@ -469,12 +505,12 @@ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
469
505
  return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
470
506
  }
471
507
 
472
- function identityFrom(terminal: Record<string, unknown>): TerminalPeer | undefined {
508
+ function identityFrom(terminal: Record<string, unknown>, instances?: KindMap): TerminalPeer | undefined {
473
509
  const wait = object(terminal.agentWait);
474
510
  const session = object(terminal.session);
475
511
  const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
476
512
  for (const candidate of candidates) {
477
- if (candidate === "codex" || candidate === "claude" || candidate === "pi") return candidate;
513
+ if (typeof candidate === "string" && definitionOf(candidate, instances)?.session.launchRecord) return candidate as TerminalPeer;
478
514
  }
479
515
  return undefined;
480
516
  }
@@ -504,13 +540,19 @@ function requireAbsoluteEntrypoint(value: string): string {
504
540
 
505
541
  function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, options: Required<TerminalRecoveryOptions>, source: Record<string, unknown>, recorded?: RecordedTerminalLaunch, ref?: SessionRef): LaunchMetadata {
506
542
  const packageEntrypoint = requireAbsoluteEntrypoint(options.packageEntrypoint);
507
- const argv = peer === "codex"
508
- ? ["bun", packageEntrypoint, "--project", projectRoot, "codex", "resume", sessionId]
509
- : peer === "claude"
510
- ? ["bun", packageEntrypoint, "--project", projectRoot, "claude", "--resume", sessionId]
511
- : ["bun", packageEntrypoint, "--project", projectRoot, "pi", "--mode", "tui", ...(ref?.backend ? ["--backend", ref.backend] : []), ...(ref?.model ? ["--model", ref.model] : []), ...(ref?.sessionFile ? ["--session-file", ref.sessionFile] : ["--session-id", sessionId])];
512
543
  const env = { ...allowedEnv(source), ...(recorded?.env ?? {}) };
513
544
  const prefix = ALLOWED_ENV.filter((name) => env[name] !== undefined).map((name) => `${name}=${shellQuote(env[name]!)}`);
545
+ const launch: LaunchMetadata = { packageEntrypoint, argv: ["bun", packageEntrypoint, "--project", projectRoot], env, command: "" };
546
+ launch.command = [...prefix, ...launch.argv.map(shellQuote)].join(" ");
547
+ // Identity and environment were independently verified before this pure two-phase launch construction.
548
+ const binding: TerminalBinding = { peer, projectRoot, sessionId, handle: nestedString(source, ["handle"])!,
549
+ incarnationId: nestedString(source, ["incarnationId"])!, worktreeId: nestedString(source, ["worktreeId"])!,
550
+ ...(ref?.sessionFile ? { sessionFile: ref.sessionFile } : {}), ...(ref?.backend ? { backend: ref.backend } : {}), ...(ref?.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
551
+ const adapter = definitionOf(peer) ?? definitionOf(peer, readInstanceConfig(projectRoot).instances);
552
+ const nativeTail = adapter?.session.resumeArgv?.(binding, false);
553
+ const tail = nativeTail && adapter ? [nativeTail[0]!, ...(peer !== adapter.kind ? ["--as", peer] : []), ...nativeTail.slice(1)] : undefined;
554
+ if (!tail) throw new Error(`${peer}: no recorded terminal resume adapter`);
555
+ const argv = [...launch.argv, ...tail];
514
556
  const command = [...prefix, ...argv.map(shellQuote)].join(" ");
515
557
  return { packageEntrypoint, command, argv, env };
516
558
  }
@@ -533,7 +575,10 @@ function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBin
533
575
  const root = nestedString(actual, ["worktreePath", "projectRoot"]);
534
576
  const worktree = nestedString(actual, ["worktreeId"]);
535
577
  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);
536
- 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);
578
+ let expectedRoot: string;
579
+ try { expectedRoot = savedTerminalRoot(binding); }
580
+ catch { return blocker("project-root-mismatch", "declared terminal execution root changed or is unavailable; refusing to mutate it", binding.peer, binding.handle); }
581
+ if (!rootMatches(root, expectedRoot)) return blocker("project-root-mismatch", "terminal belongs to a different project root; refusing to mutate it", binding.peer, binding.handle);
537
582
  return undefined;
538
583
  }
539
584
 
@@ -555,6 +600,7 @@ export async function inspectSavedTerminal(binding: TerminalBinding, options?: C
555
600
  const completeIdentity = (value: Record<string, unknown>) =>
556
601
  [nestedString(value, ["handle"]), nestedString(value, ["incarnationId"]), nestedString(value, ["worktreeId"]), nestedString(value, ["worktreePath", "projectRoot"])].every(Boolean);
557
602
  try {
603
+ savedTerminalRoot(binding);
558
604
  const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]));
559
605
  if (!completeIdentity(shown)) return unknown();
560
606
  const issue = verifyBindingData(shown, binding);
@@ -598,6 +644,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
598
644
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
599
645
  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);
600
646
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
647
+ const instances = requested.some(([peer]) => definitionOf(peer) === undefined) ? readInstanceConfig(projectRoot).instances : undefined;
601
648
  // #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
602
649
  // CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
603
650
  const rows = launchRecords(config.stateDir);
@@ -614,6 +661,15 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
614
661
 
615
662
  for (const [peer, ref] of requested) {
616
663
  const sessionId = ref.sessionId;
664
+ const adapter = definitionOf(peer, instances), extra = definitionOf(peer) === undefined;
665
+ if (!adapter?.session.launchRecord || extra && adapter.transport !== "codex-appserver") { blockers.push(blocker("ownership-unknown", `${peer}: no declared managed-terminal recovery adapter`, peer)); continue; }
666
+ const ownRecord = records.find(item => item.peer === peer);
667
+ let expectedRoot: string, expectedTerminalRoot: string;
668
+ try {
669
+ expectedRoot = executionRoot(peer, projectRoot, ownRecord?.executionCwd ?? projectRoot);
670
+ expectedTerminalRoot = savedTerminalRoot({ peer, projectRoot, executionCwd: ownRecord?.executionCwd, terminalRoot: ownRecord?.terminalRoot });
671
+ }
672
+ catch { blockers.push(blocker("ownership-unknown", `${peer}: declared execution worktree changed or is unverified`, peer)); continue; }
617
673
  // #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
618
674
  const unread = unknown.find((item) => item.peer === peer);
619
675
  if (unread) {
@@ -624,7 +680,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
624
680
  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));
625
681
  continue;
626
682
  }
627
- const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
683
+ const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot));
628
684
  const peerCandidates: Record<string, unknown>[] = [];
629
685
  for (const listedTerminal of candidates) {
630
686
  const handle = nestedString(listedTerminal, ["handle"]);
@@ -637,16 +693,16 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
637
693
  continue;
638
694
  }
639
695
  // A show readback is required; list can be stale between recovery steps.
640
- if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot)) continue;
641
- const identity = identityFrom(terminal);
696
+ if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, expectedTerminalRoot)) continue;
697
+ const identity = identityFrom(terminal, instances);
642
698
  const terminalSession = sessionFrom(terminal);
643
699
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
644
700
  // A recorded launcher proves ownership; the daemon's supplied session id is the
645
701
  // authoritative session id when Orca omits it from terminal metadata.
646
- if ((identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
702
+ if ((!extra && identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer || extra && identity === adapter.kind) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
647
703
  }
648
704
  if (!peerCandidates.length) {
649
- const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal) === undefined && !records.some((item) => item.handle === terminal.handle));
705
+ const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal, instances) === undefined && !records.some((item) => item.handle === terminal.handle));
650
706
  blockers.push(ownershipUnknown
651
707
  ? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
652
708
  : blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
@@ -669,9 +725,11 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
669
725
  continue;
670
726
  }
671
727
  const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
672
- const sessionFile = peer === "pi" ? nestedString(terminal, ["sessionFile", "sessionPath"]) ?? nestedString(object(terminal.session), ["file", "path"]) : undefined;
728
+ // Orca observation supplies file identity; launch arguments are not observed native session-file evidence.
729
+ const { args: _launchArgs, ...observedTerminal } = terminal;
730
+ const sessionFile = definitionOf(peer, instances)?.session.crashIdentity?.(observedTerminal).sessionFile;
673
731
  const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded, { ...ref, ...(sessionFile ? { sessionFile } : {}) });
674
- const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, ...(sessionFile ? { sessionFile } : {}), ...(ref.backend ? { backend: ref.backend } : {}), ...(ref.model ? { model: ref.model } : {}), launch, launchMetadata: launch };
732
+ 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 };
675
733
  bindings.push(binding);
676
734
  byPeer[peer] = binding;
677
735
  }
@@ -743,6 +801,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
743
801
  const config = normalizeOptions(options);
744
802
  let made: string | undefined; // a later failure must still report the terminal it created
745
803
  try {
804
+ savedTerminalRoot(binding);
746
805
  const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
747
806
  // The coordinator journals a pending creation before this call and reconciles
748
807
  // it on resume. Other agents in the worktree do not imply a prior create.
@@ -1,3 +1,4 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
1
2
  import { renderPlan, renderOperation, renderBlocked, renderSummary, renderStep, renderWait, renderMenu, choiceInstruction, type RecoveryPresentationOptions, type MenuPresentationItem } from "./upgrade-presentation.ts";
2
3
  import { quoted, wrap } from "./console-state.ts";
3
4
  import { operationLines, stepLabel } from "./upgrade-labels.ts";
@@ -83,7 +84,8 @@ const left = (op: { id: string; sourceRoot?: string }, io: ScreenIO): string =>
83
84
  export function peerKind(planned: PlannedProject, peer: RecoveryPeer): "tui" | "headless" | "unmanaged" | "offline" {
84
85
  if (peer.state === "offline") return "offline";
85
86
  if ((planned.terminals as { peer: string }[]).some((t) => t.peer === peer.id)) return "tui";
86
- if (peer.id === "claude" || peer.id === "codex" || (peer.id === "pi" && peer.args?.mode === "tui")) return "unmanaged";
87
+ const unrecorded = definitionOf(peer.id)?.session.unrecordedTerminal;
88
+ if (unrecorded === "always" || unrecorded === "tui" && peer.args?.mode === "tui") return "unmanaged";
87
89
  return "headless";
88
90
  }
89
91
 
@@ -93,7 +95,7 @@ function peerAction(planned: PlannedProject, peer: RecoveryPeer): string {
93
95
  if (planned.freshStart?.includes(peer.id)) return "restarts as a new session (no turn to lose)";
94
96
  const terminal = (planned.terminals as { peer: string; handle: string }[]).find((t) => t.peer === peer.id);
95
97
  if (terminal) return `resumes its session in a new terminal (replaces ${terminal.handle})`;
96
- return peer.id === "pi" ? "restarts headless on its recorded session" : "restarts headless as a new session";
98
+ return definitionOf(peer.id)?.session.restoreOnStart ? "restarts headless on its recorded session" : "restarts headless as a new session";
97
99
  }
98
100
 
99
101
  const KINDS = { tui: "TUI", headless: "headless", unmanaged: "own", offline: "-" } as const;
@@ -1,3 +1,5 @@
1
+ import { definitionOf } from "../adapters/registry.ts";
2
+ import { readInstanceConfig } from "../hub/instance-config.ts";
1
3
  import type { TerminalBinding } from "./terminal-recovery.ts";
2
4
 
3
5
  export type RestoreReceipt =
@@ -22,10 +24,16 @@ export type RestoreReceiptStatus = "unstarted" | "pending" | "waiting:pending" |
22
24
  function malformed(): never { throw new Error("invalid restore receipt"); }
23
25
  const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
24
26
  const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
25
- function terminalBinding(value: unknown): TerminalBinding {
26
- if (!object(value) || typeof value.peer !== "string" || !["codex", "claude", "pi"].includes(value.peer)) malformed();
27
+ function terminalBinding(value: unknown, historical = false): TerminalBinding {
28
+ if (!object(value) || typeof value.peer !== "string") malformed();
27
29
  for (const key of ["handle", "incarnationId", "worktreeId", "projectRoot", "sessionId"]) if (!text(value[key])) malformed();
28
- 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();
29
37
  const launch = value.launch;
30
38
  const validLaunch = (v: unknown) => object(v) && text(v.packageEntrypoint) && text(v.command)
31
39
  && Array.isArray(v.argv) && v.argv.every(arg => typeof arg === "string")
@@ -35,7 +43,9 @@ function terminalBinding(value: unknown): TerminalBinding {
35
43
  }
36
44
 
37
45
  /** Decode legacy representations only here. Absence is unstarted, never pending or failed. */
38
- 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";
39
49
  if (value === undefined) return undefined;
40
50
  if (value === "pending" || value === "failed") return { state: value };
41
51
  if (!object(value)) malformed();
@@ -46,14 +56,14 @@ export function decodeRestoreReceipt(value: unknown): RestoreReceipt | undefined
46
56
  case "pending": case "failed": return { state: value.state };
47
57
  case "waiting": {
48
58
  if (value.origin !== "pending" && value.origin !== "failed") malformed();
49
- return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding) };
59
+ return { state: "waiting", origin: value.origin, binding: terminalBinding(value.binding, historical) };
50
60
  }
51
- case "done": return { state: "done", binding: terminalBinding(value.binding) };
61
+ case "done": return { state: "done", binding: terminalBinding(value.binding, historical) };
52
62
  default: return malformed();
53
63
  }
54
64
  }
55
65
  if (Object.hasOwn(value, "attachmentReceipt")) malformed();
56
- return { state: "done", binding: terminalBinding(value) };
66
+ return { state: "done", binding: terminalBinding(value, historical) };
57
67
  }
58
68
 
59
69
  export function restoreReceiptStatus(value: unknown): RestoreReceiptStatus {