@staix/agent-hub 0.4.0 → 0.5.0

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.
@@ -0,0 +1,580 @@
1
+ import { isAbsolute, join, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ /** The only terminal commands used by this adapter. Keep this list in sync with Orca's public CLI. */
7
+ export type OrcaTerminalCommand = "list" | "show" | "wait" | "close" | "create";
8
+
9
+ export interface CommandResult {
10
+ /** Bun's name for the process exit status. */
11
+ status?: number;
12
+ /** Alternate name used by small test runners and child-process wrappers. */
13
+ exitCode?: number;
14
+ stdout?: string | Uint8Array;
15
+ stderr?: string | Uint8Array;
16
+ /** A runner may return the already-decoded JSON response. */
17
+ result?: unknown;
18
+ }
19
+
20
+ /** Runs one argv vector. The adapter never passes a shell command to this function. */
21
+ export type CommandRunner = (argv: readonly string[]) => Promise<CommandResult>;
22
+
23
+ /** Stable process identity hook. It returns a digest, never raw process metadata. */
24
+ export type ProcessIdentity = (pid: number) => string | undefined | Promise<string | undefined>;
25
+
26
+ export type TerminalPeer = "codex" | "claude";
27
+
28
+ export interface LaunchMetadata {
29
+ /** Absolute entrypoint of the package which owns the resumed session. */
30
+ packageEntrypoint: string;
31
+ /** Complete command string passed to Orca's supported `terminal create --command` flag. */
32
+ command: string;
33
+ /** The parsed argv represented by `command`, useful to a journal and tests. */
34
+ argv: string[];
35
+ /** Only these two variables can cross a recovery boundary. */
36
+ env: Partial<Pick<Record<"CODEX_HOME" | "CLAUDE_CONFIG_DIR", string>, "CODEX_HOME" | "CLAUDE_CONFIG_DIR">>;
37
+ }
38
+
39
+ export interface TerminalBinding {
40
+ peer: TerminalPeer;
41
+ handle: string;
42
+ incarnationId: string;
43
+ worktreeId: string;
44
+ projectRoot: string;
45
+ sessionId: string;
46
+ launch: LaunchMetadata;
47
+ /** Alias retained for callers that use the wire name. */
48
+ launchMetadata: LaunchMetadata;
49
+ }
50
+
51
+ export interface RecoveryBlocker {
52
+ code:
53
+ | "project-root-mismatch"
54
+ | "stale-terminal"
55
+ | "missing-terminal"
56
+ | "missing-session"
57
+ | "ownership-unknown"
58
+ | "ambiguous-terminal"
59
+ | "terminal-unready"
60
+ | "command-error"
61
+ | "ambiguous-create";
62
+ message: string;
63
+ peer?: TerminalPeer;
64
+ handle?: string;
65
+ }
66
+
67
+ export interface TerminalInspection {
68
+ bindings: TerminalBinding[];
69
+ /** A map is convenient for callers recovering both peers at once. */
70
+ byPeer: Partial<Record<TerminalPeer, TerminalBinding>>;
71
+ blockers: RecoveryBlocker[];
72
+ manualRequired: boolean;
73
+ }
74
+
75
+ export interface IdleResult {
76
+ satisfied: boolean;
77
+ manualRequired: boolean;
78
+ blockers: RecoveryBlocker[];
79
+ }
80
+
81
+ export type RestorationStatus = "restored" | "manual-required";
82
+
83
+ export interface RestorationResult {
84
+ status: RestorationStatus;
85
+ manualRequired: boolean;
86
+ peer: TerminalPeer;
87
+ oldHandle: string;
88
+ newBinding?: TerminalBinding;
89
+ blockers: RecoveryBlocker[];
90
+ }
91
+
92
+ export interface TerminalCloseResult {
93
+ closed: boolean;
94
+ manualRequired: boolean;
95
+ binding: TerminalBinding;
96
+ blockers: RecoveryBlocker[];
97
+ }
98
+
99
+ export interface TerminalCreateResult {
100
+ created: boolean;
101
+ ready: boolean;
102
+ manualRequired: boolean;
103
+ binding?: TerminalBinding;
104
+ /** Compatibility name used by the coordinator journal. */
105
+ newBinding?: TerminalBinding;
106
+ blockers: RecoveryBlocker[];
107
+ }
108
+
109
+ export interface TerminalRecoveryOptions {
110
+ runner?: CommandRunner;
111
+ /** Defaults to `orca`; callers in a managed session may pin another executable. */
112
+ orcaExecutable?: string;
113
+ /** Absolute path to this package's JS entrypoint. */
114
+ packageEntrypoint?: string;
115
+ /** Private state directory used to authenticate terminals launched by this ahub instance. */
116
+ stateDir?: string;
117
+ /** Daemon instance fence for a recorded launch. */
118
+ instanceId?: string;
119
+ /** Injectable process-start identity used to reject stale or reused launcher PIDs. */
120
+ processIdentity?: ProcessIdentity;
121
+ }
122
+
123
+ export interface RecordedTerminalLaunch {
124
+ peer: TerminalPeer;
125
+ projectRoot: string;
126
+ stateDir: string;
127
+ instanceId: string;
128
+ launcherPid: number;
129
+ launcherSignature: string;
130
+ /** Per-launch fence inherited by the native agent and Claude statusline. */
131
+ launchId: string;
132
+ handle: string;
133
+ incarnationId: string;
134
+ worktreeId: string;
135
+ env: Partial<Record<AllowedEnvName, string>>;
136
+ }
137
+
138
+ const MAX_WAIT_MS = 10 * 60 * 1000;
139
+ const MAX_COMMAND_MS = 30_000;
140
+ const ALLOWED_ENV = ["CODEX_HOME", "CLAUDE_CONFIG_DIR"] as const;
141
+ type AllowedEnvName = (typeof ALLOWED_ENV)[number];
142
+
143
+ const defaultEntrypoint = resolve(fileURLToPath(new URL("./main.js", import.meta.url)));
144
+
145
+ function bytes(value: string | Uint8Array | undefined): string {
146
+ if (value === undefined) return "";
147
+ return typeof value === "string" ? value : new TextDecoder().decode(value);
148
+ }
149
+
150
+ function commandStatus(result: CommandResult): number {
151
+ return result.status ?? result.exitCode ?? 0;
152
+ }
153
+
154
+ function decoded(result: CommandResult): unknown {
155
+ if (result.result !== undefined) return result.result;
156
+ const text = bytes(result.stdout).trim();
157
+ if (!text) return undefined;
158
+ try {
159
+ return JSON.parse(text) as unknown;
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ function errorText(result: CommandResult): string {
166
+ const stderr = bytes(result.stderr).trim();
167
+ return stderr || `exit status ${commandStatus(result)}`;
168
+ }
169
+
170
+ function runnerFailure(argv: readonly string[], result: CommandResult): RecoveryBlocker {
171
+ return { code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} failed: ${errorText(result)}` };
172
+ }
173
+
174
+ async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
175
+ const result = await runner(argv);
176
+ if (commandStatus(result) !== 0) throw new OrcaCommandError(runnerFailure(argv, result));
177
+ const value = decoded(result);
178
+ const root = object(value);
179
+ const payload = responseResult(value);
180
+ if (root.ok === false || payload.ok === false) {
181
+ throw new OrcaCommandError({ code: "command-error", message: `Orca ${argv.slice(0, 2).join(" ")} returned ok:false${typeof root.error === "string" ? `: ${root.error}` : ""}` });
182
+ }
183
+ return value;
184
+ }
185
+
186
+ export class OrcaCommandError extends Error {
187
+ constructor(public readonly blocker: RecoveryBlocker) {
188
+ super(blocker.message);
189
+ this.name = "OrcaCommandError";
190
+ }
191
+ }
192
+
193
+ /** POSIX quoting for the single command-string argument accepted by Orca. */
194
+ export function shellQuote(value: string): string {
195
+ return `'${value.replace(/'/g, `'\\''`)}'`;
196
+ }
197
+
198
+ function normalizeOptions(options?: CommandRunner | TerminalRecoveryOptions): Required<TerminalRecoveryOptions> {
199
+ const config: TerminalRecoveryOptions = typeof options === "function" ? { runner: options } : options ?? {};
200
+ const orcaExecutable = config.orcaExecutable ?? resolveOrcaExecutable();
201
+ return {
202
+ runner: config.runner ?? defaultRunner(orcaExecutable),
203
+ orcaExecutable,
204
+ packageEntrypoint: config.packageEntrypoint ?? defaultEntrypoint,
205
+ stateDir: config.stateDir ?? "",
206
+ instanceId: config.instanceId ?? "",
207
+ processIdentity: config.processIdentity ?? defaultProcessIdentity,
208
+ };
209
+ }
210
+
211
+ /** Resolve the same binary selection as the Orca CLI skill, once per operation. */
212
+ function resolveOrcaExecutable(): string {
213
+ if (process.env.ORCA_CLI_COMMAND) return process.env.ORCA_CLI_COMMAND;
214
+ if (process.env.ORCA_DEV_REPO_ROOT) return "orca-dev";
215
+ if (process.platform === "linux") return "orca-ide";
216
+ return "orca";
217
+ }
218
+
219
+ function defaultProcessIdentity(pid: number): string | undefined {
220
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
221
+ try {
222
+ const result = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart=,comm="], { stdout: "pipe", stderr: "pipe" });
223
+ if (result.exitCode !== 0) return undefined;
224
+ const identity = result.stdout.toString().trim();
225
+ if (!identity) return undefined;
226
+ return new Bun.CryptoHasher("sha256").update(identity).digest("hex");
227
+ } catch {
228
+ return undefined;
229
+ }
230
+ }
231
+
232
+ function defaultRunner(executable: string): CommandRunner {
233
+ return async (argv) => {
234
+ const child = Bun.spawn([executable, ...argv], { stdout: "pipe", stderr: "pipe" });
235
+ let timer: ReturnType<typeof setTimeout> | undefined;
236
+ try {
237
+ const result = Promise.all([child.stdout.text(), child.stderr.text(), child.exited]);
238
+ const timeout = new Promise<never>((_, reject) => {
239
+ timer = setTimeout(() => { child.kill(); reject(new Error(`orca command timed out after ${MAX_COMMAND_MS}ms`)); }, MAX_COMMAND_MS);
240
+ });
241
+ const [stdout, stderr, status] = await Promise.race([result, timeout]);
242
+ return { stdout, stderr, status };
243
+ } finally {
244
+ if (timer) clearTimeout(timer);
245
+ }
246
+ };
247
+ }
248
+
249
+ const RECORD_FILE = "terminal-recovery.json";
250
+
251
+ function recordPath(stateDir: string): string {
252
+ return join(stateDir, RECORD_FILE);
253
+ }
254
+
255
+ function readLaunchRecords(stateDir: string): RecordedTerminalLaunch[] {
256
+ if (!stateDir || !existsSync(recordPath(stateDir))) return [];
257
+ try {
258
+ const value: unknown = JSON.parse(readFileSync(recordPath(stateDir), "utf8"));
259
+ return Array.isArray(value) ? value.filter((item): item is RecordedTerminalLaunch => {
260
+ const row = object(item);
261
+ 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;
262
+ }) : [];
263
+ } catch {
264
+ return [];
265
+ }
266
+ }
267
+
268
+ function writeLaunchRecords(stateDir: string, rows: RecordedTerminalLaunch[]): void {
269
+ mkdirSync(stateDir, { recursive: true });
270
+ writeFileSync(recordPath(stateDir), `${JSON.stringify(rows)}\n`, { mode: 0o600 });
271
+ }
272
+
273
+ async function launcherMatches(row: RecordedTerminalLaunch, identity: ProcessIdentity): Promise<boolean> {
274
+ try {
275
+ const signature = await identity(row.launcherPid);
276
+ return typeof signature === "string" && signature.length > 0 && signature === row.launcherSignature;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ async function liveRecords(stateDir: string, projectRoot: string, instanceId: string, identity: ProcessIdentity): Promise<RecordedTerminalLaunch[]> {
283
+ if (!stateDir || !instanceId) return [];
284
+ const candidates = readLaunchRecords(stateDir).filter((item) => item.projectRoot === projectRoot && item.instanceId === instanceId);
285
+ const checks = await Promise.all(candidates.map(async (item) => await launcherMatches(item, identity) ? item : undefined));
286
+ return checks.filter((item): item is RecordedTerminalLaunch => item !== undefined);
287
+ }
288
+
289
+ /**
290
+ * Capture the Orca terminal handed to an ahub launcher. This is the authenticated
291
+ * identity source for legacy Orca payloads that omit agent/session metadata.
292
+ */
293
+ export async function recordTerminalLaunch(peer: TerminalPeer, projectRoot: string, stateDir: string, instanceId: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<RecordedTerminalLaunch | undefined> {
294
+ const config = normalizeOptions(options);
295
+ const handle = process.env.ORCA_TERMINAL_HANDLE;
296
+ const worktreeIdFromEnv = process.env.ORCA_WORKTREE_ID;
297
+ if (!handle || !worktreeIdFromEnv) return undefined;
298
+ const value = await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]);
299
+ const terminal = terminalObject(value);
300
+ const actualRoot = nestedString(terminal, ["worktreePath", "projectRoot"]);
301
+ const actualHandle = nestedString(terminal, ["handle"]);
302
+ const actualWorktree = nestedString(terminal, ["worktreeId"]);
303
+ const incarnationId = nestedString(terminal, ["incarnationId"]);
304
+ if (actualHandle !== handle || !actualRoot || !rootMatches(actualRoot, projectRoot) || actualWorktree !== worktreeIdFromEnv || !incarnationId) {
305
+ throw new OrcaCommandError(blocker("project-root-mismatch", "Orca launch terminal failed canonical root/worktree readback", peer, handle));
306
+ }
307
+ const launcherSignature = await config.processIdentity(process.pid);
308
+ if (!launcherSignature) throw new OrcaCommandError(blocker("command-error", "cannot prove launcher process identity; refusing to record terminal", peer, handle));
309
+ const launchId = randomUUID();
310
+ process.env.AGENTHUB_LAUNCH_ID = launchId;
311
+ process.env.AGENTHUB_INSTANCE_ID = instanceId;
312
+ const row: RecordedTerminalLaunch = { peer, projectRoot, stateDir, instanceId, launcherPid: process.pid, launcherSignature, launchId, handle, incarnationId, worktreeId: actualWorktree, env: allowedEnv({ env: process.env }) };
313
+ const rows = readLaunchRecords(stateDir).filter((item) => !(item.peer === peer && item.instanceId === instanceId));
314
+ rows.push(row);
315
+ writeLaunchRecords(stateDir, rows);
316
+ return row;
317
+ }
318
+
319
+ function rootMatches(actual: unknown, expected: string): boolean {
320
+ return typeof actual === "string" && actual === expected;
321
+ }
322
+
323
+ function object(value: unknown): Record<string, unknown> {
324
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
325
+ }
326
+
327
+ function responseResult(value: unknown): Record<string, unknown> {
328
+ const root = object(value);
329
+ return object(root.result ?? root);
330
+ }
331
+
332
+ function terminalObject(value: unknown): Record<string, unknown> {
333
+ const root = responseResult(value);
334
+ return object(root.terminal ?? root);
335
+ }
336
+
337
+ function nestedString(source: Record<string, unknown>, names: string[]): string | undefined {
338
+ for (const name of names) {
339
+ const direct = source[name];
340
+ if (typeof direct === "string" && direct.length > 0) return direct;
341
+ }
342
+ return undefined;
343
+ }
344
+
345
+ function sessionFrom(terminal: Record<string, unknown>): string | undefined {
346
+ const wait = object(terminal.agentWait);
347
+ const session = object(terminal.session);
348
+ return nestedString(terminal, ["sessionId", "agentSessionId", "resumeSessionId"]) ?? nestedString(wait, ["sessionId", "agentSessionId", "resumeSessionId", "session", "agentSession"]) ?? nestedString(session, ["id", "sessionId"]);
349
+ }
350
+
351
+ function identityFrom(terminal: Record<string, unknown>): TerminalPeer | undefined {
352
+ const wait = object(terminal.agentWait);
353
+ const session = object(terminal.session);
354
+ const candidates = [terminal.agentIdentity, terminal.agent, wait.agentIdentity, wait.agent, session.agentIdentity, session.agent];
355
+ for (const candidate of candidates) {
356
+ if (candidate === "codex" || candidate === "claude") return candidate;
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ function allowedEnv(terminal: Record<string, unknown>): Partial<Record<AllowedEnvName, string>> {
362
+ const env: Partial<Record<AllowedEnvName, string>> = {};
363
+ const candidates = [terminal.env, terminal.environment, terminal.launchEnv, object(terminal.launch).env];
364
+ for (const candidate of candidates) {
365
+ const source = object(candidate);
366
+ for (const name of ALLOWED_ENV) {
367
+ if (env[name] === undefined && typeof source[name] === "string") env[name] = source[name] as string;
368
+ }
369
+ }
370
+ return env;
371
+ }
372
+
373
+ function listTerminals(value: unknown): Record<string, unknown>[] {
374
+ const root = responseResult(value);
375
+ const list = root.terminals ?? root.items;
376
+ return Array.isArray(list) ? list.map(object) : [];
377
+ }
378
+
379
+ function requireAbsoluteEntrypoint(value: string): string {
380
+ if (!isAbsolute(value)) throw new Error(`package entrypoint must be absolute: ${value}`);
381
+ return value;
382
+ }
383
+
384
+ function launchFor(peer: TerminalPeer, projectRoot: string, sessionId: string, options: Required<TerminalRecoveryOptions>, source: Record<string, unknown>, recorded?: RecordedTerminalLaunch): LaunchMetadata {
385
+ const packageEntrypoint = requireAbsoluteEntrypoint(options.packageEntrypoint);
386
+ const argv = peer === "codex"
387
+ ? ["bun", packageEntrypoint, "--project", projectRoot, "codex", "resume", sessionId]
388
+ : ["bun", packageEntrypoint, "--project", projectRoot, "claude", "--resume", sessionId];
389
+ const env = { ...allowedEnv(source), ...(recorded?.env ?? {}) };
390
+ const prefix = ALLOWED_ENV.filter((name) => env[name] !== undefined).map((name) => `${name}=${shellQuote(env[name]!)}`);
391
+ const command = [...prefix, ...argv.map(shellQuote)].join(" ");
392
+ return { packageEntrypoint, command, argv, env };
393
+ }
394
+
395
+ function blocker(code: RecoveryBlocker["code"], message: string, peer?: TerminalPeer, handle?: string): RecoveryBlocker {
396
+ return { code, message, ...(peer ? { peer } : {}), ...(handle ? { handle } : {}) };
397
+ }
398
+
399
+ function verifyBindingData(actual: Record<string, unknown>, binding: TerminalBinding): RecoveryBlocker | undefined {
400
+ const handle = nestedString(actual, ["handle"]);
401
+ const incarnation = nestedString(actual, ["incarnationId"]);
402
+ const root = nestedString(actual, ["worktreePath", "projectRoot"]);
403
+ const worktree = nestedString(actual, ["worktreeId"]);
404
+ 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);
405
+ 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);
406
+ return undefined;
407
+ }
408
+
409
+ async function showBinding(binding: TerminalBinding, options: Required<TerminalRecoveryOptions>): Promise<Record<string, unknown>> {
410
+ const value = await run(options.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]);
411
+ const actual = terminalObject(value);
412
+ const issue = verifyBindingData(actual, binding);
413
+ if (issue) throw new OrcaCommandError(issue);
414
+ return actual;
415
+ }
416
+
417
+ /**
418
+ * Read Orca's authoritative terminal inventory and bind only exact agent/session identities.
419
+ * Titles, previews, pty ids and process names are deliberately never used as identity.
420
+ */
421
+ export async function inspectTerminals(projectRoot: string, sessions: Partial<Record<TerminalPeer, string>>, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalInspection> {
422
+ const config = normalizeOptions(options);
423
+ const records = await liveRecords(config.stateDir, projectRoot, config.instanceId, config.processIdentity);
424
+ const blockers: RecoveryBlocker[] = [];
425
+ const bindings: TerminalBinding[] = [];
426
+ const byPeer: Partial<Record<TerminalPeer, TerminalBinding>> = {};
427
+ const requested = (Object.entries(sessions) as [TerminalPeer, string | undefined][]).filter((entry): entry is [TerminalPeer, string] => typeof entry[1] === "string" && entry[1].length > 0);
428
+ if (!requested.length) return { bindings, byPeer, blockers, manualRequired: false };
429
+
430
+ let listed: Record<string, unknown>[];
431
+ try {
432
+ listed = listTerminals(await run(config.runner, ["terminal", "list", "--json"]));
433
+ } catch (error) {
434
+ const commandBlocker = error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error));
435
+ return { bindings, byPeer, blockers: [commandBlocker], manualRequired: true };
436
+ }
437
+
438
+ for (const [peer, sessionId] of requested) {
439
+ const candidates = listed.filter((terminal) => rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot));
440
+ const peerCandidates: Record<string, unknown>[] = [];
441
+ for (const listedTerminal of candidates) {
442
+ const handle = nestedString(listedTerminal, ["handle"]);
443
+ if (!handle) continue;
444
+ let terminal: Record<string, unknown>;
445
+ try {
446
+ terminal = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", handle, "--json"]));
447
+ } catch (error) {
448
+ blockers.push(error instanceof OrcaCommandError ? { ...error.blocker, peer, handle } : blocker("command-error", String(error), peer, handle));
449
+ continue;
450
+ }
451
+ // A show readback is required; list can be stale between recovery steps.
452
+ if (!rootMatches(terminal.worktreePath ?? terminal.projectRoot, projectRoot)) continue;
453
+ const identity = identityFrom(terminal);
454
+ const terminalSession = sessionFrom(terminal);
455
+ const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
456
+ // A recorded launcher proves ownership; the daemon's supplied session id is the
457
+ // authoritative session id when Orca omits it from terminal metadata.
458
+ if ((identity === peer && terminalSession === sessionId) || (recorded !== undefined && (identity === undefined || identity === peer) && (terminalSession === undefined || terminalSession === sessionId))) peerCandidates.push(terminal);
459
+ }
460
+ if (!peerCandidates.length) {
461
+ const ownershipUnknown = candidates.some((terminal) => identityFrom(terminal) === undefined && !records.some((item) => item.handle === terminal.handle));
462
+ blockers.push(ownershipUnknown
463
+ ? blocker("ownership-unknown", `cannot prove ${peer} terminal ownership from Orca metadata`, peer)
464
+ : blocker("missing-terminal", `no ${peer} terminal with session ${sessionId} in ${projectRoot}`, peer));
465
+ continue;
466
+ }
467
+ if (peerCandidates.length > 1) {
468
+ blockers.push(blocker("ambiguous-terminal", `multiple ${peer} terminals match session ${sessionId}; refusing to choose`, peer));
469
+ continue;
470
+ }
471
+ const terminal = peerCandidates[0]!;
472
+ const handle = nestedString(terminal, ["handle"]);
473
+ const incarnationId = nestedString(terminal, ["incarnationId"]);
474
+ const worktreeId = nestedString(terminal, ["worktreeId"]);
475
+ if (!handle || !incarnationId || !worktreeId) {
476
+ blockers.push(blocker("stale-terminal", `terminal metadata lacks a stable handle, incarnation or worktree id`, peer, handle));
477
+ continue;
478
+ }
479
+ if (terminal.orphaned === true || terminal.connected === false) {
480
+ blockers.push(blocker("terminal-unready", `terminal ${handle} is orphaned or disconnected`, peer, handle));
481
+ continue;
482
+ }
483
+ const recorded = records.find((item) => item.peer === peer && item.handle === handle && item.worktreeId === terminal.worktreeId && item.incarnationId === terminal.incarnationId);
484
+ const launch = launchFor(peer, projectRoot, sessionId, config, terminal, recorded);
485
+ const binding: TerminalBinding = { peer, handle, incarnationId, worktreeId, projectRoot, sessionId, launch, launchMetadata: launch };
486
+ bindings.push(binding);
487
+ byPeer[peer] = binding;
488
+ }
489
+ return { bindings, byPeer, blockers, manualRequired: blockers.length > 0 };
490
+ }
491
+
492
+ /** Wait only on the captured terminal and only for Orca's supported TUI-idle condition. */
493
+ export async function waitForIdle(binding: TerminalBinding, timeoutMs: number, options?: CommandRunner | TerminalRecoveryOptions): Promise<IdleResult> {
494
+ const config = normalizeOptions(options);
495
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(Math.floor(timeoutMs), MAX_WAIT_MS) : 0;
496
+ try {
497
+ await showBinding(binding, config);
498
+ const value = await run(config.runner, ["terminal", "wait", "--terminal", binding.handle, "--for", "tui-idle", "--timeout-ms", String(timeout), "--json"]);
499
+ const root = responseResult(value);
500
+ const wait = object(root.wait);
501
+ const satisfied = root.satisfied === true || wait.satisfied === true;
502
+ const blockers = satisfied ? [] : [blocker("terminal-unready", `terminal ${binding.handle} did not become idle before the bounded wait`, binding.peer, binding.handle)];
503
+ return { satisfied, manualRequired: !satisfied, blockers };
504
+ } catch (error) {
505
+ const item = error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle);
506
+ return { satisfied: false, manualRequired: true, blockers: [item] };
507
+ }
508
+ }
509
+
510
+ function restoration(peer: TerminalPeer, oldHandle: string, blockers: RecoveryBlocker[]): RestorationResult {
511
+ return { status: "manual-required", manualRequired: true, peer, oldHandle, blockers };
512
+ }
513
+
514
+ function closeFailure(binding: TerminalBinding, blockers: RecoveryBlocker[]): TerminalCloseResult {
515
+ return { closed: false, manualRequired: true, binding, blockers };
516
+ }
517
+
518
+ /** Quiesce and close exactly the captured terminal. This is a separately journalable effect. */
519
+ export async function closeTerminal(binding: TerminalBinding, timeoutMs = 600_000, options?: CommandRunner | TerminalRecoveryOptions): Promise<TerminalCloseResult> {
520
+ const config = normalizeOptions(options);
521
+ // The coordinator passes zero after its explicit quiesce/wait step. The show
522
+ // readback below still proves the captured identity before the close mutation.
523
+ const idle = timeoutMs === 0 ? { satisfied: true, manualRequired: false, blockers: [] as RecoveryBlocker[] } : await waitForIdle(binding, timeoutMs, config);
524
+ if (!idle.satisfied) return closeFailure(binding, idle.blockers);
525
+ try {
526
+ await showBinding(binding, config);
527
+ await run(config.runner, ["terminal", "close", "--terminal", binding.handle, "--json"]);
528
+ const afterClose = listTerminals(await run(config.runner, ["terminal", "list", "--json"]));
529
+ const stillThere = afterClose.some((terminal) => terminal.handle === binding.handle || terminal.incarnationId === binding.incarnationId);
530
+ if (stillThere) return closeFailure(binding, [blocker("ambiguous-create", `close of terminal ${binding.handle} was not confirmed`, binding.peer, binding.handle)]);
531
+ return { closed: true, manualRequired: false, binding, blockers: [] };
532
+ } catch (error) {
533
+ return closeFailure(binding, [error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle)]);
534
+ }
535
+ }
536
+
537
+ /** Create a replacement in the captured worktree, then prove TUI readiness and session mapping. */
538
+ export async function createTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions, timeoutMs = 600_000): Promise<TerminalCreateResult> {
539
+ const config = normalizeOptions(options);
540
+ try {
541
+ const worktreeSelector = binding.worktreeId.startsWith("id:") ? binding.worktreeId : `id:${binding.worktreeId}`;
542
+ const value = await run(config.runner, ["terminal", "create", "--worktree", worktreeSelector, "--command", binding.launch.command, "--title", `${binding.peer} recovery`, "--json"]);
543
+ let created = terminalObject(value);
544
+ const createdHandle = nestedString(created, ["handle"]);
545
+ if (!createdHandle) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", "Orca create returned no terminal handle", binding.peer)] };
546
+ const incarnationId = nestedString(created, ["incarnationId"]);
547
+ const worktreeId = nestedString(created, ["worktreeId"]) ?? binding.worktreeId;
548
+ if (!incarnationId) {
549
+ const listed = listTerminals(await run(config.runner, ["terminal", "list", "--json"])).filter((terminal) => terminal.handle === createdHandle);
550
+ if (listed.length !== 1) return { created: false, ready: false, manualRequired: true, blockers: [blocker("ambiguous-create", `created terminal ${createdHandle} lacks stable identity`, binding.peer, createdHandle)] };
551
+ created = listed[0]!;
552
+ }
553
+ const replacement: TerminalBinding = { ...binding, handle: createdHandle, incarnationId: nestedString(created, ["incarnationId"])!, worktreeId, launch: binding.launch, launchMetadata: binding.launch };
554
+ const idle = await waitForIdle(replacement, timeoutMs, config);
555
+ if (!idle.satisfied) return { created: true, ready: false, manualRequired: true, blockers: idle.blockers };
556
+ const shown = await showBinding(replacement, config);
557
+ const identity = identityFrom(shown);
558
+ const session = sessionFrom(shown);
559
+ 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);
560
+ const recorded = (await Promise.all(possibleRecords.map(async (item) => await launcherMatches(item, config.processIdentity) ? item : undefined))).find((item): item is RecordedTerminalLaunch => item !== undefined);
561
+ if ((identity !== binding.peer || session !== binding.sessionId) && !(recorded && (identity === undefined || identity === binding.peer) && (session === undefined || session === binding.sessionId))) {
562
+ 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)] };
563
+ }
564
+ return { created: true, ready: true, manualRequired: false, binding: replacement, newBinding: replacement, blockers: [] };
565
+ } catch (error) {
566
+ return { created: false, ready: false, manualRequired: true, blockers: [error instanceof OrcaCommandError ? error.blocker : blocker("command-error", String(error), binding.peer, binding.handle)] };
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Close one verified terminal and recreate it in the same Orca worktree. Every mutation is
572
+ * preceded by a show readback and no ambiguous close/create outcome is retried.
573
+ */
574
+ export async function restoreTerminal(binding: TerminalBinding, timeoutMs = 600_000, options?: CommandRunner | TerminalRecoveryOptions): Promise<RestorationResult> {
575
+ const closed = await closeTerminal(binding, timeoutMs, options);
576
+ if (!closed.closed) return restoration(binding.peer, binding.handle, closed.blockers);
577
+ const created = await createTerminal(binding, options, timeoutMs);
578
+ if (!created.binding) return restoration(binding.peer, binding.handle, created.blockers);
579
+ return { status: "restored", manualRequired: false, peer: binding.peer, oldHandle: binding.handle, newBinding: created.binding, blockers: [] };
580
+ }