@staix/agent-hub 0.12.18 → 0.12.20

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +419 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +18 -11
  12. package/docs/smoke.md +91 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +412 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +70 -12
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
@@ -1,8 +1,9 @@
1
1
  import { isAbsolute, join, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { processSignature } from "../pi/process-signature.ts";
6
+ import { atomicPrivateJSON } from "../hub/recovery-store.ts";
6
7
 
7
8
  /** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
8
9
  export type OrcaTerminalCommand = "list" | "show" | "wait" | "close" | "create";
@@ -63,7 +64,9 @@ export interface RecoveryBlocker {
63
64
  | "ambiguous-terminal"
64
65
  | "terminal-unready"
65
66
  | "command-error"
66
- | "ambiguous-create";
67
+ | "ambiguous-create"
68
+ | "launcher-exited"
69
+ | "terminal-gone";
67
70
  message: string;
68
71
  peer?: TerminalPeer;
69
72
  handle?: string;
@@ -146,6 +149,8 @@ export interface RecordedTerminalLaunch {
146
149
 
147
150
  const MAX_WAIT_MS = 10 * 60 * 1000;
148
151
  const MAX_COMMAND_MS = 30_000;
152
+ /** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
153
+ const EXIT_SLICE_MS = 5_000;
149
154
  const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
150
155
  type AllowedEnvName = (typeof ALLOWED_ENV)[number];
151
156
 
@@ -176,13 +181,27 @@ function errorText(result: CommandResult): string {
176
181
  return stderr || `exit status ${commandStatus(result)}`;
177
182
  }
178
183
 
184
+ /** Orca's structured error code, from the JSON it prints on stdout or stderr. */
185
+ function errorCode(result: CommandResult): unknown {
186
+ const fromStdout = object(object(decoded(result)).error).code;
187
+ if (fromStdout !== undefined) return fromStdout;
188
+ try { return object(object(JSON.parse(bytes(result.stderr))).error).code; } catch { return undefined; }
189
+ }
190
+
179
191
  function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
180
192
  return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
181
193
  }
182
194
 
183
195
  async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
184
196
  const result = await runner(argv);
185
- if (commandStatus(result) !== 0) throw new OrcaCommandError(runnerFailure(argv, result));
197
+ if (commandStatus(result) !== 0) {
198
+ // Orca 1.4.223 answers a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
199
+ if (errorCode(result) === "terminal_handle_stale") {
200
+ const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
201
+ throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
202
+ }
203
+ throw new OrcaCommandError(runnerFailure(argv, result));
204
+ }
186
205
  const value = decoded(result);
187
206
  const root = object(value);
188
207
  const payload = responseResult(value);
@@ -249,42 +268,59 @@ function defaultRunner(executable: string): CommandRunner {
249
268
 
250
269
  const RECORD_FILE = "terminal-recovery.json";
251
270
 
252
- function recordPath(stateDir: string): string {
271
+ export function recordPath(stateDir: string): string {
253
272
  return join(stateDir, RECORD_FILE);
254
273
  }
255
274
 
256
- function readLaunchRecords(stateDir: string): RecordedTerminalLaunch[] {
275
+ /** #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none". */
276
+ function launchRecords(stateDir: string): RecordedTerminalLaunch[] | "unreadable" {
257
277
  if (!stateDir || !existsSync(recordPath(stateDir))) return [];
258
278
  try {
259
279
  const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
260
- return Array.isArray(value) ? value.filter((item): item is RecordedTerminalLaunch => {
280
+ if (!Array.isArray(value)) return "unreadable";
281
+ return value.filter((item): item is RecordedTerminalLaunch => {
261
282
  const row = object(item);
262
283
  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;
263
- }) : [];
264
- } catch {
265
- return [];
284
+ });
285
+ } catch (error) {
286
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? [] : "unreadable";
266
287
  }
267
288
  }
268
289
 
269
290
  function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): void {
270
291
  mkdirSync(stateDir, { recursive: true });
271
- writeFileSync(recordPath(stateDir), `${JSON.stringify(rows)}\n`, { mode: 0o600 });
292
+ atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
272
293
  }
273
294
 
274
- async function launcherMatches(row: RecordedTerminalLaunch, identity: ProcessIdentity): Promise<boolean> {
275
- try {
276
- const signature = await identity(row.launcherPid);
277
- return typeof signature === "string" && signature.length > 0 && signature === row.launcherSignature;
278
- } catch {
279
- return false;
280
- }
295
+ /**
296
+ * #215: live when the recorded process signature matches; gone when the pid no longer exists (ESRCH) or another
297
+ * process holds it now; unknown when the pid exists but its identity cannot be read. Unknown is never gone.
298
+ */
299
+ export type LauncherState = "live" | "gone" | "unknown";
300
+ async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity): Promise<LauncherState> {
301
+ let signature: string | undefined;
302
+ try { signature = await identity(row.launcherPid); } catch { /* unreadable: decided by the pid probe below */ }
303
+ if (typeof signature === "string" && signature.length > 0) return signature === row.launcherSignature ? "live" : "gone";
304
+ try { process.kill(row.launcherPid, 0); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return "gone"; }
305
+ return "unknown";
306
+ }
307
+
308
+ /** This instance's launch records whose launcher is live, and those whose launcher cannot be identified (never gone). */
309
+ async function recordsByState(rows: RecordedTerminalLaunch[], projectRoot: string, instanceId: string, identity: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[] }> {
310
+ if (!instanceId) return { live: [], unknown: [] };
311
+ const candidates = rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
312
+ const states = await Promise.all(candidates.map((item) => launcherState(item, identity)));
313
+ return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown") };
281
314
  }
282
315
 
283
- async function liveRecords(stateDir: string, projectRoot: string, instanceId: string, identity: ProcessIdentity): Promise<RecordedTerminalLaunch[]> {
284
- if (!stateDir || !instanceId) return [];
285
- const candidates = readLaunchRecords(stateDir).filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
286
- const checks = await Promise.all(candidates.map(async (item) => await launcherMatches(item, identity) ? item : undefined));
287
- return checks.filter((item): item is RecordedTerminalLaunch => item !== undefined);
316
+ /** #215: the launcher recorded for `peer` on this instance (one row per peer and instance) and its state; managed means live. */
317
+ export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState } | undefined> {
318
+ const config = normalizeOptions(options);
319
+ if (!config.stateDir || !config.instanceId) return undefined;
320
+ const rows = launchRecords(config.stateDir);
321
+ if (rows === "unreadable") return { state: "unknown" };
322
+ const record = rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
323
+ return record ? { record, state: await launcherState(record, config.processIdentity) } : undefined;
288
324
  }
289
325
 
290
326
  /**
@@ -311,7 +347,13 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
311
347
  process.env.AGENTHUB_LAUNCH_ID = launchId;
312
348
  process.env.AGENTHUB_INSTANCE_ID = instanceId;
313
349
  const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
314
- const rows = readLaunchRecords(stateDir).filter((item) => !(item.peer === peer && item.instanceId === instanceId));
350
+ const existing = launchRecords(stateDir);
351
+ // #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
352
+ // then read those launchers as gone.
353
+ if (existing === "unreadable") {
354
+ throw new OrcaCommandError(blocker("command-error", `${recordPath(stateDir)} cannot be read, so recording this launch would erase every other launcher's record; next action: inspect that file and move it aside (recovery then treats sessions it recorded as unmanaged), then launch again`, peer, handle));
355
+ }
356
+ const rows = existing.filter((item) => !(item.peer === peer && item.instanceId === instanceId));
315
357
  rows.push(row);
316
358
  writeLaunchRecords(stateDir, rows);
317
359
  return row;
@@ -401,6 +443,8 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
401
443
  code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
402
444
  code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
403
445
  code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
446
+ code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
447
+ code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
404
448
  "inspect the named terminal and resume recovery after the identity is verified";
405
449
  return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
406
450
  }
@@ -429,12 +473,16 @@ async function showBinding(binding: TerminalBinding, options: Required<TerminalR
429
473
  */
430
474
  export async function inspectTerminals(projectRoot: string, sessions: Partial<Record<TerminalPeer, string | SessionRef>>, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalInspection> {
431
475
  const config = normalizeOptions(options);
432
- const records = await liveRecords(config.stateDir, projectRoot, config.instanceId, config.processIdentity);
433
476
  const blockers: RecoveryBlocker[] = [];
434
477
  const bindings: TerminalBinding[] = [];
435
478
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
436
479
  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);
437
480
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
481
+ // #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
482
+ // CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
483
+ const rows = launchRecords(config.stateDir);
484
+ if (rows === "unreadable") return { bindings, byPeer, blockers: [blocker("command-error", `${recordPath(config.stateDir)} cannot be read, so no terminal can be bound to its launch; inspect or move it aside, then plan or resume again`)], manualRequired: true };
485
+ const { live: records, unknown } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity);
438
486
 
439
487
  let listed: Record<string, unknown>[];
440
488
  try {
@@ -446,6 +494,12 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
446
494
 
447
495
  for (const [peer, ref] of requested) {
448
496
  const sessionId = ref.sessionId;
497
+ // #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
498
+ const unread = unknown.find((item) => item.peer === peer);
499
+ if (unread) {
500
+ blockers.push(blocker("ownership-unknown", `the ${peer} launcher recorded for terminal ${unread.handle} (pid ${unread.launcherPid}) cannot be identified, so no terminal can be bound to its launch; wait until it exits or end it, then plan or resume again`, peer, unread.handle));
501
+ continue;
502
+ }
449
503
  const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
450
504
  const peerCandidates: Record<string, unknown>[] = [];
451
505
  for (const listedTerminal of candidates) {
@@ -500,16 +554,25 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
500
554
  return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
501
555
  }
502
556
 
557
+ /**
558
+ * One bounded Orca wait. Orca 1.4.223 answers a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
559
+ * that is "not satisfied", not a failure. Any other failure still throws.
560
+ */
561
+ async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
562
+ const argv = ["terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", String(timeoutMs), "--json"];
563
+ const result = await runner(argv);
564
+ if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return false;
565
+ const root = responseResult(await run(async () => result, argv));
566
+ return root.satisfied === true || object(root.wait).satisfied === true;
567
+ }
568
+
503
569
  /** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
504
570
  export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, options?: CommandRunner | TerminalRecoveryOptions): Promise<IdleResult> {
505
571
  const config = normalizeOptions(options);
506
572
  const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
507
573
  try {
508
574
  await showBinding(binding, config);
509
- const value = await run(config.runner, ["terminal", "wait", "--terminal", binding.handle, "--for", "tui-idle", "--timeout-ms", String(timeout), "--json"]);
510
- const root = responseResult(value);
511
- const wait = object(root.wait);
512
- const satisfied = root.satisfied === true || wait.satisfied === true;
575
+ const satisfied = await waitSatisfied(config.runner, binding.handle, timeout);
513
576
  const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
514
577
  return { satisfied, manualRequired: !satisfied, blockers };
515
578
  } catch (error) {
@@ -548,9 +611,13 @@ export async function closeTerminal(binding: TerminalBinding, timeoutMs = 600_00
548
611
  }
549
612
  }
550
613
 
551
- /** Create a replacement in the captured worktree, then prove TUI readiness and session mapping. */
552
- export async function createTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions, timeoutMs = 600_000): Promise<TerminalCreateResult> {
614
+ /**
615
+ * Create a replacement in the captured worktree, then prove TUI readiness and session mapping. `anySession` maps a
616
+ * fresh session the operator chose (#215): the terminal must still be this launch's, under any new session id.
617
+ */
618
+ export async function createTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions, timeoutMs = 600_000, anySession = false): Promise<TerminalCreateResult> {
553
619
  const config = normalizeOptions(options);
620
+ let made: string | undefined; // a later failure must still report the terminal it created
554
621
  try {
555
622
  const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
556
623
  // The coordinator journals a pending creation before this call and reconciles
@@ -558,6 +625,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
558
625
  const value = await run(config.runner, ["terminal", "create", "--worktree", worktreeSelector, "--command", binding.launch.command, "--title", `${binding.peer} recovery`, "--json"]);
559
626
  let created = terminalObject(value);
560
627
  const createdHandle = nestedString(created, ["handle"]);
628
+ made = createdHandle;
561
629
  if (!createdHandle) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", "Orca create returned no terminal handle", binding.peer)] };
562
630
  const incarnationId = nestedString(created, ["incarnationId"]);
563
631
  const worktreeId = nestedString(created, ["worktreeId"]) ?? binding.worktreeId;
@@ -566,20 +634,56 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
566
634
  if (listed.length !== 1) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} lacks stable identity`, binding.peer, createdHandle)] };
567
635
  created = listed[0]!;
568
636
  }
569
- const replacement: TerminalBinding = { ...binding, handle: createdHandle, incarnationId: nestedString(created, ["incarnationId"])!, worktreeId, launch: binding.launch, launchMetadata: binding.launch };
570
- const idle = await waitForIdle(replacement, timeoutMs, config);
637
+ let replacement: TerminalBinding = { ...binding, handle: createdHandle, incarnationId: nestedString(created, ["incarnationId"])!, worktreeId, launch: binding.launch, launchMetadata: binding.launch };
638
+ // The launch record `ahub <peer>` writes in the new terminal: live, gone (all recorded launchers), or neither.
639
+ const launches = async () => {
640
+ const rows = launchRecords(config.stateDir);
641
+ if (rows === "unreadable") return { live: undefined, gone: false, mayRun: true }; // unknown, never gone
642
+ const possible = rows.filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
643
+ const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity) })));
644
+ return { live: states.find((entry) => entry.state === "live")?.item, gone: states.length > 0 && states.every((entry) => entry.state === "gone"), mayRun: states.some((entry) => entry.state !== "gone") };
645
+ };
646
+ // #215: Orca types the command into a login shell that outlives it, so the terminal never exits with the launcher.
647
+ // Readiness is awaited in slices, and between them the launcher's own record says whether it still runs: one that
648
+ // recorded itself and is gone failed. A stale handle is gone only when Orca no longer lists the terminal's
649
+ // incarnation and no recorded launcher may still run. One that never recorded itself is left to the readiness
650
+ // bound and to the coordinator's reconciliation of its pending receipt.
651
+ const exited = (why: string) => ({ created: true, ready: false, manualRequired: true, blockers: [blocker("launcher-exited", `the ${binding.peer} restoration launcher in terminal ${createdHandle} ${why} before its TUI was ready`, binding.peer, createdHandle)] });
652
+ const deadline = Date.now() + Math.min(timeoutMs, MAX_WAIT_MS);
653
+ const slice = () => Math.max(1, Math.min(EXIT_SLICE_MS, deadline - Date.now())); // Orca refuses --timeout-ms 0
654
+ let idle = await waitForIdle(replacement, slice(), config);
655
+ for (;;) {
656
+ if (idle.blockers[0]?.code === "terminal-gone") {
657
+ const launch = await launches();
658
+ if (launch.gone) return exited("exited");
659
+ if (launch.mayRun) break;
660
+ const inventory = await run(config.runner, ["terminal", "list", "--json"]);
661
+ // A truncated inventory cannot show that the terminal is no longer listed.
662
+ const listed = responseResult(inventory).truncated === true || listTerminals(inventory).some((terminal) => terminal.incarnationId === replacement.incarnationId);
663
+ if (!listed) return exited("lost its terminal");
664
+ break;
665
+ }
666
+ if (idle.satisfied || idle.blockers[0]?.code !== "terminal-unready") break;
667
+ if ((await launches()).gone) return exited("exited");
668
+ if (Date.now() >= deadline) break;
669
+ idle = await waitForIdle(replacement, slice(), config);
670
+ }
571
671
  if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
672
+ // An idle terminal may be the login shell the launcher returned to.
673
+ const launch = await launches();
674
+ if (launch.gone) return exited("exited");
572
675
  const shown = await showBinding(replacement, config);
573
676
  const identity = identityFrom(shown);
574
677
  const session = sessionFrom(shown);
575
- const possibleRecords = readLaunchRecords(config.stateDir).filter((item) => item.instanceId === config.instanceId && item.peer === binding.peer && item.projectRoot === binding.projectRoot && item.handle === createdHandle && item.worktreeId === replacement.worktreeId && item.incarnationId === replacement.incarnationId);
576
- const recorded = (await Promise.all(possibleRecords.map(async (item) => await launcherMatches(item, config.processIdentity) ? item : undefined))).find((item): item is RecordedTerminalLaunch => item !== undefined);
577
- if ((identity !== binding.peer || session !== binding.sessionId) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || session === binding.sessionId))) {
678
+ const recorded = launch.live;
679
+ const same = (value: string | undefined) => anySession || value === binding.sessionId;
680
+ if ((identity !== binding.peer || !same(session)) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) {
578
681
  return { created: true, ready: true, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} cannot be mapped to the original ${binding.peer} session`, binding.peer, createdHandle)] };
579
682
  }
683
+ if (anySession && session) replacement = { ...replacement, sessionId: session };
580
684
  return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };
581
685
  } catch (error) {
582
- return { created: false, ready: false, manualRequired: true, blockers: [error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle)] };
686
+ return { created: made !== undefined, ready: false, manualRequired: true, blockers: [error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, made ?? binding.handle)] };
583
687
  }
584
688
  }
585
689