@sentropic/h2a-runtime 0.91.1 → 0.93.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.
Files changed (108) hide show
  1. package/dist/config.d.ts +2 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +16 -3
  4. package/dist/config.js.map +1 -1
  5. package/dist/conv-guard.d.ts +6 -1
  6. package/dist/conv-guard.d.ts.map +1 -1
  7. package/dist/conv-guard.js +33 -8
  8. package/dist/conv-guard.js.map +1 -1
  9. package/dist/enroll.d.ts.map +1 -1
  10. package/dist/enroll.js +12 -2
  11. package/dist/enroll.js.map +1 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +819 -155
  15. package/dist/index.js.map +1 -1
  16. package/dist/launch-layout.d.ts +74 -0
  17. package/dist/launch-layout.d.ts.map +1 -0
  18. package/dist/launch-layout.js +324 -0
  19. package/dist/launch-layout.js.map +1 -0
  20. package/dist/llm-gateway-runtime/index.d.ts +6 -0
  21. package/dist/llm-gateway-runtime/index.d.ts.map +1 -1
  22. package/dist/llm-gateway-runtime/index.js +134 -60
  23. package/dist/llm-gateway-runtime/index.js.map +1 -1
  24. package/dist/llm-gateway-runtime/session-ledger.d.ts +38 -55
  25. package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -1
  26. package/dist/llm-gateway-runtime/session-ledger.js +102 -250
  27. package/dist/llm-gateway-runtime/session-ledger.js.map +1 -1
  28. package/dist/llm-gateway-runtime/sticky.d.ts +25 -52
  29. package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -1
  30. package/dist/llm-gateway-runtime/sticky.js +36 -605
  31. package/dist/llm-gateway-runtime/sticky.js.map +1 -1
  32. package/dist/llm-mesh.d.ts +19 -72
  33. package/dist/llm-mesh.d.ts.map +1 -1
  34. package/dist/llm-mesh.js +42 -399
  35. package/dist/llm-mesh.js.map +1 -1
  36. package/dist/llm-routing-config.d.ts +28 -0
  37. package/dist/llm-routing-config.d.ts.map +1 -0
  38. package/dist/llm-routing-config.js +100 -0
  39. package/dist/llm-routing-config.js.map +1 -0
  40. package/dist/native-host.d.ts +146 -0
  41. package/dist/native-host.d.ts.map +1 -0
  42. package/dist/native-host.js +456 -0
  43. package/dist/native-host.js.map +1 -0
  44. package/dist/native-terminal/host.d.ts +317 -1
  45. package/dist/native-terminal/host.d.ts.map +1 -1
  46. package/dist/native-terminal/host.js +696 -3
  47. package/dist/native-terminal/host.js.map +1 -1
  48. package/dist/native-terminal/op.d.ts +5 -0
  49. package/dist/native-terminal/op.d.ts.map +1 -0
  50. package/dist/native-terminal/op.js +408 -0
  51. package/dist/native-terminal/op.js.map +1 -0
  52. package/dist/native-terminal/process.d.ts +2 -0
  53. package/dist/native-terminal/process.d.ts.map +1 -1
  54. package/dist/native-terminal/process.js +16 -2
  55. package/dist/native-terminal/process.js.map +1 -1
  56. package/dist/native-terminal/server.d.ts.map +1 -1
  57. package/dist/native-terminal/server.js +6 -2
  58. package/dist/native-terminal/server.js.map +1 -1
  59. package/dist/native-terminal/socket-path.d.ts +8 -0
  60. package/dist/native-terminal/socket-path.d.ts.map +1 -1
  61. package/dist/native-terminal/socket-path.js +31 -1
  62. package/dist/native-terminal/socket-path.js.map +1 -1
  63. package/dist/native-terminal/supervisor.d.ts +13 -0
  64. package/dist/native-terminal/supervisor.d.ts.map +1 -1
  65. package/dist/native-terminal/supervisor.js +45 -0
  66. package/dist/native-terminal/supervisor.js.map +1 -1
  67. package/dist/pty.d.ts +23 -0
  68. package/dist/pty.d.ts.map +1 -1
  69. package/dist/pty.js +35 -0
  70. package/dist/pty.js.map +1 -1
  71. package/dist/readiness.d.ts +7 -0
  72. package/dist/readiness.d.ts.map +1 -1
  73. package/dist/readiness.js +12 -9
  74. package/dist/readiness.js.map +1 -1
  75. package/dist/registry.d.ts +324 -2
  76. package/dist/registry.d.ts.map +1 -1
  77. package/dist/registry.js +659 -20
  78. package/dist/registry.js.map +1 -1
  79. package/dist/restore.d.ts +91 -32
  80. package/dist/restore.d.ts.map +1 -1
  81. package/dist/restore.js +226 -186
  82. package/dist/restore.js.map +1 -1
  83. package/dist/status-projection.d.ts.map +1 -1
  84. package/dist/tmux.d.ts +9 -6
  85. package/dist/tmux.d.ts.map +1 -1
  86. package/dist/tmux.js +34 -8
  87. package/dist/tmux.js.map +1 -1
  88. package/package.json +3 -3
  89. package/dist/llm-gateway-runtime/accounts.d.ts +0 -62
  90. package/dist/llm-gateway-runtime/accounts.d.ts.map +0 -1
  91. package/dist/llm-gateway-runtime/accounts.js +0 -323
  92. package/dist/llm-gateway-runtime/accounts.js.map +0 -1
  93. package/dist/llm-gateway-runtime/model-catalog.d.ts +0 -44
  94. package/dist/llm-gateway-runtime/model-catalog.d.ts.map +0 -1
  95. package/dist/llm-gateway-runtime/model-catalog.js +0 -191
  96. package/dist/llm-gateway-runtime/model-catalog.js.map +0 -1
  97. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +0 -3
  98. package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +0 -1
  99. package/dist/llm-gateway-runtime/proxy-anthropic.js +0 -318
  100. package/dist/llm-gateway-runtime/proxy-anthropic.js.map +0 -1
  101. package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts +0 -11
  102. package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts.map +0 -1
  103. package/dist/llm-gateway-runtime/proxy-cloud-code.js +0 -215
  104. package/dist/llm-gateway-runtime/proxy-cloud-code.js.map +0 -1
  105. package/dist/llm-gateway-runtime/proxy-openai.d.ts +0 -118
  106. package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +0 -1
  107. package/dist/llm-gateway-runtime/proxy-openai.js +0 -1305
  108. package/dist/llm-gateway-runtime/proxy-openai.js.map +0 -1
package/dist/registry.js CHANGED
@@ -18,7 +18,12 @@ import { uptime } from "node:os";
18
18
  import { dirname, join } from "node:path";
19
19
  import { getLayoutConfig, resolveConfigPath } from "./config.js";
20
20
  import { acquireFileLock, releaseFileLock } from "./file-lock.js";
21
- import { managedSessionCandidates, parseManagedSessionName, listLocalSessions, } from "./tmux.js";
21
+ import { localSessionName, managedSessionCandidates, parseManagedSessionName, listLocalSessions, tmuxAvailable, } from "./tmux.js";
22
+ import { nativeSessionLiveness } from "./native-host.js";
23
+ /** Managed local interactive session hosted by tmux OR the native PTY host. */
24
+ export function isManagedLocalKind(kind) {
25
+ return kind === "local-tmux" || kind === "local-native";
26
+ }
22
27
  /** System boot time in ms epoch (now minus uptime). */
23
28
  function defaultBootTimeMs() {
24
29
  return Date.now() - uptime() * 1000;
@@ -38,19 +43,204 @@ const CONVERSATION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[
38
43
  export function looksLikeConversationUuid(value) {
39
44
  return value !== undefined && CONVERSATION_UUID_RE.test(value);
40
45
  }
46
+ function isRestorePinnedCandidate(entry) {
47
+ return (entry.kind === "local-tmux" &&
48
+ entry.source === "run" &&
49
+ entry.sessionClass === "human" &&
50
+ !entry.endedAt &&
51
+ looksLikeConversationUuid(entry.convId));
52
+ }
53
+ function shouldPreserveByRestorePin(entry) {
54
+ if (entry.restorePinned === false)
55
+ return false;
56
+ if (isRestorePinnedCandidate(entry))
57
+ return true;
58
+ // Invariant: restore pin never preserves positively non-human rows (no implicit pin by absence).
59
+ // As of 2026-08-08 this affects 0 lines — the 29 role=job rows with absent sessionClass are all kind=remote, already outside the perimeter by the kind/source gates; role==='job' is added by SYMMETRY with sessionClass==='background' so a positively non-human row is never pinned even when classless.
60
+ if (entry.endedAt || entry.sessionClass === "background" || entry.role === "job")
61
+ return false;
62
+ return (entry.kind === "local-tmux" &&
63
+ entry.source === "run" &&
64
+ // Invariant: "unknown protects" inside the local-tmux/run restore-pin perimeter.
65
+ // Restore-pin covers durable human local-tmux/run rows, while remote sessions
66
+ // are deliberately out of the restore-pin perimeter.
67
+ // As of 2026-08-08, the architect measured 29 sessionClass-absent lines; all were kind=remote, so this guard currently has no observed effect.
68
+ (entry.sessionClass === "human" || entry.sessionClass === undefined));
69
+ }
70
+ // Returns the 3-state RegistryReadResult above
71
+ // ({state:"ok",entries,unreadable} | {state:"unknown",reason}), NOT a plain
72
+ // array — a bare `.map()`/`.find()`/`.some()`/`.every()` on the return value
73
+ // is a bug. tsc does NOT type-check `.js` files, so a signature change here
74
+ // is invisible to JS callers (e.g. packages/h2a/test/*.js) even though every
75
+ // `.ts` caller gets a compile error. Anyone widening/changing this return
76
+ // type MUST grep repo-wide for BOTH extensions before trusting the compiler
77
+ // to be exhaustive:
78
+ // git grep -nE 'loadRegistry\b' -- '**/*.js' '**/*.ts' | grep -v loadRegistryWithDiagnostics
41
79
  export function loadRegistry(path = resolveRegistryPath()) {
80
+ const raw = rawRegistryRead(path);
81
+ if (raw.state === "unknown")
82
+ return raw;
83
+ // B2 — WHOLE-FILE trace, same logic as the per-identity `unreadable` check
84
+ // applied to the WHOLE file: this file was REBUILT from a registry the
85
+ // write path could not read (rawRegistryEntriesForWrite moved the
86
+ // unreadable bytes aside to a sibling registry.corrupt-*.json and rebuilt
87
+ // from `[]`). Rows that lived in the corrupt bytes are NOT provably absent
88
+ // from THIS read — a destructive caller must never treat "missing here"
89
+ // as "no local session existed" while this trace is present. Every reader
90
+ // (not just this one) must see "unknown", so this is checked on the READ
91
+ // path (loadRegistry), never on rawRegistryRead itself — the write path
92
+ // (rawRegistryEntriesForWrite) deliberately ignores this field so a
93
+ // rebuilt-but-now-valid file keeps accepting new enrolments (REBUILDING is
94
+ // allowed); only the "was something silently lost?" READ verdict is
95
+ // poisoned, and only until the next successful write clears the trace.
96
+ if (raw.rebuiltFromCorruptAt !== undefined) {
97
+ return {
98
+ state: "unknown",
99
+ reason: `registry was rebuilt from an unreadable file at ${raw.rebuiltFromCorruptAt} ` +
100
+ `(see registry.corrupt-*.json next to the registry) — rows from before the ` +
101
+ `rebuild are not provably absent`,
102
+ };
103
+ }
104
+ const entries = [];
105
+ const unreadable = [];
106
+ for (const row of raw.rows) {
107
+ if (isRegistryEntry(row)) {
108
+ entries.push(row);
109
+ }
110
+ else if (row && typeof row === "object") {
111
+ // Counter-mutant guard (PART-A producer): a row that fails
112
+ // `isRegistryEntry` is TRANSPORTED, never dropped — dropping it here
113
+ // is exactly the per-row lie this type exists to remove.
114
+ unreadable.push(row);
115
+ }
116
+ // A non-object row (string/number/null/etc.) cannot address any target
117
+ // by identity (no id/label/tmuxSession to match against) — it is
118
+ // discarded rather than carried as a formless "unreadable" row.
119
+ }
120
+ return { state: "ok", entries, unreadable };
121
+ }
122
+ /** Raw `entries` array of the registry FILE, with the read state preserved. */
123
+ function rawRegistryRead(path) {
124
+ let text;
42
125
  try {
43
- const parsed = JSON.parse(readFileSync(path, "utf8"));
126
+ text = readFileSync(path, "utf8");
127
+ }
128
+ catch (error) {
129
+ // An ABSENT file is provable emptiness (rows cannot exist without a
130
+ // file); every other read failure (EACCES, EIO, …) is UNKNOWN.
131
+ if (error.code === "ENOENT") {
132
+ return { state: "ok", rows: [] };
133
+ }
134
+ return {
135
+ state: "unknown",
136
+ reason: `registry file is unreadable (${error.code ?? "read error"})`,
137
+ };
138
+ }
139
+ try {
140
+ const parsed = JSON.parse(text);
44
141
  const entries = parsed?.entries;
45
- if (!Array.isArray(entries))
46
- return [];
47
- return entries.filter(isRegistryEntry);
142
+ if (!Array.isArray(entries)) {
143
+ return { state: "unknown", reason: "registry has no entries array" };
144
+ }
145
+ const rebuiltFromCorruptAt = parsed
146
+ ?.rebuiltFromCorruptAt;
147
+ return {
148
+ state: "ok",
149
+ rows: entries,
150
+ ...(typeof rebuiltFromCorruptAt === "string" ? { rebuiltFromCorruptAt } : {}),
151
+ };
48
152
  }
49
153
  catch {
50
- // missing or corrupt file -> empty registry (it is rebuilt by enrolment)
51
- return [];
154
+ return { state: "unknown", reason: "registry is corrupt (not valid JSON)" };
52
155
  }
53
156
  }
157
+ /**
158
+ * Move the unreadable bytes at `path` aside VERBATIM to a sibling
159
+ * `registry.corrupt-<ts>-<pid>.json` (best-effort: a truly unreadable file,
160
+ * e.g. EACCES, has nothing left to move — the rebuild still proceeds and is
161
+ * still flagged by the caller). B2 — "REBUILDING is allowed. DESTROYING is
162
+ * not.": called by `withRegistryLock` ONLY immediately before a write that
163
+ * is actually about to REBUILD the file from `[]`, never on a mere read or a
164
+ * `save:false` no-op (e.g. `prune` finding nothing to change on a corrupt
165
+ * file) — otherwise a corrupt registry nothing ever mutates would spam a
166
+ * fresh sibling file on every poll (`remote ls` calls `prune` every time).
167
+ * Returns the rebuild timestamp to stamp on the save (the WHOLE-FILE trace
168
+ * `loadRegistry` turns into "unknown" for every destructive reader).
169
+ */
170
+ function moveAsideUnreadableRegistry(path) {
171
+ const at = new Date().toISOString();
172
+ try {
173
+ const original = readFileSync(path, "utf8");
174
+ const corruptPath = join(dirname(path), `registry.corrupt-${at.replace(/[:.]/g, "-")}-${process.pid}.json`);
175
+ writeFileSync(corruptPath, original, "utf8");
176
+ }
177
+ catch {
178
+ // Nothing readable to move aside (EACCES/EIO/ENOENT/…) — the rebuild
179
+ // still proceeds and is still flagged so destructive readers stay
180
+ // fail-closed.
181
+ }
182
+ return at;
183
+ }
184
+ /**
185
+ * Pure identity match: does raw row `raw` (already known to have FAILED
186
+ * `isRegistryEntry`) plausibly address `target` under the SAME identity rules
187
+ * used to match a VALID row (id / label / exact tmuxSession / managed-name
188
+ * candidates)? Shared by `unreadableRowsForTarget` (A2's per-identity poison
189
+ * check inside `resolveManagedHost`) and the legacy path-based helper below.
190
+ * Defensive: an unreadable row is read WITHOUT presuming a well-formed shape.
191
+ */
192
+ function rawRowMatchesTarget(target, raw) {
193
+ const requested = parseManagedSessionName(target);
194
+ const candidates = requested ? [target] : managedSessionCandidates(target);
195
+ const id = typeof raw.id === "string" ? raw.id : undefined;
196
+ const label = typeof raw.label === "string" ? raw.label : undefined;
197
+ const tmuxSession = typeof raw.tmuxSession === "string" ? raw.tmuxSession : undefined;
198
+ if (requested) {
199
+ return (tmuxSession === target ||
200
+ (tmuxSession === undefined && id === requested.slug));
201
+ }
202
+ return (id === target ||
203
+ label === target ||
204
+ (tmuxSession !== undefined && candidates.includes(tmuxSession)));
205
+ }
206
+ /**
207
+ * Unreadable rows (from an ALREADY-READ `loadRegistry()` snapshot) that
208
+ * plausibly address `target` under the resolver's identity rules. Pure — no
209
+ * fs — so every caller consults the SAME snapshot instead of re-reading the
210
+ * file (this is what `resolveManagedHost` calls for its per-identity poison
211
+ * check, A2). THE RULE: absence must be provable BY IDENTITY — an unknown by
212
+ * identity for X, never a global unknown that disables every OTHER identity.
213
+ */
214
+ export function unreadableRowsForTarget(target, unreadable) {
215
+ return unreadable.filter((raw) => rawRowMatchesTarget(target, raw));
216
+ }
217
+ /**
218
+ * Path-based convenience wrapper: performs its OWN registry read (a second
219
+ * read when the caller already has one — prefer `unreadableRowsForTarget`
220
+ * with an existing `loadRegistry()` snapshot when one is available, e.g.
221
+ * inside `resolveManagedHost`). Raw rows in the registry FILE that FAIL
222
+ * validation (e.g. `kind` absent or unreadable) but plausibly address
223
+ * `target`. `loadRegistry()` cannot return them as `entries` — but a
224
+ * DESTRUCTIVE caller must know the identity exists in an unreadable state:
225
+ * such a row makes local host state UNKNOWN (fail closed, sol-2), never "no
226
+ * local session" — which would let the act fall through to a REMOTE homonym.
227
+ *
228
+ * B3 — this is itself a RE-READ on a destructive path (a TOCTOU window: the
229
+ * caller's own earlier read may have succeeded, then the registry became
230
+ * unreadable strictly BETWEEN the two reads — another writer, a partial
231
+ * write, disk pressure). A re-read inherits the SAME 3-state contract as the
232
+ * first: a whole-file "unknown" here used to flatten to `[]` — "no
233
+ * unreadable row for this identity" — which is indistinguishable from "the
234
+ * row is fine" and let the caller fall through past this guard. Returns
235
+ * `"unknown"` instead so the caller refuses exactly like any other unknown
236
+ * registry read, never silently re-simplified into an empty collection.
237
+ */
238
+ export function unreadableRegistryRowsForTarget(target, path = resolveRegistryPath()) {
239
+ const read = loadRegistry(path);
240
+ if (read.state === "unknown")
241
+ return "unknown";
242
+ return unreadableRowsForTarget(target, read.unreadable);
243
+ }
54
244
  /**
55
245
  * Read the registry without collapsing a missing/corrupt file into an empty
56
246
  * collection. Operational commands may rebuild their registry; the status
@@ -77,13 +267,162 @@ export function loadRegistryWithDiagnostics(path = resolveRegistryPath()) {
77
267
  };
78
268
  }
79
269
  }
270
+ // ---------------------------------------------------------------------------
271
+ // Native-terminal PTY pgid persistence.
272
+ //
273
+ // Durable session identity (incl. pgid) lives in the single registry store BY
274
+ // DESIGN — h2a keeps ONE durable local store (the owner's SQLite direction);
275
+ // a separate native-terminal store would be exactly the heterogeneity being
276
+ // eliminated. This is NOT a layer leak from native-terminal/host.ts into the
277
+ // registry; do not remove this coupling. The pgid is stored on a real
278
+ // `RegistryEntry` row (same file, same lock, same atomic write as every other
279
+ // registry mutation) keyed by a namespaced id so it can never collide with a
280
+ // real claude/codex/agy session row. `tool`/`source`/`cwd` on that row are
281
+ // syntactically-required placeholders (RegistryTool/RegistrySource are closed
282
+ // enums built around CLI sessions, not arbitrary PTY commands) — the only
283
+ // consumer of this row is `readNativeTerminalPgid` below, which reads `.pgid`
284
+ // and ignores the rest. `kind: "local"` keeps the row OUT of `remote ls`
285
+ // (`localLsRows` only surfaces `kind: "local-tmux"`) and out of delegate's
286
+ // concurrency accounting (`role` is left unset, so `tryClaimSlot`/`listJobs`
287
+ // never see it).
288
+ // ---------------------------------------------------------------------------
289
+ const NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX = "native-terminal-pty:";
290
+ function nativeTerminalPgidEntryId(sessionId) {
291
+ return `${NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX}${sessionId}`;
292
+ }
293
+ export function persistNativeTerminalPgid(sessionId, pgid, path = resolveRegistryPath(), owner, leaderStartTime, groupToken) {
294
+ if (!Number.isSafeInteger(pgid) || pgid <= 0) {
295
+ throw new RangeError("pgid must be a positive safe integer");
296
+ }
297
+ const id = nativeTerminalPgidEntryId(sessionId);
298
+ withRegistryLock(path, (entries) => {
299
+ const now = new Date().toISOString();
300
+ const idx = entries.findIndex((e) => e.id === id);
301
+ const entry = {
302
+ id,
303
+ tool: "claude",
304
+ kind: "local",
305
+ cwd: "",
306
+ source: "scan",
307
+ enrolledAt: idx >= 0 ? entries[idx].enrolledAt : now,
308
+ lastSeenAt: now,
309
+ pgid,
310
+ ...(leaderStartTime !== undefined ? { pgidLeaderStartTime: leaderStartTime } : {}),
311
+ ...(groupToken !== undefined ? { pgidGroupToken: groupToken } : {}),
312
+ ...(owner !== undefined ? { ownerHostPid: owner.pid } : {}),
313
+ ...(owner?.startTime !== undefined ? { ownerHostStartTime: owner.startTime } : {}),
314
+ };
315
+ const next = idx >= 0
316
+ ? entries.map((e, i) => (i === idx ? entry : e))
317
+ : [...entries, entry];
318
+ return { entries: next, result: undefined };
319
+ });
320
+ }
321
+ /**
322
+ * Resolve a native-terminal PTY session's durable pgid, for a reap that may
323
+ * come from a host that never knew the session (e.g. a fresh host started
324
+ * after the owning host was killed).
325
+ *
326
+ * Reads via `loadRegistryWithDiagnostics`, NEVER via the plain `loadRegistry`
327
+ * array reader: `loadRegistry` silently flattens a corrupt/unreadable
328
+ * registry to `[]`, which would conflate two very different situations —
329
+ * "this session genuinely has no recorded pgid" vs. "the registry could not
330
+ * be read at all". A reaper must tell those apart: an unreadable registry
331
+ * must NEVER be treated as "nothing to reap" (that recreates the exact
332
+ * invisible-orphan bug this mechanism exists to close), so it is reported as
333
+ * `unresolved` with a diagnostic reason, loudly, by the caller (see
334
+ * `NativeTerminalHost#reapOrphan`), which must never guess a pgid or kill
335
+ * silently on either branch below.
336
+ *
337
+ * (If/when a future 3-state `loadRegistry` lands — `state: "ok" | "unknown"`
338
+ * — the migration is a refinement of the condition below, not a new branch:
339
+ * map `state: "unknown"` to the same `unresolved` result.)
340
+ */
341
+ export function readNativeTerminalPgid(sessionId, path = resolveRegistryPath()) {
342
+ const diagnostics = loadRegistryWithDiagnostics(path);
343
+ if (!diagnostics.known) {
344
+ return {
345
+ status: "unresolved",
346
+ reason: `registry unreadable: ${diagnostics.reason ?? "unknown reason"}`,
347
+ };
348
+ }
349
+ const id = nativeTerminalPgidEntryId(sessionId);
350
+ const entry = diagnostics.entries.find((e) => e.id === id);
351
+ if (!entry || typeof entry.pgid !== "number") {
352
+ return {
353
+ status: "unresolved",
354
+ reason: `no pgid recorded for terminal session ${sessionId}`,
355
+ };
356
+ }
357
+ return {
358
+ status: "resolved",
359
+ pgid: entry.pgid,
360
+ ...(typeof entry.pgidLeaderStartTime === "number"
361
+ ? { leaderStartTime: entry.pgidLeaderStartTime }
362
+ : {}),
363
+ ...(typeof entry.pgidGroupToken === "string"
364
+ ? { groupToken: entry.pgidGroupToken }
365
+ : {}),
366
+ };
367
+ }
368
+ /**
369
+ * Enumerate every durably-persisted native-terminal PTY session, for a
370
+ * reconcile pass that must decide, PER ENTRY, whether its owning host is
371
+ * proven dead (see `reconcileDeadHostOrphans` in native-terminal/host.ts, the
372
+ * sole consumer). Reads via `loadRegistryWithDiagnostics` for the same reason
373
+ * `readNativeTerminalPgid` does: an unreadable registry must never collapse
374
+ * to "zero entries" — that would silently make a reconcile pass reap
375
+ * (or skip) NOTHING while believing it saw the whole truth.
376
+ */
377
+ export function listNativeTerminalPgidEntries(path = resolveRegistryPath()) {
378
+ const diagnostics = loadRegistryWithDiagnostics(path);
379
+ if (!diagnostics.known) {
380
+ return { known: false, reason: diagnostics.reason ?? "unknown reason" };
381
+ }
382
+ const entries = [];
383
+ for (const e of diagnostics.entries) {
384
+ if (!e.id.startsWith(NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX))
385
+ continue;
386
+ if (typeof e.pgid !== "number")
387
+ continue;
388
+ const sessionId = e.id.slice(NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX.length);
389
+ const entry = {
390
+ sessionId,
391
+ pgid: e.pgid,
392
+ };
393
+ if (typeof e.ownerHostPid === "number") {
394
+ const owner = { pid: e.ownerHostPid };
395
+ if (typeof e.ownerHostStartTime === "number")
396
+ owner.startTime = e.ownerHostStartTime;
397
+ entry.owner = owner;
398
+ }
399
+ entries.push(entry);
400
+ }
401
+ return { known: true, entries };
402
+ }
403
+ /**
404
+ * Remove a single native-terminal-pty row once its orphan group has been
405
+ * reaped (or it is otherwise confirmed handled). Returns false when the id
406
+ * was not present (already pruned, or never existed) — a no-op, not an
407
+ * error, since a reconcile pass may race a concurrent prune of the same row.
408
+ */
409
+ export function pruneNativeTerminalPgidEntry(sessionId, path = resolveRegistryPath()) {
410
+ const id = nativeTerminalPgidEntryId(sessionId);
411
+ return withRegistryLock(path, (entries) => {
412
+ const idx = entries.findIndex((e) => e.id === id);
413
+ if (idx < 0)
414
+ return { entries, result: false, save: false };
415
+ const next = entries.slice(0, idx).concat(entries.slice(idx + 1));
416
+ return { entries: next, result: true };
417
+ });
418
+ }
80
419
  function isRegistryEntry(raw) {
81
420
  if (!raw || typeof raw !== "object")
82
421
  return false;
83
422
  const e = raw;
84
423
  return (typeof e.id === "string" &&
85
424
  (e.tool === "claude" || e.tool === "codex" || e.tool === "agy") &&
86
- (e.kind === "local-tmux" || e.kind === "local" || e.kind === "remote") &&
425
+ (e.kind === "local-tmux" || e.kind === "local-native" || e.kind === "local" || e.kind === "remote") &&
87
426
  typeof e.cwd === "string" &&
88
427
  typeof e.enrolledAt === "string" &&
89
428
  typeof e.lastSeenAt === "string" &&
@@ -102,14 +441,46 @@ function isRegistryEntry(raw) {
102
441
  e.delegationOrigin === "mcp:h2a_run" ||
103
442
  e.delegationOrigin === "cli:h2a-delegate") &&
104
443
  (e.pid === undefined || (typeof e.pid === "number" && Number.isInteger(e.pid) && e.pid > 0)) &&
444
+ (e.pgid === undefined || (typeof e.pgid === "number" && Number.isInteger(e.pgid) && e.pgid > 0)) &&
445
+ (e.pgidLeaderStartTime === undefined ||
446
+ (typeof e.pgidLeaderStartTime === "number" &&
447
+ Number.isInteger(e.pgidLeaderStartTime) &&
448
+ e.pgidLeaderStartTime >= 0)) &&
449
+ (e.pgidGroupToken === undefined || typeof e.pgidGroupToken === "string") &&
450
+ (e.ownerHostPid === undefined ||
451
+ (typeof e.ownerHostPid === "number" && Number.isInteger(e.ownerHostPid) && e.ownerHostPid > 0)) &&
452
+ (e.ownerHostStartTime === undefined ||
453
+ (typeof e.ownerHostStartTime === "number" &&
454
+ Number.isInteger(e.ownerHostStartTime) &&
455
+ e.ownerHostStartTime >= 0)) &&
105
456
  (e.delegatorInstance === undefined || typeof e.delegatorInstance === "string") &&
106
- (e.delegatorTmuxSession === undefined || typeof e.delegatorTmuxSession === "string"));
457
+ (e.delegatorTmuxSession === undefined || typeof e.delegatorTmuxSession === "string") &&
458
+ (e.restorePinned === undefined || typeof e.restorePinned === "boolean"));
107
459
  }
108
- /** Atomic write: tmp file in the same dir, then rename. */
109
- function saveRegistry(entries, path) {
460
+ /**
461
+ * Atomic write: tmp file in the same dir, then rename. `preserved` carries
462
+ * raw rows that FAIL validation (e.g. `kind` unreadable): they are written
463
+ * back verbatim so a load-mutate-save cycle never silently ERASES an
464
+ * unreadable row from the file (sol-2 — an erased row turns "host state
465
+ * unknown" into "no local session", re-routing destructive acts).
466
+ *
467
+ * `rebuiltFromCorruptAt` (B2) stamps the WHOLE-FILE trace when this save is
468
+ * the rebuild that followed a whole-file-unknown read: `loadRegistry` turns
469
+ * its presence into an "unknown" verdict for every subsequent READ, until a
470
+ * later `saveRegistry` call (one that did NOT rebuild from corrupt) omits it
471
+ * again and the trace clears.
472
+ */
473
+ function saveRegistry(entries, path, preserved = [], rebuiltFromCorruptAt) {
110
474
  mkdirSync(dirname(path), { recursive: true });
111
475
  const tmp = `${path}.tmp.${process.pid}`;
112
- writeFileSync(tmp, JSON.stringify({ version: 1, entries }, null, 2), "utf8");
476
+ const body = {
477
+ version: 1,
478
+ entries: [...entries, ...preserved],
479
+ };
480
+ if (rebuiltFromCorruptAt !== undefined) {
481
+ body.rebuiltFromCorruptAt = rebuiltFromCorruptAt;
482
+ }
483
+ writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
113
484
  renameSync(tmp, path);
114
485
  }
115
486
  // ---------------------------------------------------------------------------
@@ -143,9 +514,24 @@ function saveRegistry(entries, path) {
143
514
  export function withRegistryLock(path, fn) {
144
515
  const fd = acquireFileLock(path);
145
516
  try {
146
- const { entries, result, save } = fn(loadRegistry(path));
147
- if (save !== false)
148
- saveRegistry(entries, path);
517
+ // WRITE-path raw read: enrolment deliberately REBUILDS a missing/corrupt
518
+ // registry (a hiccup must not brick every future enrolment), so an
519
+ // unknown state flattens to `[]` for `fn`'s input — but see below: the
520
+ // corrupt BYTES are moved aside (never overwritten in place) before any
521
+ // write that actually rebuilds from that `[]`.
522
+ const raw = rawRegistryRead(path);
523
+ const rows = raw.state === "ok" ? raw.rows : [];
524
+ const { entries, result, save } = fn(rows.filter(isRegistryEntry));
525
+ if (save !== false) {
526
+ // B2 — "REBUILDING is allowed. DESTROYING is not.": only reached when
527
+ // a write is ACTUALLY about to rebuild the file, never on a read-only
528
+ // `save:false` no-op (prune finding nothing to change on a corrupt
529
+ // file must not spam a fresh registry.corrupt-*.json on every poll).
530
+ const rebuiltFromCorruptAt = raw.state === "unknown" ? moveAsideUnreadableRegistry(path) : undefined;
531
+ // Unreadable rows are preserved verbatim: a mutation of the VALID
532
+ // entries must never erase what it could not read (sol-2).
533
+ saveRegistry(entries, path, rows.filter((row) => !isRegistryEntry(row)), rebuiltFromCorruptAt);
534
+ }
149
535
  return result;
150
536
  }
151
537
  finally {
@@ -292,6 +678,15 @@ function applyEnroll(entries, input) {
292
678
  const gatewayMode = input.gatewayMode ?? prev?.gatewayMode;
293
679
  if (gatewayMode !== undefined)
294
680
  entry.gatewayMode = gatewayMode;
681
+ if (input.restorePinned !== undefined) {
682
+ entry.restorePinned = input.restorePinned;
683
+ }
684
+ else if (prev?.restorePinned !== undefined) {
685
+ entry.restorePinned = prev.restorePinned;
686
+ }
687
+ else {
688
+ entry.restorePinned = shouldPreserveByRestorePin(entry);
689
+ }
295
690
  if (idx >= 0)
296
691
  entries[idx] = entry;
297
692
  else
@@ -381,7 +776,14 @@ export function advanceJob(id, to, path = resolveRegistryPath()) {
381
776
  /** Live job entries (role "job"), liveness reconciled like any other entry. */
382
777
  export function listJobs(opts = {}) {
383
778
  const path = opts.path ?? resolveRegistryPath();
384
- return loadRegistry(path).filter((e) => e.role === "job");
779
+ const read = loadRegistry(path);
780
+ // Unknown read → EMPTY VIEW, deliberately: this list feeds supervision
781
+ // views and job launch planning (acts that only ever START work); no
782
+ // kill/stop keys off it. Callers that must PROVE local absence go through
783
+ // resolveManagedHost, which transports the unknown read as a refusal.
784
+ return read.state === "ok"
785
+ ? read.entries.filter((e) => e.role === "job")
786
+ : [];
385
787
  }
386
788
  /** Refresh lastSeenAt. Returns false when the id is unknown. */
387
789
  export function touchEntry(id, path = resolveRegistryPath()) {
@@ -416,6 +818,16 @@ function defaultTmuxHasSession(name) {
416
818
  return false;
417
819
  }
418
820
  }
821
+ function defaultNativeSessionLiveness(name) {
822
+ try {
823
+ return nativeSessionLiveness(name);
824
+ }
825
+ catch {
826
+ // The producer already maps its own failures to "unknown"; this belt
827
+ // keeps an unexpected throw from ever reading as death.
828
+ return "unknown";
829
+ }
830
+ }
419
831
  function defaultPidAlive(pid) {
420
832
  try {
421
833
  process.kill(pid, 0);
@@ -468,6 +880,16 @@ function processIsTool(pid, tool, read) {
468
880
  export function isLive(e, opts = {}) {
469
881
  if (e.endedAt)
470
882
  return false;
883
+ if (e.kind === "local-native") {
884
+ const liveness = opts.nativeSessionLiveness ?? defaultNativeSessionLiveness;
885
+ // "unknown" deliberately counts as possibly-live here (`!== false`):
886
+ // isLive feeds views AND prune — erasing the host pin of an unprovable
887
+ // session would re-route later destructive acts (F2). Only a POSITIVE
888
+ // "false" reads as death.
889
+ return (e.tmuxSession
890
+ ? [e.tmuxSession]
891
+ : managedSessionCandidates(e.id)).some((name) => liveness(name) !== false);
892
+ }
471
893
  if (e.kind === "local-tmux") {
472
894
  const has = opts.tmuxHasSession ?? defaultTmuxHasSession;
473
895
  return (e.tmuxSession
@@ -498,13 +920,39 @@ export function isLive(e, opts = {}) {
498
920
  /** Entries considered live right now (see isLive for the per-kind rules). */
499
921
  export function listLive(opts = {}) {
500
922
  const path = opts.path ?? resolveRegistryPath();
501
- return loadRegistry(path).filter((e) => isLive(e, opts));
923
+ const read = loadRegistry(path);
924
+ // Unknown read → EMPTY VIEW: `ls`-style projections degrade to showing no
925
+ // registry rows (the status bar has its own UNKNOWN rendering via
926
+ // loadRegistryWithDiagnostics). Guards that must prove the ABSENCE of a
927
+ // writer must not consume this list — convOwners reads the registry itself
928
+ // and refuses on an unknown read.
929
+ return read.state === "ok" ? read.entries.filter((e) => isLive(e, opts)) : [];
502
930
  }
503
931
  /**
504
932
  * Drop DEAD entries whose last activity (endedAt, else lastSeenAt) is older
505
933
  * than maxAgeHours. Live entries always stay; recently-dead ones stay too so
506
934
  * `restore` can still resume them after a reboot via the scan fallback.
507
935
  * Returns the number of removed entries.
936
+ *
937
+ * RECONCILIATION DECISION (2026-08, #199 rebase — restore-pin x FILE-level
938
+ * unknown; architect to re-measure): `shouldPreserveByRestorePin` never
939
+ * observes `loadRegistry()`'s 3-state read directly — it runs INSIDE
940
+ * `withRegistryLock`, over the already-validated `entries` array only. Two
941
+ * unprovable cases both resolve CONSERVATIVE-PRESERVE by construction,
942
+ * neither newly added here:
943
+ * - whole-FILE unknown (corrupt/unreadable registry.json):
944
+ * `withRegistryLock`'s own raw read flattens to `[]` for its write-path
945
+ * contract, so `kept.length === entries.length` (0 === 0) and this prune
946
+ * is a NO-OP (`save:false`, see below) — nothing is read, so nothing a
947
+ * restore pin could have kept is ever written over or lost.
948
+ * - a single restore-pinned row that fails `isRegistryEntry` (per-row
949
+ * unreadable, PART A): it never reaches `entries` here at all (excluded
950
+ * before this callback runs), but `withRegistryLock`/`saveRegistry`
951
+ * re-append every such raw row VERBATIM on every write (sol-2) —
952
+ * `shouldPreserveByRestorePin` cannot prune what it never sees, and the
953
+ * row survives regardless of what this function decides.
954
+ * Neither case is exercised through an explicit `state:"unknown"` branch
955
+ * here (there isn't one to write) — REPORTED, not silently assumed settled.
508
956
  */
509
957
  export function prune(maxAgeHours, opts = {}) {
510
958
  const path = opts.path ?? resolveRegistryPath();
@@ -513,6 +961,8 @@ export function prune(maxAgeHours, opts = {}) {
513
961
  const kept = entries.filter((e) => {
514
962
  if (isLive(e, opts))
515
963
  return true;
964
+ if (shouldPreserveByRestorePin(e))
965
+ return true;
516
966
  const last = Date.parse(e.endedAt ?? e.lastSeenAt);
517
967
  return Number.isFinite(last) && last >= cutoff;
518
968
  });
@@ -544,11 +994,17 @@ export function enrollFromRun(args) {
544
994
  const tool = coerceRegistryTool(args.profile);
545
995
  if (!tool)
546
996
  return; // shell/opencode/… sessions stay tmux-only
997
+ // Runtime twin of the required-parameter type: an untyped caller that omits
998
+ // the host gets NO row rather than a row on a guessed host. Never default.
999
+ if (args.hostKind !== "local-tmux" && args.hostKind !== "local-native") {
1000
+ process.stderr.write(`[h2a] registry enrolment refused for ${args.slug}: caller did not name the terminal host\n`);
1001
+ return;
1002
+ }
547
1003
  try {
548
1004
  enroll({
549
1005
  id: args.slug,
550
1006
  tool,
551
- kind: "local-tmux",
1007
+ kind: args.hostKind,
552
1008
  cwd: args.cwd,
553
1009
  source: "run",
554
1010
  label: args.slug,
@@ -603,7 +1059,7 @@ export function localLsRows(sessions, live) {
603
1059
  // kind:"local" entries are Claude Code conversation sessions (UUID ids,
604
1060
  // no tmuxSession) — they are internal CC state, not user-facing sessions.
605
1061
  for (const e of live) {
606
- if (e.kind !== "local-tmux" || matched.has(e.id))
1062
+ if (!isManagedLocalKind(e.kind) || matched.has(e.id))
607
1063
  continue;
608
1064
  // A historical record without an exact tmux name cannot authoritatively
609
1065
  // claim either member of a live h2a-/remote- collision. It already remains
@@ -649,7 +1105,7 @@ export function listLocalForLs(opts = {}) {
649
1105
  export function resolveLocalTmuxSessionForName(target, entries) {
650
1106
  const requested = parseManagedSessionName(target);
651
1107
  const matches = entries.filter((e) => {
652
- if (e.role !== undefined || e.kind !== "local-tmux" || e.endedAt) {
1108
+ if (e.role !== undefined || !isManagedLocalKind(e.kind) || e.endedAt) {
653
1109
  return false;
654
1110
  }
655
1111
  // A full managed name is an exact selector, never a slug/label alias.
@@ -692,4 +1148,187 @@ export function localTmuxSessionForName(target, entries) {
692
1148
  const resolution = resolveLocalTmuxSessionForName(target, entries);
693
1149
  return resolution.kind === "found" ? resolution.name : undefined;
694
1150
  }
1151
+ /**
1152
+ * DEDICATED resolver for `kind:"local-native"` registry entries — the native
1153
+ * twin of the local-tmux target filter in index.ts. That filter stays
1154
+ * tmux-scoped on purpose: its second caller iterates LIVE tmux sessions by
1155
+ * name inside the destructive relaunch planner, where a broadened match would
1156
+ * be a category error. The two resolvers coexist so neither lies about its
1157
+ * name.
1158
+ *
1159
+ * A native entry's PERSISTED identity, as measured on every native write path
1160
+ * (`enroll` in startJob and `enrollFromRun` on run/resume/relaunch): `id` and
1161
+ * `label` carry the slug, and `tmuxSession` — despite its tmux-era name —
1162
+ * carries the native host session name `localSessionName(slug)`, the
1163
+ * h2a-<slug> naming contract shared across hosts (native-host.ts). Historical
1164
+ * rows without a persisted session name fall back to the managed-name
1165
+ * candidates of their id, exactly like `isLive`.
1166
+ *
1167
+ * NO liveness input exists here by construction: which host a recorded
1168
+ * session belongs to is decided by its persisted kind alone — a dead native
1169
+ * session stays native. Whether an ACT on it can proceed is the caller's
1170
+ * separate liveness gate.
1171
+ * (0 local-native entries in the fleet registry as of 2026-08-08; this
1172
+ * resolver prepares the re-use paths for the sessions #178 will produce.)
1173
+ */
1174
+ export function registryEntriesForNativeTarget(target,
1175
+ // No default read: the caller owns the registry READ STATE (an unknown
1176
+ // read must refuse upstream, never flatten into "no rows" here).
1177
+ entries) {
1178
+ return registryEntriesForManagedKind("local-native", target, entries);
1179
+ }
1180
+ /**
1181
+ * The SAME exact-identity filter scoped to persisted `kind:"local-tmux"` rows.
1182
+ * It exists so host resolution can consult BOTH persisted kinds symmetrically
1183
+ * (`resolveManagedHost`): recognizing only one kind before falling back to a
1184
+ * probe was the measured F2 defect — a persisted tmux row plus a homonymous
1185
+ * live native process re-routed destructive acts to the wrong host.
1186
+ * (The broader index.ts local-tmux target filter stays separate on purpose:
1187
+ * it also matches LIVE tmux state for the relaunch planner.)
1188
+ */
1189
+ export function registryEntriesForLocalTmuxTarget(target,
1190
+ // No default read: the caller owns the registry READ STATE (an unknown
1191
+ // read must refuse upstream, never flatten into "no rows" here).
1192
+ entries) {
1193
+ return registryEntriesForManagedKind("local-tmux", target, entries);
1194
+ }
1195
+ function registryEntriesForManagedKind(kind, target, entries) {
1196
+ const requested = parseManagedSessionName(target);
1197
+ const candidates = requested ? [target] : managedSessionCandidates(target);
1198
+ return entries.filter((e) => {
1199
+ if (e.kind !== kind)
1200
+ return false;
1201
+ // A positively-ENDED session no longer pins a host: only current rows
1202
+ // take part in host resolution (mirrors isLive's endedAt short-circuit).
1203
+ if (e.endedAt !== undefined)
1204
+ return false;
1205
+ // A full managed name is an exact selector, never a slug/label alias
1206
+ // (same rule as the tmux filter and resolveLocalTmuxSessionForName).
1207
+ if (requested) {
1208
+ return (e.tmuxSession === target ||
1209
+ (e.tmuxSession === undefined && e.id === requested.slug));
1210
+ }
1211
+ return (e.id === target ||
1212
+ e.label === target ||
1213
+ (e.tmuxSession !== undefined && candidates.includes(e.tmuxSession)));
1214
+ });
1215
+ }
1216
+ /** Native probe: an op failure is UNKNOWN, never dead (producer is 3-state). */
1217
+ export function probeNativeSession(name) {
1218
+ try {
1219
+ const alive = nativeSessionLiveness(name);
1220
+ if (alive === true)
1221
+ return "live";
1222
+ if (alive === false)
1223
+ return "dead";
1224
+ return "unknown";
1225
+ }
1226
+ catch {
1227
+ return "unknown";
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Tmux probe. No tmux binary/server means no tmux session CAN exist (a tmux
1232
+ * session cannot outlive its server), so that is provable death — only a
1233
+ * spawn-level failure of an installed tmux is UNKNOWN.
1234
+ */
1235
+ export function probeTmuxSession(name) {
1236
+ if (!tmuxAvailable())
1237
+ return "dead";
1238
+ try {
1239
+ // "=" prefix forces an exact session-name match (no prefix matching).
1240
+ const r = spawnSync("tmux", ["has-session", "-t", `=${name}`], {
1241
+ stdio: "ignore",
1242
+ });
1243
+ if (r.error)
1244
+ return "unknown";
1245
+ return r.status === 0 ? "live" : "dead";
1246
+ }
1247
+ catch {
1248
+ return "unknown";
1249
+ }
1250
+ }
1251
+ function isRegistryReadResult(value) {
1252
+ return !Array.isArray(value);
1253
+ }
1254
+ export function resolveManagedHost(target, entries = loadRegistry(), probes = {}) {
1255
+ // The registry READ STATE is part of the resolution (F2): an unreadable
1256
+ // registry means the persisted host cannot be proven — transported as
1257
+ // "unknown" so every destructive caller refuses instead of treating the
1258
+ // unprovable rows as absent and probing/guessing a host.
1259
+ const read = isRegistryReadResult(entries)
1260
+ ? entries
1261
+ // A plain-array caller carries NO unreadable-row information (it never
1262
+ // had a snapshot to draw one from) — `unreadable:[]` here is a neutral
1263
+ // "none known", not a claim that none exist. Callers that hold a real
1264
+ // `loadRegistry()` snapshot MUST pass it whole (not `.entries`) for the
1265
+ // per-identity poison check below to see the unreadable rows at all.
1266
+ : { state: "ok", entries: [...entries], unreadable: [] };
1267
+ if (read.state === "unknown") {
1268
+ return {
1269
+ state: "unknown",
1270
+ reason: `registry read failed: ${read.reason}`,
1271
+ };
1272
+ }
1273
+ // A2 (PART A — resolver per-identity unreadable check, sol-2): for target
1274
+ // identity X, ask "is there an unreadable row that COULD be X?" — a
1275
+ // same-name/same-form TWIN — never "are there any unreadable rows at all?".
1276
+ // A hit poisons the resolution for X ONLY; every OTHER identity in this
1277
+ // same registry stays fully resolvable. This runs BEFORE the recorded/
1278
+ // discovered/missing branches below (A3 — the kill must not precede the
1279
+ // check): a valid row for X plus an unreadable twin of X must never reach
1280
+ // "recorded" and hand a caller a name to kill.
1281
+ const unreadableForTarget = unreadableRowsForTarget(target, read.unreadable);
1282
+ if (unreadableForTarget.length > 0) {
1283
+ const ids = unreadableForTarget
1284
+ .map((row) => (typeof row.id === "string" ? row.id : "<no id>"))
1285
+ .join(", ");
1286
+ return {
1287
+ state: "unknown",
1288
+ reason: `unreadable registry row(s) for this identity (row id: ${ids})`,
1289
+ };
1290
+ }
1291
+ const rows = read.entries;
1292
+ const nativeRows = registryEntriesForNativeTarget(target, rows);
1293
+ const tmuxRows = registryEntriesForLocalTmuxTarget(target, rows);
1294
+ const exactName = (rows) => rows[0]?.tmuxSession ??
1295
+ (parseManagedSessionName(target)
1296
+ ? target
1297
+ : localSessionName(rows[0]?.id ?? target));
1298
+ if (nativeRows.length > 0 && tmuxRows.length > 0) {
1299
+ return { state: "ambiguous", candidates: ["local-native", "local-tmux"] };
1300
+ }
1301
+ if (nativeRows.length > 0) {
1302
+ return { state: "recorded", kind: "local-native", name: exactName(nativeRows) };
1303
+ }
1304
+ if (tmuxRows.length > 0) {
1305
+ return { state: "recorded", kind: "local-tmux", name: exactName(tmuxRows) };
1306
+ }
1307
+ // Discovery: probes may only run when NO persisted row exists.
1308
+ const probedName = parseManagedSessionName(target)
1309
+ ? target
1310
+ : localSessionName(target);
1311
+ const native = (probes.native ?? probeNativeSession)(probedName);
1312
+ const tmux = (probes.tmux ?? probeTmuxSession)(probedName);
1313
+ if (native === "unknown" || tmux === "unknown") {
1314
+ const failed = [
1315
+ ...(native === "unknown" ? ["native"] : []),
1316
+ ...(tmux === "unknown" ? ["tmux"] : []),
1317
+ ].join("+");
1318
+ return {
1319
+ state: "unknown",
1320
+ reason: `${failed} host probe failed for ${probedName}`,
1321
+ };
1322
+ }
1323
+ if (native === "live" && tmux === "live") {
1324
+ return { state: "ambiguous", candidates: ["local-native", "local-tmux"] };
1325
+ }
1326
+ if (native === "live") {
1327
+ return { state: "discovered", kind: "local-native", name: probedName };
1328
+ }
1329
+ if (tmux === "live") {
1330
+ return { state: "discovered", kind: "local-tmux", name: probedName };
1331
+ }
1332
+ return { state: "missing" };
1333
+ }
695
1334
  //# sourceMappingURL=registry.js.map