@staix/agent-hub 0.12.19 → 0.12.21

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 (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. 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
- import { processSignature } from "../pi/process-signature.ts";
5
+ import { processLiveness, 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;
@@ -127,6 +130,12 @@ export interface TerminalRecoveryOptions {
127
130
  instanceId?: string;
128
131
  /** Injectable process-start identity used to reject stale or reused launcher PIDs. */
129
132
  processIdentity?: ProcessIdentity;
133
+ /**
134
+ * #228: the identity as releases up to 0.12.12 computed it (unpinned `ps`, the reader's TZ and locale). It defaults
135
+ * to that read only when `processIdentity` is the default, so an injected identity is never second-guessed by a
136
+ * real `ps` read of the same pid.
137
+ */
138
+ legacyIdentity?: ProcessIdentity;
130
139
  }
131
140
 
132
141
  export interface RecordedTerminalLaunch {
@@ -146,6 +155,8 @@ export interface RecordedTerminalLaunch {
146
155
 
147
156
  const MAX_WAIT_MS = 10 * 60 * 1000;
148
157
  const MAX_COMMAND_MS = 30_000;
158
+ /** #215: a replacement's readiness is awaited in slices this long, checking between them whether its launcher still runs. */
159
+ const EXIT_SLICE_MS = 5_000;
149
160
  const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
150
161
  type AllowedEnvName = (typeof ALLOWED_ENV)[number];
151
162
 
@@ -176,13 +187,27 @@ function errorText(result: CommandResult): string {
176
187
  return stderr || `exit status ${commandStatus(result)}`;
177
188
  }
178
189
 
190
+ /** Orca's structured error code, from the JSON it prints on stdout or stderr. */
191
+ function errorCode(result: CommandResult): unknown {
192
+ const fromStdout = object(object(decoded(result)).error).code;
193
+ if (fromStdout !== undefined) return fromStdout;
194
+ try { return object(object(JSON.parse(bytes(result.stderr))).error).code; } catch { return undefined; }
195
+ }
196
+
179
197
  function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
180
198
  return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
181
199
  }
182
200
 
183
201
  async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
184
202
  const result = await runner(argv);
185
- if (commandStatus(result) !== 0) throw new OrcaCommandError(runnerFailure(argv, result));
203
+ if (commandStatus(result) !== 0) {
204
+ // Orca 1.4.223 answers a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
205
+ if (errorCode(result) === "terminal_handle_stale") {
206
+ const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
207
+ throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
208
+ }
209
+ throw new OrcaCommandError(runnerFailure(argv, result));
210
+ }
186
211
  const value = decoded(result);
187
212
  const root = object(value);
188
213
  const payload = responseResult(value);
@@ -214,6 +239,7 @@ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Re
214
239
  stateDir: config.stateDir ?? "",
215
240
  instanceId: config.instanceId ?? "",
216
241
  processIdentity: config.processIdentity ?? defaultProcessIdentity,
242
+ legacyIdentity: config.legacyIdentity ?? (config.processIdentity ? () => undefined : unpinnedProcessIdentity),
217
243
  };
218
244
  }
219
245
 
@@ -249,42 +275,97 @@ function defaultRunner(executable: string): CommandRunner {
249
275
 
250
276
  const RECORD_FILE = "terminal-recovery.json";
251
277
 
252
- function recordPath(stateDir: string): string {
278
+ export function recordPath(stateDir: string): string {
253
279
  return join(stateDir, RECORD_FILE);
254
280
  }
255
281
 
256
- function readLaunchRecords(stateDir: string): RecordedTerminalLaunch[] {
257
- if (!stateDir || !existsSync(recordPath(stateDir))) return [];
282
+ function validRecord(item: unknown): item is RecordedTerminalLaunch {
283
+ const row = object(item);
284
+ 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;
285
+ }
286
+
287
+ /**
288
+ * #215: "unreadable" (a corrupt or unreadable file) says nothing about launchers, so it must never read as "none".
289
+ * #228: a row that fails validation is kept apart (`invalid`), never dropped: whatever it would answer is unknown, and
290
+ * `all` is the file as read, so a writer puts every such row back unchanged.
291
+ */
292
+ type LaunchRecords = { rows: RecordedTerminalLaunch[]; invalid: unknown[]; all: unknown[] };
293
+ function launchRecords(stateDir: string): LaunchRecords | "unreadable" {
294
+ const none = { rows: [], invalid: [], all: [] };
295
+ if (!stateDir || !existsSync(recordPath(stateDir))) return none;
258
296
  try {
259
297
  const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
260
- return Array.isArray(value) ? value.filter((item): item is RecordedTerminalLaunch => {
261
- const row = object(item);
262
- 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 [];
298
+ if (!Array.isArray(value)) return "unreadable";
299
+ return { rows: value.filter(validRecord), invalid: value.filter((item) => !validRecord(item)), all: value };
300
+ } catch (error) {
301
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? none : "unreadable";
266
302
  }
267
303
  }
268
304
 
269
- function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): void {
305
+ /**
306
+ * #228: whether a row that cannot be evaluated may answer a question about this peer, project and instance: it does
307
+ * unless one of its fields is a readable string that names another one (a row for another peer or an older instance).
308
+ */
309
+ function concerns(raw: unknown, query: { peer?: string; projectRoot: string; instanceId: string }): boolean {
310
+ const row = object(raw);
311
+ const other = (value: unknown, wanted?: string) => wanted !== undefined && typeof value === "string" && value !== wanted;
312
+ return !other(row.peer, query.peer) && !other(row.projectRoot, query.projectRoot) && !other(row.instanceId, query.instanceId);
313
+ }
314
+
315
+ function writeLaunchRecords(stateDir: string, rows: unknown[]): void {
270
316
  mkdirSync(stateDir, { recursive: true });
271
- writeFileSync(recordPath(stateDir), `${JSON.stringify(rows)}\n`, { mode: 0o600 });
317
+ atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
272
318
  }
273
319
 
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;
320
+ /**
321
+ * #215: live when the recorded process signature matches; gone when the pid no longer exists (ESRCH) or another
322
+ * process holds it now; unknown when the pid exists but its identity cannot be read. Unknown is never gone.
323
+ */
324
+ export type LauncherState = "live" | "gone" | "unknown";
325
+ async function launcherState(row: RecordedTerminalLaunch, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<LauncherState> {
326
+ let current: string | undefined;
327
+ try { current = await identity(row.launcherPid) || undefined; } catch { /* unreadable: decided by the pid probe */ }
328
+ // #228: a launcher recorded by 0.12.12 or older hashed `ps` in its own TZ and locale (#177 pinned it in 0.12.13). Read
329
+ // the same unpinned way in this reader's environment, a match is that launcher; anything else stays gone (pid reuse).
330
+ if (current && current !== row.launcherSignature) {
331
+ let old: string | undefined;
332
+ try { old = await legacy(row.launcherPid) || undefined; } catch { /* no legacy reading: the pinned one decides */ }
333
+ if (old === row.launcherSignature) return "live";
280
334
  }
335
+ return processLiveness(row.launcherPid, row.launcherSignature, () => current);
336
+ }
337
+
338
+ /** The launcher signature as releases up to 0.12.12 computed it: `ps` in the caller's own TZ and locale, unpinned. */
339
+ function unpinnedProcessIdentity(pid: number): string | undefined {
340
+ try {
341
+ const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
342
+ const text = result.exitCode === 0 ? result.stdout.toString().trim() : "";
343
+ return text ? new Bun.CryptoHasher("sha256").update(text).digest("hex") : undefined;
344
+ } catch { return undefined; }
345
+ }
346
+
347
+ /**
348
+ * This instance's launch records whose launcher is live, those whose launcher cannot be identified (never gone), and
349
+ * the rows that cannot be evaluated and may be this project's and instance's (#228: unknown for their peer).
350
+ */
351
+ async function recordsByState(records: LaunchRecords, projectRoot: string, instanceId: string, identity: ProcessIdentity, legacy: ProcessIdentity): Promise<{ live: RecordedTerminalLaunch[]; unknown: RecordedTerminalLaunch[]; invalid: unknown[] }> {
352
+ if (!instanceId) return { live: [], unknown: [], invalid: [] };
353
+ const candidates = records.rows.filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
354
+ const states = await Promise.all(candidates.map((item) => launcherState(item, identity, legacy)));
355
+ return { live: candidates.filter((_, i) => states[i] === "live"), unknown: candidates.filter((_, i) => states[i] === "unknown"),
356
+ invalid: records.invalid.filter((raw) => concerns(raw, { projectRoot, instanceId })) };
281
357
  }
282
358
 
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);
359
+ /** #215: the launcher recorded for `peer` on this instance (one row per peer and instance) and its state; managed means live. */
360
+ export async function launcherOf(peer: TerminalPeer, projectRoot: string, options?: TerminalRecoveryOptions): Promise<{ record?: RecordedTerminalLaunch; state: LauncherState; invalidRow?: true } | undefined> {
361
+ const config = normalizeOptions(options);
362
+ if (!config.stateDir || !config.instanceId) return undefined;
363
+ const records = launchRecords(config.stateDir);
364
+ if (records === "unreadable") return { state: "unknown" };
365
+ // #228: a row that cannot be evaluated and may be this launcher's is unknown, never "no launcher".
366
+ if (records.invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) return { state: "unknown", invalidRow: true };
367
+ const record = records.rows.find((row) => row.peer === peer && row.projectRoot === projectRoot && row.instanceId === config.instanceId);
368
+ return record ? { record, state: await launcherState(record, config.processIdentity, config.legacyIdentity) } : undefined;
288
369
  }
289
370
 
290
371
  /**
@@ -311,7 +392,14 @@ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: stri
311
392
  process.env.AGENTHUB_LAUNCH_ID = launchId;
312
393
  process.env.AGENTHUB_INSTANCE_ID = instanceId;
313
394
  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));
395
+ const existing = launchRecords(stateDir);
396
+ // #215: rewriting an unreadable file would replace every other launcher's record with this one, and recovery would
397
+ // then read those launchers as gone.
398
+ if (existing === "unreadable") {
399
+ 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));
400
+ }
401
+ // #228: every row that cannot be evaluated goes back unchanged; only this peer's valid row for this instance is replaced.
402
+ const rows = existing.all.filter((item) => !(validRecord(item) && item.peer === peer && item.instanceId === instanceId));
315
403
  rows.push(row);
316
404
  writeLaunchRecords(stateDir, rows);
317
405
  return row;
@@ -401,6 +489,8 @@ function blocker(code: RecoveryBlocker["code"], message: string, peer?: Terminal
401
489
  code === "ambiguous-terminal" ? "close or identify the duplicate terminal, then resume recovery" :
402
490
  code === "ambiguous-create" ? "inspect the existing terminal and attach the original session before resuming" :
403
491
  code === "terminal-unready" ? "finish or cancel the active terminal turn, then resume recovery" :
492
+ code === "launcher-exited" ? "the session was not restored; the coordinator's error names the choices" :
493
+ code === "terminal-gone" ? "the terminal was closed; inspect the project's terminals in Orca before resuming recovery" :
404
494
  "inspect the named terminal and resume recovery after the identity is verified";
405
495
  return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle, terminalReference: handle } : {}), nextAction };
406
496
  }
@@ -429,12 +519,16 @@ async function showBinding(binding: TerminalBinding, options: Required<TerminalR
429
519
  */
430
520
  export async function inspectTerminals(projectRoot: string, sessions: Partial<Record<TerminalPeer, string | SessionRef>>, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalInspection> {
431
521
  const config = normalizeOptions(options);
432
- const records = await liveRecords(config.stateDir, projectRoot, config.instanceId, config.processIdentity);
433
522
  const blockers: RecoveryBlocker[] = [];
434
523
  const bindings: TerminalBinding[] = [];
435
524
  const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
436
525
  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
526
  if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
527
+ // #215: without the records a terminal could be bound from Orca metadata alone, losing the launch's captured
528
+ // CODEX_HOME or CLAUDE_CONFIG_DIR: an unreadable file blocks the binding, never reads as "no records".
529
+ const rows = launchRecords(config.stateDir);
530
+ 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 };
531
+ const { live: records, unknown, invalid } = await recordsByState(rows, projectRoot, config.instanceId, config.processIdentity, config.legacyIdentity);
438
532
 
439
533
  let listed: Record<string, unknown>[];
440
534
  try {
@@ -446,6 +540,16 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
446
540
 
447
541
  for (const [peer, ref] of requested) {
448
542
  const sessionId = ref.sessionId;
543
+ // #215: a launcher whose identity cannot be read may be this peer's: binding from Orca metadata would lose its launch.
544
+ const unread = unknown.find((item) => item.peer === peer);
545
+ if (unread) {
546
+ 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));
547
+ continue;
548
+ }
549
+ if (invalid.some((raw) => concerns(raw, { peer, projectRoot, instanceId: config.instanceId }))) {
550
+ 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));
551
+ continue;
552
+ }
449
553
  const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
450
554
  const peerCandidates: Record<string, unknown>[] = [];
451
555
  for (const listedTerminal of candidates) {
@@ -500,16 +604,25 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
500
604
  return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
501
605
  }
502
606
 
607
+ /**
608
+ * One bounded Orca wait. Orca 1.4.223 answers a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
609
+ * that is "not satisfied", not a failure. Any other failure still throws.
610
+ */
611
+ async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
612
+ const argv = ["terminal", "wait", "--terminal", handle, "--for", "tui-idle", "--timeout-ms", String(timeoutMs), "--json"];
613
+ const result = await runner(argv);
614
+ if (commandStatus(result) !== 0 && errorCode(result) === "timeout") return false;
615
+ const root = responseResult(await run(async () => result, argv));
616
+ return root.satisfied === true || object(root.wait).satisfied === true;
617
+ }
618
+
503
619
  /** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
504
620
  export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, options?: CommandRunner | TerminalRecoveryOptions): Promise<IdleResult> {
505
621
  const config = normalizeOptions(options);
506
622
  const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
507
623
  try {
508
624
  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;
625
+ const satisfied = await waitSatisfied(config.runner, binding.handle, timeout);
513
626
  const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
514
627
  return { satisfied, manualRequired: !satisfied, blockers };
515
628
  } catch (error) {
@@ -548,9 +661,13 @@ export async function closeTerminal(binding: TerminalBinding, timeoutMs = 600_00
548
661
  }
549
662
  }
550
663
 
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> {
664
+ /**
665
+ * Create a replacement in the captured worktree, then prove TUI readiness and session mapping. `anySession` maps a
666
+ * fresh session the operator chose (#215): the terminal must still be this launch's, under any new session id.
667
+ */
668
+ export async function createTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions, timeoutMs = 600_000, anySession = false): Promise<TerminalCreateResult> {
553
669
  const config = normalizeOptions(options);
670
+ let made: string | undefined; // a later failure must still report the terminal it created
554
671
  try {
555
672
  const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
556
673
  // The coordinator journals a pending creation before this call and reconciles
@@ -558,6 +675,7 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
558
675
  const value = await run(config.runner, ["terminal", "create", "--worktree", worktreeSelector, "--command", binding.launch.command, "--title", `${binding.peer} recovery`, "--json"]);
559
676
  let created = terminalObject(value);
560
677
  const createdHandle = nestedString(created, ["handle"]);
678
+ made = createdHandle;
561
679
  if (!createdHandle) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", "Orca create returned no terminal handle", binding.peer)] };
562
680
  const incarnationId = nestedString(created, ["incarnationId"]);
563
681
  const worktreeId = nestedString(created, ["worktreeId"]) ?? binding.worktreeId;
@@ -566,20 +684,58 @@ export async function createTerminal(binding: TerminalBinding, options?: Command
566
684
  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
685
  created = listed[0]!;
568
686
  }
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);
687
+ let replacement: TerminalBinding = { ...binding, handle: createdHandle, incarnationId: nestedString(created, ["incarnationId"])!, worktreeId, launch: binding.launch, launchMetadata: binding.launch };
688
+ // The launch record `ahub <peer>` writes in the new terminal: live, gone (all recorded launchers), or neither.
689
+ const launches = async () => {
690
+ const rows = launchRecords(config.stateDir);
691
+ if (rows === "unreadable") return { live: undefined, gone: false, mayRun: true }; // unknown, never gone
692
+ // #228: a row that cannot be evaluated and may be this launch's is unknown too.
693
+ if (rows.invalid.some((raw) => concerns(raw, { peer: binding.peer, projectRoot: binding.projectRoot, instanceId: config.instanceId }))) return { live: undefined, gone: false, mayRun: true };
694
+ const possible = rows.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);
695
+ const states = await Promise.all(possible.map(async (item) => ({ item, state: await launcherState(item, config.processIdentity, config.legacyIdentity) })));
696
+ 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") };
697
+ };
698
+ // #215: Orca types the command into a login shell that outlives it, so the terminal never exits with the launcher.
699
+ // Readiness is awaited in slices, and between them the launcher's own record says whether it still runs: one that
700
+ // recorded itself and is gone failed. A stale handle is gone only when Orca no longer lists the terminal's
701
+ // incarnation and no recorded launcher may still run. One that never recorded itself is left to the readiness
702
+ // bound and to the coordinator's reconciliation of its pending receipt.
703
+ 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)] });
704
+ const deadline = Date.now() + Math.min(timeoutMs, MAX_WAIT_MS);
705
+ const slice = () => Math.max(1, Math.min(EXIT_SLICE_MS, deadline - Date.now())); // Orca refuses --timeout-ms 0
706
+ let idle = await waitForIdle(replacement, slice(), config);
707
+ for (;;) {
708
+ if (idle.blockers[0]?.code === "terminal-gone") {
709
+ const launch = await launches();
710
+ if (launch.gone) return exited("exited");
711
+ if (launch.mayRun) break;
712
+ const inventory = await run(config.runner, ["terminal", "list", "--json"]);
713
+ // A truncated inventory cannot show that the terminal is no longer listed.
714
+ const listed = responseResult(inventory).truncated === true || listTerminals(inventory).some((terminal) => terminal.incarnationId === replacement.incarnationId);
715
+ if (!listed) return exited("lost its terminal");
716
+ break;
717
+ }
718
+ if (idle.satisfied || idle.blockers[0]?.code !== "terminal-unready") break;
719
+ if ((await launches()).gone) return exited("exited");
720
+ if (Date.now() >= deadline) break;
721
+ idle = await waitForIdle(replacement, slice(), config);
722
+ }
571
723
  if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
724
+ // An idle terminal may be the login shell the launcher returned to.
725
+ const launch = await launches();
726
+ if (launch.gone) return exited("exited");
572
727
  const shown = await showBinding(replacement, config);
573
728
  const identity = identityFrom(shown);
574
729
  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))) {
730
+ const recorded = launch.live;
731
+ const same = (value: string | undefined) => anySession || value === binding.sessionId;
732
+ if ((identity !== binding.peer || !same(session)) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || same(session)))) {
578
733
  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
734
  }
735
+ if (anySession && session) replacement = { ...replacement, sessionId: session };
580
736
  return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };
581
737
  } 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)] };
738
+ 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
739
  }
584
740
  }
585
741