@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.
- package/CHANGELOG.md +21 -0
- package/README.md +3 -3
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +2 -1
- package/docs/agent-notes/models.md +3 -0
- package/docs/agent-notes/tasks.md +3 -2
- package/docs/events.md +4 -1
- package/docs/operations.md +419 -17
- package/docs/quickstart.md +1 -1
- package/docs/security.md +18 -11
- package/docs/smoke.md +91 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +412 -3
- package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
- package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +63 -25
- package/src/adapters/claude-channel.ts +61 -20
- package/src/adapters/codex-appserver.ts +3 -0
- package/src/adapters/local-worker.ts +4 -0
- package/src/cli/console-state.ts +325 -102
- package/src/cli/console.ts +44 -23
- package/src/cli/help.ts +120 -0
- package/src/cli/identity.ts +3 -3
- package/src/cli/launch.ts +2 -0
- package/src/cli/main.ts +171 -75
- package/src/cli/preview.ts +1 -0
- package/src/cli/reset.ts +194 -0
- package/src/cli/status-lines.ts +9 -5
- package/src/cli/terminal-recovery.ts +140 -36
- package/src/cli/upgrade-runtime.ts +274 -56
- package/src/cli/upgrade.ts +366 -41
- package/src/hub/ask.ts +1 -1
- package/src/hub/board.ts +15 -7
- package/src/hub/bus.ts +60 -4
- package/src/hub/child-process.ts +1 -0
- package/src/hub/conductor.ts +38 -6
- package/src/hub/control-client.ts +5 -3
- package/src/hub/daemon.ts +70 -12
- package/src/hub/events.ts +7 -1
- package/src/hub/execution-budget.ts +15 -6
- package/src/hub/hub-tools.ts +4 -4
- package/src/hub/inference.ts +136 -1
- package/src/hub/lifecycle.ts +7 -1
- package/src/hub/peers.ts +2 -0
- package/src/hub/recovery-store.ts +51 -3
- package/src/hub/report.ts +43 -1
- package/src/hub/restart.ts +25 -0
- package/src/hub/routing.ts +48 -16
- package/src/hub/snapshots.ts +3 -2
- package/src/hub/tasks.ts +236 -83
- package/src/local/deny.ts +2 -1
- package/src/models/mlx.ts +11 -8
- package/src/models/ollama.ts +2 -2
- package/src/models/relay.ts +190 -42
- package/src/models/route/relay-selector.ts +43 -31
- package/src/models/route/runtime.ts +17 -7
- package/src/models/route/signals.ts +12 -1
- package/src/models/route/stage.ts +57 -3
- package/src/omniroute/client.ts +1 -1
- package/src/ui/index.html +1 -0
- package/templates/AGENTS.block.md +1 -1
- 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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
+
atomicPrivateJSON(recordPath(stateDir), rows); // temp + rename: a reader never sees half a file
|
|
272
293
|
}
|
|
273
294
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
552
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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
|
|
576
|
-
const
|
|
577
|
-
if ((identity !== binding.peer || session
|
|
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:
|
|
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
|
|