@sentropic/h2a-runtime 0.92.0 → 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.
- package/dist/conv-guard.d.ts +6 -1
- package/dist/conv-guard.d.ts.map +1 -1
- package/dist/conv-guard.js +33 -8
- package/dist/conv-guard.js.map +1 -1
- package/dist/enroll.d.ts.map +1 -1
- package/dist/enroll.js +12 -2
- package/dist/enroll.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +743 -97
- package/dist/index.js.map +1 -1
- package/dist/launch-layout.d.ts +74 -0
- package/dist/launch-layout.d.ts.map +1 -0
- package/dist/launch-layout.js +324 -0
- package/dist/launch-layout.js.map +1 -0
- package/dist/native-host.d.ts +146 -0
- package/dist/native-host.d.ts.map +1 -0
- package/dist/native-host.js +456 -0
- package/dist/native-host.js.map +1 -0
- package/dist/native-terminal/host.d.ts +317 -1
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +696 -3
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts +5 -0
- package/dist/native-terminal/op.d.ts.map +1 -0
- package/dist/native-terminal/op.js +408 -0
- package/dist/native-terminal/op.js.map +1 -0
- package/dist/native-terminal/process.d.ts +2 -0
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +16 -2
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +6 -2
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +8 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +31 -1
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +13 -0
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +45 -0
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/pty.d.ts +23 -0
- package/dist/pty.d.ts.map +1 -1
- package/dist/pty.js +35 -0
- package/dist/pty.js.map +1 -1
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.d.ts.map +1 -1
- package/dist/readiness.js +12 -9
- package/dist/readiness.js.map +1 -1
- package/dist/registry.d.ts +317 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +622 -19
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +91 -32
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +226 -186
- package/dist/restore.js.map +1 -1
- package/dist/tmux.d.ts +2 -1
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +1 -1
- package/dist/tmux.js.map +1 -1
- package/package.json +1 -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;
|
|
@@ -62,18 +67,179 @@ function shouldPreserveByRestorePin(entry) {
|
|
|
62
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.
|
|
63
68
|
(entry.sessionClass === "human" || entry.sessionClass === undefined));
|
|
64
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
|
|
65
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;
|
|
66
125
|
try {
|
|
67
|
-
|
|
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);
|
|
68
141
|
const entries = parsed?.entries;
|
|
69
|
-
if (!Array.isArray(entries))
|
|
70
|
-
return
|
|
71
|
-
|
|
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
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return { state: "unknown", reason: "registry is corrupt (not valid JSON)" };
|
|
155
|
+
}
|
|
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");
|
|
72
176
|
}
|
|
73
177
|
catch {
|
|
74
|
-
//
|
|
75
|
-
|
|
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.
|
|
76
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);
|
|
77
243
|
}
|
|
78
244
|
/**
|
|
79
245
|
* Read the registry without collapsing a missing/corrupt file into an empty
|
|
@@ -101,13 +267,162 @@ export function loadRegistryWithDiagnostics(path = resolveRegistryPath()) {
|
|
|
101
267
|
};
|
|
102
268
|
}
|
|
103
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
|
+
}
|
|
104
419
|
function isRegistryEntry(raw) {
|
|
105
420
|
if (!raw || typeof raw !== "object")
|
|
106
421
|
return false;
|
|
107
422
|
const e = raw;
|
|
108
423
|
return (typeof e.id === "string" &&
|
|
109
424
|
(e.tool === "claude" || e.tool === "codex" || e.tool === "agy") &&
|
|
110
|
-
(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") &&
|
|
111
426
|
typeof e.cwd === "string" &&
|
|
112
427
|
typeof e.enrolledAt === "string" &&
|
|
113
428
|
typeof e.lastSeenAt === "string" &&
|
|
@@ -126,15 +441,46 @@ function isRegistryEntry(raw) {
|
|
|
126
441
|
e.delegationOrigin === "mcp:h2a_run" ||
|
|
127
442
|
e.delegationOrigin === "cli:h2a-delegate") &&
|
|
128
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)) &&
|
|
129
456
|
(e.delegatorInstance === undefined || typeof e.delegatorInstance === "string") &&
|
|
130
457
|
(e.delegatorTmuxSession === undefined || typeof e.delegatorTmuxSession === "string") &&
|
|
131
458
|
(e.restorePinned === undefined || typeof e.restorePinned === "boolean"));
|
|
132
459
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
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) {
|
|
135
474
|
mkdirSync(dirname(path), { recursive: true });
|
|
136
475
|
const tmp = `${path}.tmp.${process.pid}`;
|
|
137
|
-
|
|
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");
|
|
138
484
|
renameSync(tmp, path);
|
|
139
485
|
}
|
|
140
486
|
// ---------------------------------------------------------------------------
|
|
@@ -168,9 +514,24 @@ function saveRegistry(entries, path) {
|
|
|
168
514
|
export function withRegistryLock(path, fn) {
|
|
169
515
|
const fd = acquireFileLock(path);
|
|
170
516
|
try {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|
|
174
535
|
return result;
|
|
175
536
|
}
|
|
176
537
|
finally {
|
|
@@ -415,7 +776,14 @@ export function advanceJob(id, to, path = resolveRegistryPath()) {
|
|
|
415
776
|
/** Live job entries (role "job"), liveness reconciled like any other entry. */
|
|
416
777
|
export function listJobs(opts = {}) {
|
|
417
778
|
const path = opts.path ?? resolveRegistryPath();
|
|
418
|
-
|
|
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
|
+
: [];
|
|
419
787
|
}
|
|
420
788
|
/** Refresh lastSeenAt. Returns false when the id is unknown. */
|
|
421
789
|
export function touchEntry(id, path = resolveRegistryPath()) {
|
|
@@ -450,6 +818,16 @@ function defaultTmuxHasSession(name) {
|
|
|
450
818
|
return false;
|
|
451
819
|
}
|
|
452
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
|
+
}
|
|
453
831
|
function defaultPidAlive(pid) {
|
|
454
832
|
try {
|
|
455
833
|
process.kill(pid, 0);
|
|
@@ -502,6 +880,16 @@ function processIsTool(pid, tool, read) {
|
|
|
502
880
|
export function isLive(e, opts = {}) {
|
|
503
881
|
if (e.endedAt)
|
|
504
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
|
+
}
|
|
505
893
|
if (e.kind === "local-tmux") {
|
|
506
894
|
const has = opts.tmuxHasSession ?? defaultTmuxHasSession;
|
|
507
895
|
return (e.tmuxSession
|
|
@@ -532,13 +920,39 @@ export function isLive(e, opts = {}) {
|
|
|
532
920
|
/** Entries considered live right now (see isLive for the per-kind rules). */
|
|
533
921
|
export function listLive(opts = {}) {
|
|
534
922
|
const path = opts.path ?? resolveRegistryPath();
|
|
535
|
-
|
|
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)) : [];
|
|
536
930
|
}
|
|
537
931
|
/**
|
|
538
932
|
* Drop DEAD entries whose last activity (endedAt, else lastSeenAt) is older
|
|
539
933
|
* than maxAgeHours. Live entries always stay; recently-dead ones stay too so
|
|
540
934
|
* `restore` can still resume them after a reboot via the scan fallback.
|
|
541
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.
|
|
542
956
|
*/
|
|
543
957
|
export function prune(maxAgeHours, opts = {}) {
|
|
544
958
|
const path = opts.path ?? resolveRegistryPath();
|
|
@@ -580,11 +994,17 @@ export function enrollFromRun(args) {
|
|
|
580
994
|
const tool = coerceRegistryTool(args.profile);
|
|
581
995
|
if (!tool)
|
|
582
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
|
+
}
|
|
583
1003
|
try {
|
|
584
1004
|
enroll({
|
|
585
1005
|
id: args.slug,
|
|
586
1006
|
tool,
|
|
587
|
-
kind:
|
|
1007
|
+
kind: args.hostKind,
|
|
588
1008
|
cwd: args.cwd,
|
|
589
1009
|
source: "run",
|
|
590
1010
|
label: args.slug,
|
|
@@ -639,7 +1059,7 @@ export function localLsRows(sessions, live) {
|
|
|
639
1059
|
// kind:"local" entries are Claude Code conversation sessions (UUID ids,
|
|
640
1060
|
// no tmuxSession) — they are internal CC state, not user-facing sessions.
|
|
641
1061
|
for (const e of live) {
|
|
642
|
-
if (e.kind
|
|
1062
|
+
if (!isManagedLocalKind(e.kind) || matched.has(e.id))
|
|
643
1063
|
continue;
|
|
644
1064
|
// A historical record without an exact tmux name cannot authoritatively
|
|
645
1065
|
// claim either member of a live h2a-/remote- collision. It already remains
|
|
@@ -685,7 +1105,7 @@ export function listLocalForLs(opts = {}) {
|
|
|
685
1105
|
export function resolveLocalTmuxSessionForName(target, entries) {
|
|
686
1106
|
const requested = parseManagedSessionName(target);
|
|
687
1107
|
const matches = entries.filter((e) => {
|
|
688
|
-
if (e.role !== undefined || e.kind
|
|
1108
|
+
if (e.role !== undefined || !isManagedLocalKind(e.kind) || e.endedAt) {
|
|
689
1109
|
return false;
|
|
690
1110
|
}
|
|
691
1111
|
// A full managed name is an exact selector, never a slug/label alias.
|
|
@@ -728,4 +1148,187 @@ export function localTmuxSessionForName(target, entries) {
|
|
|
728
1148
|
const resolution = resolveLocalTmuxSessionForName(target, entries);
|
|
729
1149
|
return resolution.kind === "found" ? resolution.name : undefined;
|
|
730
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
|
+
}
|
|
731
1334
|
//# sourceMappingURL=registry.js.map
|