@staix/agent-hub 0.12.31 → 0.12.33

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 (79) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +14 -7
  3. package/docs/agent-notes/adapters.md +7 -4
  4. package/docs/agent-notes/bus.md +3 -0
  5. package/docs/agent-notes/daemon.md +13 -8
  6. package/docs/agent-notes/local-worker.md +1 -1
  7. package/docs/agent-notes/tasks.md +4 -3
  8. package/docs/agent-notes/tests.md +1 -0
  9. package/docs/cooperbench.md +1 -1
  10. package/docs/gsd-collaboration-analysis.md +1 -1
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +141 -25
  13. package/docs/quickstart.md +10 -6
  14. package/docs/security.md +18 -9
  15. package/docs/smoke.md +34 -0
  16. package/docs/specs/2026-09-19-agent-hub-design.md +204 -0
  17. package/docs/specs/2026-09-20-upgrade-recovery-design.md +211 -0
  18. package/docs/verification/2026-10-18-0.12.32.md +54 -0
  19. package/docs/verification/2026-10-18-0.12.33.md +42 -0
  20. package/docs/verified.json +24 -16
  21. package/package.json +2 -2
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +1296 -26
  24. package/src/adapters/acp.ts +5 -6
  25. package/src/adapters/definitions/builtin-metadata.ts +121 -0
  26. package/src/adapters/definitions/kimi-native.ts +4 -0
  27. package/src/adapters/definitions/qwen-native.ts +24 -0
  28. package/src/adapters/definitions/qwen.ts +71 -0
  29. package/src/adapters/pi.ts +6 -2
  30. package/src/adapters/registry-types.ts +174 -0
  31. package/src/adapters/registry.ts +36 -0
  32. package/src/cli/console-state.ts +74 -5
  33. package/src/cli/console.ts +35 -2
  34. package/src/cli/help.ts +3 -1
  35. package/src/cli/identity.ts +13 -3
  36. package/src/cli/launch.ts +1 -6
  37. package/src/cli/main.ts +200 -39
  38. package/src/cli/output.ts +2 -0
  39. package/src/cli/preview.ts +8 -5
  40. package/src/cli/recovery-guidance.ts +137 -0
  41. package/src/cli/recovery-presentation-options.ts +8 -0
  42. package/src/cli/recovery-session-file.ts +18 -0
  43. package/src/cli/reset.ts +13 -1
  44. package/src/cli/terminal-recovery.ts +48 -0
  45. package/src/cli/upgrade-interactive.ts +129 -75
  46. package/src/cli/upgrade-labels.ts +30 -0
  47. package/src/cli/upgrade-presentation-data.ts +41 -0
  48. package/src/cli/upgrade-presentation-flags.ts +31 -0
  49. package/src/cli/upgrade-presentation.ts +227 -0
  50. package/src/cli/upgrade-restore-state.ts +6 -2
  51. package/src/cli/upgrade-runtime.ts +360 -199
  52. package/src/cli/upgrade.ts +278 -25
  53. package/src/hub/bus.ts +144 -14
  54. package/src/hub/cohorts.ts +2 -2
  55. package/src/hub/config-apply.ts +242 -0
  56. package/src/hub/config-reload.ts +120 -0
  57. package/src/hub/config-trust.ts +11 -2
  58. package/src/hub/control-client.ts +1 -1
  59. package/src/hub/crash.ts +8 -6
  60. package/src/hub/daemon.ts +577 -179
  61. package/src/hub/deferred-hub.ts +60 -0
  62. package/src/hub/delivery-journal.ts +6 -0
  63. package/src/hub/events.ts +2 -2
  64. package/src/hub/manager.ts +1 -1
  65. package/src/hub/peer-catalog.ts +10 -13
  66. package/src/hub/peers.ts +12 -2
  67. package/src/hub/permission-mode.ts +5 -3
  68. package/src/hub/recovery-continuity.ts +152 -0
  69. package/src/hub/recovery-store.ts +10 -4
  70. package/src/hub/restart.ts +12 -1
  71. package/src/hub/sdlc.ts +5 -1
  72. package/src/hub/settings.ts +50 -36
  73. package/src/hub/settled-roles.ts +35 -0
  74. package/src/hub/supervision.ts +3 -2
  75. package/src/hub/tasks.ts +29 -9
  76. package/src/hub/ui.ts +1 -1
  77. package/src/memory/brief.ts +6 -5
  78. package/src/pi/session.ts +23 -4
  79. package/src/ui/index.html +23 -7
@@ -0,0 +1,137 @@
1
+ import { recordedFileState } from "./recovery-session-file.ts";
2
+ import { decodeRestoreReceipt } from "./upgrade-restore-state.ts";
3
+ import { shellQuote } from "./terminal-recovery.ts";
4
+ import type { TerminalBinding } from "./terminal-recovery.ts";
5
+ import type { Inspection, PlannedProject, ProjectProgress, RecoveryOperation, RecoveryPeer } from "./upgrade.ts";
6
+
7
+ export type RecoveryNativePeer = "codex" | "pi" | "claude";
8
+ export type RecordedSessionOverride = Pick<TerminalBinding, "sessionId" | "sessionFile">;
9
+ export interface RecordedPeerGuidance {
10
+ peer: RecoveryNativePeer;
11
+ sessionId?: string;
12
+ sessionFile?: string;
13
+ command?: string;
14
+ reattachCommand?: string;
15
+ note: string;
16
+ }
17
+ export interface RecoveryGuidanceOptions {
18
+ /** Person-only blocked guidance; no lifecycle action is taken by this projection. */
19
+ stopAndArchiveCommand?: string;
20
+ /** Caller-validated retained repair bindings, including accepted sessions no longer in a done/waiting receipt. */
21
+ recorded?: Partial<Record<RecoveryNativePeer, RecordedSessionOverride>>;
22
+ /** The caller's fixed formatter for this operation's own coordinator, never model-supplied code. */
23
+ reattachCommand?: (peer: RecoveryNativePeer) => string | undefined;
24
+ }
25
+
26
+ const text = (value: unknown): value is string => typeof value === "string" && value.length > 0;
27
+ const argv = (...words: string[]): string => words.map(shellQuote).join(" ");
28
+ const peers: readonly RecoveryNativePeer[] = ["codex", "pi", "claude"];
29
+
30
+ function sourceSession(peer: RecoveryNativePeer, source: RecoveryPeer | undefined): { sessionId?: string; sessionFile?: string } {
31
+ const sessionId = peer === "codex" ? source?.threadId : source?.sessionId;
32
+ const sessionFile = peer === "pi" ? source?.sessionFile ?? source?.args?.sessionFile : undefined;
33
+ return { ...(text(sessionId) ? { sessionId } : {}), ...(text(sessionFile) ? { sessionFile } : {}) };
34
+ }
35
+
36
+ /** Fixed person commands for the existing Pi archive exit; this helper performs no lifecycle action. */
37
+ export function piArchiveExit(planned: PlannedProject, stopAndArchiveCommand: string): string {
38
+ return `Run ${stopAndArchiveCommand}; after stop-and-archive, run ${argv("ahub", "--project", planned.project.root, "pi")}. A proven missing archived bookmark is retired by the normal Pi start without a fresh fallback in that same call; repeat the normal start as its notice directs.`;
39
+ }
40
+
41
+ /** Accepted identity outlives an incarnation's repair fence. Malformed evidence never means absence. */
42
+ export function resolveAcceptedBinding(planned: PlannedProject, progress: ProjectProgress, peer: RecoveryNativePeer): TerminalBinding | undefined {
43
+ if (progress.id !== planned.project.id) throw new Error("recorded binding project mismatch");
44
+ const binding = (value: unknown): TerminalBinding => {
45
+ const receipt = decodeRestoreReceipt({ state: "done", binding: value });
46
+ if (receipt?.state !== "done" || receipt.binding.peer !== peer || receipt.binding.projectRoot !== planned.project.root) throw new Error("recorded binding does not match its peer and project");
47
+ return receipt.binding;
48
+ };
49
+ const restored = decodeRestoreReceipt(progress.terminals[`restored:${peer}`]);
50
+ if (restored?.state === "done" || restored?.state === "waiting") return binding(restored.binding);
51
+ if (progress.reattachments !== undefined) {
52
+ if (!progress.reattachments || typeof progress.reattachments !== "object" || Array.isArray(progress.reattachments)) throw new Error("invalid recorded reattachment markers");
53
+ if (Object.hasOwn(progress.reattachments, peer)) {
54
+ const marker = progress.reattachments[peer];
55
+ if (!marker || typeof marker !== "object" || Array.isArray(marker) || Object.keys(marker).some(key => !["state", "at", "cause", "binding"].includes(key)) ||
56
+ !["ready", "attempted"].includes(marker.state) || marker.cause !== "detached after restore" || !Number.isFinite(marker.at) || marker.at < 0) throw new Error("invalid recorded reattachment marker");
57
+ return binding(marker.binding);
58
+ }
59
+ }
60
+ const retiredKey = `retired:${peer}`;
61
+ if (Object.hasOwn(progress.terminals, retiredKey)) return binding(progress.terminals[retiredKey]);
62
+ const original = planned.terminals.filter(value => !!value && typeof value === "object" && (value as { peer?: unknown }).peer === peer);
63
+ if (original.length > 1) throw new Error("ambiguous planned recorded bindings");
64
+ return original.length ? binding(original[0]) : undefined;
65
+ }
66
+
67
+ /**
68
+ * Person-only projection of recorded identities, never a peer/tool result or evidence of live attachment.
69
+ * A supplied override must already be validated by the recovery coordinator. It deliberately wins over both
70
+ * current receipts and the original plan. A malformed current receipt otherwise stays unavailable.
71
+ */
72
+ export function recordedPeerGuidance(planned: PlannedProject, progress: ProjectProgress, options: RecoveryGuidanceOptions = {}): RecordedPeerGuidance[] {
73
+ return peers.map(peer => {
74
+ let session: { sessionId?: string; sessionFile?: string } | undefined;
75
+ let unavailable = false;
76
+ const override = options.recorded && Object.hasOwn(options.recorded, peer) ? options.recorded[peer] : undefined;
77
+ if (override !== undefined) session = override;
78
+ else {
79
+ try {
80
+ session = resolveAcceptedBinding(planned, progress, peer)
81
+ ?? sourceSession(peer, planned.source.peers.find(row => row.id === peer));
82
+ } catch { unavailable = true; }
83
+ }
84
+ const sessionId = text(session?.sessionId) ? session.sessionId : undefined;
85
+ const sessionFile = peer === "pi" && text(session?.sessionFile) ? session.sessionFile : undefined;
86
+ if (unavailable || (!sessionId && !sessionFile)) return { peer, note: `${peer}: recorded session identity unavailable; inspect the retained recovery receipt before choosing a conversation` };
87
+ const prefix = ["ahub", "--project", planned.project.root];
88
+ const command = peer === "codex"
89
+ ? sessionId ? argv(...prefix, "codex", "resume", sessionId) : undefined
90
+ : peer === "pi"
91
+ ? sessionFile ? argv(...prefix, "pi", "--session-file", sessionFile) : sessionId ? argv(...prefix, "pi", "--session-id", sessionId) : undefined
92
+ : sessionId ? argv(...prefix, "claude", "--resume", sessionId) : undefined;
93
+ let note = peer === "claude"
94
+ ? "The existing Claude channel may reconnect while its native session remains open. Transcript availability is unknown; the recorded resume command is guidance, not proof that the transcript can be resumed."
95
+ : peer === "codex" ? "Recorded Codex thread; leave the old native client before resuming this conversation."
96
+ : "Recorded Pi conversation; preserve this session when starting Pi again.";
97
+ if (sessionFile && options.stopAndArchiveCommand && recordedFileState(sessionFile) === "missing") {
98
+ note = `Recorded Pi session file is positively missing. Fresh-session is not supported for Pi. ${piArchiveExit(planned, options.stopAndArchiveCommand)}`;
99
+ }
100
+ const reattachCommand = command ? options.reattachCommand?.(peer) : undefined;
101
+ return { peer, ...(sessionId ? { sessionId } : {}), ...(sessionFile ? { sessionFile } : {}), ...(command ? { command } : {}), ...(reattachCommand ? { reattachCommand } : {}), note };
102
+ });
103
+ }
104
+
105
+ /** Historical receipt facts only. A stopped/archived target is never described as still running. */
106
+ export function stopAndArchiveSummary(op: RecoveryOperation, live: Readonly<Record<string, Inspection | undefined>> = {}): string[] {
107
+ const lines = [op.disposition
108
+ ? `${op.plan.kind} abandoned, not completed; committed transitions were not rolled back.`
109
+ : "Stop-and-archive is not recorded; no abandonment outcome is claimed."];
110
+ for (const progress of op.projects) {
111
+ if (["started", "peers-restored", "verified"].includes(progress.phase) || progress.restarts?.length) lines.push(`${progress.id}: target ${op.plan.version} had started; this is a recorded historical step.`);
112
+ const outcome = op.disposition?.projects[progress.id];
113
+ if (outcome) lines.push(`${progress.id}: ${outcome}`);
114
+ const continuity = op.disposition?.continuity?.[progress.id];
115
+ if (continuity === "not-archived") lines.push(`${progress.id}: no Pi continuity bookmark because the snapshot was not archived by this operation; use the manual recorded-session commands below.`);
116
+ else if (continuity === "unavailable") lines.push(`${progress.id}: Pi continuity bookmark unavailable; automatic session continuity is unconfirmed, so use the manual recorded-session commands below.`);
117
+ const observed = live[progress.id];
118
+ if (observed) lines.push(`${progress.id}: supplied inspection reported ${observed.state}${observed.version ? ` on ${observed.version}` : ""}; it does not change the recorded disposition.`);
119
+ const planned = op.plan.projects.find(project => project.project.id === progress.id);
120
+ if (planned) for (const row of recordedPeerGuidance(planned, progress)) {
121
+ if (row.command) lines.push(`${progress.id} ${row.peer}: ${row.command}`);
122
+ lines.push(`${progress.id} ${row.peer}: ${row.note}`);
123
+ }
124
+ }
125
+ if (op.freshOverride) {
126
+ const { peer, evidence, checked, at } = op.freshOverride;
127
+ lines.push(`Explicit fresh override: ${peer} in ${evidence.projectId}; accepted session ${shellQuote(evidence.sessionId)}, ${evidence.kind}${evidence.checkedPath ? ` at ${shellQuote(evidence.checkedPath)}` : ""} checked ${checked} at ${at}; missing-history replacement was person-authorized.`);
128
+ }
129
+ lines.push(op.pluginInstalled ? "Claude plugin installation recorded." : "Claude plugin installation not recorded.");
130
+ if (op.globalInstalled) lines.push("global CLI installation recorded.");
131
+ else if (op.plan.kind === "upgrade") {
132
+ lines.push("global CLI installation remains; it was not recorded as completed.");
133
+ const exact = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(op.plan.version);
134
+ lines.push(exact ? argv("bun", "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts") : "Exact target version unavailable; review the retained receipt before installing the global CLI.");
135
+ } else lines.push("global CLI installation not recorded; a restart does not install a new CLI.");
136
+ return lines;
137
+ }
@@ -0,0 +1,8 @@
1
+ /** Pure shared recognition; the console still validates colour values and precedence. */
2
+ export function recoveryPresentationFlag(arg: string): { kind: "json" | "verbose" } | { kind: "color"; value: string } | undefined {
3
+ if (arg === "--json") return { kind: "json" };
4
+ if (arg === "--verbose") return { kind: "verbose" };
5
+ const prefix = "--color=";
6
+ if (arg.startsWith(prefix)) return { kind: "color", value: arg.slice(prefix.length) };
7
+ return undefined;
8
+ }
@@ -0,0 +1,18 @@
1
+ import { statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { TerminalBinding } from "./terminal-recovery.ts";
5
+
6
+ /** The exact transcript file, or the exact sessions directory recursively searched for a rollout. */
7
+ export function checkedSessionPath(binding: TerminalBinding, kind: "rollout" | "transcript"): string {
8
+ if (kind === "rollout") return join(binding.launch.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
9
+ const config = binding.launch.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
10
+ const slug = binding.projectRoot.replace(/[^a-zA-Z0-9]/g, "-");
11
+ return join(config, "projects", slug, `${binding.sessionId}.jsonl`);
12
+ }
13
+
14
+ /** Only an absent path is positive missing evidence. Unreadable or non-file evidence stays unknown. */
15
+ export function recordedFileState(file: string): "found" | "missing" | "unknown" {
16
+ try { return statSync(file).isFile() ? "found" : "unknown"; }
17
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unknown"; }
18
+ }
package/src/cli/reset.ts CHANGED
@@ -4,6 +4,7 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
4
4
  import { join } from "node:path";
5
5
  import { Bus } from "../hub/bus.ts";
6
6
  import { DeliveryJournal } from "../hub/delivery-journal.ts";
7
+ import { validateDeferredHub } from "../hub/deferred-hub.ts";
7
8
  import type { Project } from "../hub/registry.ts";
8
9
  import { HUB_DB_BUSY_TIMEOUT_MS } from "../hub/db-timeout.ts";
9
10
 
@@ -73,7 +74,14 @@ export function planReset(stateDir: string, projectId: string): ResetPlan {
73
74
  const bus: unknown = JSON.parse(meta?.bus_snapshot || "{}"), manual: unknown = JSON.parse(meta?.manual_paused || "[]");
74
75
  shaped(isList(manual, isText), "manual_paused");
75
76
  shaped(isObject(bus) && (bus.manualPaused === undefined || isList(bus.manualPaused, isText)) && (bus.queues === undefined || (isObject(bus.queues) && Object.values(bus.queues).every((queue) => isList(queue, isEnvelope)))), "bus_snapshot");
76
- const { queues = {}, manualPaused = [] } = bus as { queues?: Record<string, { id: string }[]>; manualPaused?: string[] };
77
+ const { queues = {}, manualPaused = [], seen = [], deferredHub } = bus as {
78
+ queues?: Record<string, { id: string }[]>; manualPaused?: string[]; seen?: { id: string }[]; deferredHub?: unknown;
79
+ };
80
+ shaped(isList(seen, isEnvelope), "bus_snapshot.seen");
81
+ // Deferred notices are pending delivery too, but must not be published just to preview or reset them.
82
+ const deferred = validateDeferredHub(deferredHub, undefined, new Set([
83
+ ...seen.map(env => env.id), ...Object.values(queues).flat().map(env => env.id),
84
+ ]));
77
85
  plan.manualHolds = [...new Set([...manual as string[], ...manualPaused])].sort();
78
86
  // As Bus.queueList: a queued envelope that no open row stands for is listed as q:<peer>:<envelope id>.
79
87
  // ponytail: the rule is copied so the dry run never opens the journal for writing; the reset tests compare the
@@ -84,6 +92,9 @@ export function planReset(stateDir: string, projectId: string): ResetPlan {
84
92
  return (originals as { id: string }[]).map((env) => `${row.peer}:${env.id}`);
85
93
  }));
86
94
  for (const [peer, queue] of Object.entries(queues)) for (const env of queue) if (!recorded.has(`${peer}:${env.id}`)) plan.queued.push(`q:${peer}:${env.id}`);
95
+ for (const env of deferred) for (const peer of new Set(env.to)) {
96
+ if (!recorded.has(`${peer}:${env.id}`)) plan.queued.push(`q:${peer}:${env.id}`);
97
+ }
87
98
  } finally { db.close(); }
88
99
  return plan;
89
100
  }
@@ -114,6 +125,7 @@ export function resetRuntime(stateDir: string, project: Project): { settled: str
114
125
  const journal = new DeliveryJournal({ file, projectRoot: project.root, projectId: project.id, instanceId: randomUUID() });
115
126
  const bus = new Bus({ journal });
116
127
  try {
128
+ settled.push(...bus.discardDeferredHub("reset"));
117
129
  for (const { id } of bus.queueList().filter((row) => row.state === "queued" || row.state === "needs_review")) {
118
130
  // Read again each time: a queue entry's revision is the journal's, and every resolution moves it on.
119
131
  bus.resolveDelivery(id, bus.queueShow(id)!.revision, "discard", "reset");
@@ -76,6 +76,12 @@ export interface RecoveryBlocker {
76
76
  nextAction?: string;
77
77
  }
78
78
 
79
+ /** Read-only evidence for one already recorded terminal, never a session-selection result. */
80
+ export interface SavedTerminalInspection {
81
+ state: "present" | "orphaned" | "missing" | "changed" | "unknown";
82
+ blocker?: RecoveryBlocker;
83
+ }
84
+
79
85
  export interface TerminalInspection {
80
86
  bindings: TerminalBinding[];
81
87
  /** A map is convenient for callers recovering both peers at once. */
@@ -539,6 +545,48 @@ async function showBinding(binding: TerminalBinding, options: Required<TerminalR
539
545
  return actual;
540
546
  }
541
547
 
548
+ /**
549
+ * Judge only the saved terminal identity. The caller separately fences the peer's current/restarted launchers.
550
+ * A failed read is unknown; only a typed stale handle plus a complete inventory can prove absence.
551
+ */
552
+ export async function inspectSavedTerminal(binding: TerminalBinding, options?: CommandRunner | TerminalRecoveryOptions): Promise<SavedTerminalInspection> {
553
+ const config = normalizeOptions(options);
554
+ const unknown = (): SavedTerminalInspection => ({ state: "unknown", blocker: blocker("command-error", "saved terminal evidence is unavailable or inconsistent", binding.peer, binding.handle) });
555
+ const completeIdentity = (value: Record<string, unknown>) =>
556
+ [nestedString(value, ["handle"]), nestedString(value, ["incarnationId"]), nestedString(value, ["worktreeId"]), nestedString(value, ["worktreePath", "projectRoot"])].every(Boolean);
557
+ try {
558
+ const shown = terminalObject(await run(config.runner, ["terminal", "show", "--terminal", binding.handle, "--json"]));
559
+ if (!completeIdentity(shown)) return unknown();
560
+ const issue = verifyBindingData(shown, binding);
561
+ if (issue) return { state: "changed", blocker: issue };
562
+ if (shown.orphaned === true) return { state: "orphaned", blocker: blocker("terminal-gone", "the saved terminal is orphaned", binding.peer, binding.handle) };
563
+ if (shown.connected === false) return unknown();
564
+ return { state: "present" };
565
+ } catch (error) {
566
+ if (!(error instanceof OrcaCommandError) || error.blocker.code !== "terminal-gone") return unknown();
567
+ }
568
+ try {
569
+ const result = responseResult(await run(config.runner, ["terminal", "list", "--json"]));
570
+ const rows = result.terminals ?? result.items;
571
+ if (result.truncated === true || !Array.isArray(rows)) return unknown();
572
+ const handles = new Set<string>(), incarnations = new Set<string>();
573
+ for (const raw of rows) {
574
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return unknown();
575
+ const row = raw as Record<string, unknown>;
576
+ if (!completeIdentity(row)) return unknown();
577
+ const handle = nestedString(row, ["handle"])!, incarnation = nestedString(row, ["incarnationId"])!;
578
+ if (handles.has(handle) || incarnations.has(incarnation)) return unknown();
579
+ handles.add(handle); incarnations.add(incarnation);
580
+ }
581
+ const retained = (rows as Record<string, unknown>[]).find(row => row.handle === binding.handle || row.incarnationId === binding.incarnationId);
582
+ if (retained) {
583
+ const issue = verifyBindingData(retained, binding);
584
+ return issue ? { state: "changed", blocker: issue } : unknown();
585
+ }
586
+ return { state: "missing", blocker: blocker("terminal-gone", "the saved terminal handle and incarnation are absent from the complete inventory", binding.peer, binding.handle) };
587
+ } catch { return unknown(); }
588
+ }
589
+
542
590
  /**
543
591
  * Read Orca's authoritative terminal inventory and bind only exact agent/session identities.
544
592
  * Titles, previews, pty ids and process names are deliberately never used as identity.