@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
@@ -1,12 +1,306 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
1
2
  import { randomUUID } from "node:crypto";
3
+ // Durable session identity (incl. pgid) lives in the single registry store by
4
+ // design — one local store per the owner's durable-state direction (see the
5
+ // longer rationale on `persistNativeTerminalPgid`/`readNativeTerminalPgid` in
6
+ // registry.ts). This is NOT a layer leak; do not remove this coupling. The
7
+ // PTY host stays otherwise decoupled from the registry — this import exists
8
+ // ONLY for pgid durability across a host crash, nothing else here reads or
9
+ // writes registry state.
10
+ import { listNativeTerminalPgidEntries, persistNativeTerminalPgid, pruneNativeTerminalPgidEntry, readNativeTerminalPgid, } from "../registry.js";
2
11
  import { TerminalReplayBuffer, } from "./replay-buffer.js";
3
12
  import { NATIVE_TERMINAL_DEFAULT_MAX_SESSIONS, NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS, NATIVE_TERMINAL_MAX_SESSIONS, } from "./protocol.js";
13
+ function isErrnoException(error) {
14
+ return error instanceof Error && "code" in error;
15
+ }
16
+ /**
17
+ * Real POSIX implementation: `process.kill(-pgid, signal)` signals every
18
+ * process in the group in one syscall, regardless of whether any of them is
19
+ * stuck, in D-state, or has overwritten its own trap handlers — it asks
20
+ * nothing of the target. `kill(-pgid, 0)` is the standard "is anything still
21
+ * in this group" probe (ESRCH = empty). Not meaningful on win32 (no POSIX
22
+ * process groups); callers on that platform fall back to per-process kill.
23
+ */
24
+ export const posixProcessGroupReaper = {
25
+ killGroup(pgid, signal) {
26
+ try {
27
+ process.kill(-pgid, signal);
28
+ }
29
+ catch (error) {
30
+ if (isErrnoException(error) && error.code === "ESRCH")
31
+ return;
32
+ throw error;
33
+ }
34
+ },
35
+ isGroupAlive(pgid) {
36
+ try {
37
+ process.kill(-pgid, 0);
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ if (isErrnoException(error) && error.code === "ESRCH")
42
+ return false;
43
+ // EPERM or anything else: we cannot prove death — stay conservative.
44
+ return true;
45
+ }
46
+ },
47
+ describeGroup(pgid) {
48
+ if (process.platform !== "linux") {
49
+ return "diagnostic unavailable on this platform";
50
+ }
51
+ try {
52
+ const members = [];
53
+ for (const name of readdirSync("/proc")) {
54
+ if (!/^\d+$/.test(name))
55
+ continue;
56
+ try {
57
+ const raw = readFileSync(`/proc/${name}/stat`, "utf8");
58
+ const commandEnd = raw.lastIndexOf(")");
59
+ const fields = raw.slice(commandEnd + 2).split(" ");
60
+ if (Number(fields[2]) === pgid) {
61
+ members.push(`${name}(state=${fields[0]})`);
62
+ }
63
+ }
64
+ catch {
65
+ // The process exited mid-scan; skip it.
66
+ }
67
+ }
68
+ return members.length > 0
69
+ ? members.join(", ")
70
+ : "no /proc members found (race with exit?)";
71
+ }
72
+ catch (error) {
73
+ return `diagnostic scan failed: ${String(error)}`;
74
+ }
75
+ },
76
+ };
77
+ function supportsProcessGroupSignals() {
78
+ return process.platform !== "win32";
79
+ }
80
+ /**
81
+ * Read a process's start-time — Linux `/proc/<pid>/stat` field 22
82
+ * ("starttime", clock ticks since boot) — the pid-recycling-proof
83
+ * discriminant used for owning-host attribution
84
+ * (`RegistryEntry.ownerHostPid`/`ownerHostStartTime`). An immutable property
85
+ * of a process for its entire lifetime: two DIFFERENT processes that ever
86
+ * held the same pid cannot both report the same start-time (barring a read
87
+ * error), which is what makes "pid matches AND start-time matches" a safe
88
+ * proof of "still the same process", not just "some process now owns this
89
+ * pid".
90
+ *
91
+ * Parsing anchors on the LAST occurrence of the two-character sequence ") "
92
+ * rather than a naive whitespace split: field 2 (`comm`, the executable
93
+ * name) is parenthesized and CAN itself contain spaces and parentheses
94
+ * (e.g. a process named "node (pty host)"), which would shift every
95
+ * subsequent field under a plain split — silently reading the wrong number
96
+ * "most of the time" and breaking on the one process with a weird name. That
97
+ * would be a confident wrong answer, worse than no discriminant.
98
+ *
99
+ * After locating the split point, man(5) proc's field 3 ("state") is
100
+ * `rest[0]`; field 22 ("starttime") is therefore `rest[19]`.
101
+ *
102
+ * A mandatory sanity check follows: `starttime` (in clock ticks, USER_HZ=100
103
+ * on Linux) divided by 100 must not exceed the system's current uptime — a
104
+ * process cannot have started in the future relative to boot. If that check
105
+ * fails, the parse is treated as shifted/malformed and this function returns
106
+ * undefined (never answers from a mis-parsed field) rather than guessing.
107
+ *
108
+ * Returns undefined — never throws — on any read/parse failure or on a
109
+ * non-Linux platform (no /proc); callers must treat "unavailable" the same
110
+ * conservative way `posixProcessGroupReaper.isGroupAlive` treats EPERM: as
111
+ * "cannot prove", never as "prove me dead".
112
+ */
113
+ export function readProcessStartTime(pid) {
114
+ if (process.platform !== "linux")
115
+ return undefined;
116
+ try {
117
+ const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
118
+ const splitAt = raw.lastIndexOf(") ");
119
+ if (splitAt < 0)
120
+ return undefined;
121
+ const rest = raw.slice(splitAt + 2).split(" ");
122
+ const starttime = Number(rest[19]);
123
+ if (!Number.isFinite(starttime) || starttime < 0)
124
+ return undefined;
125
+ const uptimeRaw = readFileSync("/proc/uptime", "utf8");
126
+ const uptimeSeconds = Number(uptimeRaw.trim().split(/\s+/)[0]);
127
+ if (!Number.isFinite(uptimeSeconds) || starttime / 100 > uptimeSeconds) {
128
+ // Sanity check failed: refuse rather than answer from a shifted parse.
129
+ return undefined;
130
+ }
131
+ return starttime;
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ /** Env var name the spawned tree's session token travels under (see `create()`). */
138
+ export const H2A_SESSION_TOKEN_ENV_VAR = "H2A_SESSION_TOKEN";
139
+ /**
140
+ * Enumerate every process currently in Linux process group `pgid` — the
141
+ * pid-recycling-proof discriminant of last resort when the group LEADER
142
+ * itself (pid==pgid) is gone and there is therefore no start-time left to
143
+ * re-read at all (see `#verifyGroupLeaderIdentity`'s leader-absent branch,
144
+ * and the ORDINARY orphan it exists for: a shell that exits normally
145
+ * leaving a backgrounded descendant — leader gone, group alive, no
146
+ * containment ever triggered). Scans `/proc` directly, matching
147
+ * `posixProcessGroupReaper.describeGroup`'s own parsing (field 3, the pgrp,
148
+ * via the same "last ')'" split `readProcessStartTime` above documents at
149
+ * length — a `comm` field can itself contain parens/spaces). Best-effort: a
150
+ * process that exits mid-scan is silently skipped, never treated as a parse
151
+ * failure.
152
+ */
153
+ function listProcessGroupMemberPids(pgid) {
154
+ if (process.platform !== "linux")
155
+ return [];
156
+ let names;
157
+ try {
158
+ names = readdirSync("/proc");
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ const members = [];
164
+ for (const name of names) {
165
+ if (!/^\d+$/.test(name))
166
+ continue;
167
+ try {
168
+ const raw = readFileSync(`/proc/${name}/stat`, "utf8");
169
+ const commandEnd = raw.lastIndexOf(")");
170
+ const fields = raw.slice(commandEnd + 2).split(" ");
171
+ if (Number(fields[2]) === pgid)
172
+ members.push(Number(name));
173
+ }
174
+ catch {
175
+ // Exited mid-scan; not a member we can act on either way.
176
+ }
177
+ }
178
+ return members;
179
+ }
180
+ /**
181
+ * Read the session token a single process currently carries in its own
182
+ * environment (`H2A_SESSION_TOKEN`), by parsing `/proc/<pid>/environ`
183
+ * (NUL-separated `KEY=VALUE` entries — never a shell-quoted string, so no
184
+ * escaping to worry about). Returns undefined on ANY read/parse failure or
185
+ * a non-Linux platform — same "never guess" contract as `readProcessStartTime`.
186
+ */
187
+ function readProcessSessionToken(pid) {
188
+ if (process.platform !== "linux")
189
+ return undefined;
190
+ try {
191
+ const raw = readFileSync(`/proc/${pid}/environ`, "utf8");
192
+ const prefix = `${H2A_SESSION_TOKEN_ENV_VAR}=`;
193
+ for (const entry of raw.split("\0")) {
194
+ if (entry.startsWith(prefix))
195
+ return entry.slice(prefix.length);
196
+ }
197
+ return undefined;
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ }
203
+ /**
204
+ * The group-MEMBERSHIP anchor of last resort: true iff AT LEAST ONE process
205
+ * currently in group `pgid` carries `expectedToken` in its own environment
206
+ * (see `RegistryEntry.pgidGroupToken`'s doc comment for the full rationale).
207
+ * Used ONLY when the group leader itself is unreadable — the rare path —
208
+ * never as a substitute for the fast leader-start-time check above it.
209
+ *
210
+ * DECLARED LIMITS (do not let a reviewer discover these):
211
+ * - `/proc/<pid>/environ` is only readable for processes owned by the SAME
212
+ * uid as this host process — true here (the host and every PTY tree it
213
+ * spawns share a uid).
214
+ * - A process can freely rewrite its own environ after exec; this is
215
+ * therefore a NON-adversarial membership proof. That is sufficient here:
216
+ * the threat this guards against is the CHANCE of the OS recycling a
217
+ * pgid to an unrelated process, not an adversary actively forging a
218
+ * token — an actual attacker with same-uid code execution already has
219
+ * far more direct ways to interfere than faking this one env var.
220
+ * - This enumeration runs ONLY on the leader-absent path (never on every
221
+ * reap), keeping it rare in practice — the fast leader-start-time check
222
+ * above it covers the common case.
223
+ */
224
+ export function groupCarriesSessionToken(pgid, expectedToken) {
225
+ for (const pid of listProcessGroupMemberPids(pgid)) {
226
+ if (readProcessSessionToken(pid) === expectedToken)
227
+ return true;
228
+ }
229
+ return false;
230
+ }
231
+ function processExists(pid) {
232
+ try {
233
+ process.kill(pid, 0);
234
+ return true;
235
+ }
236
+ catch (error) {
237
+ if (isErrnoException(error) && error.code === "ESRCH")
238
+ return false;
239
+ // EPERM or anything else: the process DOES exist (we simply may not
240
+ // signal it) — conservative: existing, never gone.
241
+ return true;
242
+ }
243
+ }
244
+ /**
245
+ * The real owner-host liveness probe. Two necessary conditions before
246
+ * declaring a host PROVEN DEAD (mirrors reapOrphan's own "prove death, don't
247
+ * just signal" conservatism one level up — see `reconcileDeadHostOrphans`):
248
+ *
249
+ * - the owner pid no longer exists at all -> DEAD (no ambiguity possible),
250
+ * - OR the pid exists but its CURRENT start-time differs from the one
251
+ * recorded at persistence time -> DEAD (the pid was recycled: a different
252
+ * process now holds it).
253
+ *
254
+ * Every other outcome is conservative-ALIVE: a pid that exists with a
255
+ * matching start-time is alive (possibly just unreachable — NOT proof of
256
+ * death); a pid that exists but whose start-time cannot be read/parsed right
257
+ * now is unresolvable (never guess); a pid that exists with NO recorded
258
+ * start-time (legacy row, or a write-time read failure) cannot be checked
259
+ * for recycling at all, so it stays alive rather than manufacture a "dead"
260
+ * verdict from missing data.
261
+ *
262
+ * This asymmetry is deliberate: the DANGEROUS error (reaping a live host)
263
+ * would require a live process's immutable start-time to change — which
264
+ * cannot happen barring a read error, itself already routed to
265
+ * "unresolvable". The BENIGN error (a recycled pid that coincidentally
266
+ * shares a start-time, or a legacy row with no recorded start-time)
267
+ * concludes "alive" and merely leaves an orphan uncollected — the pre-fix
268
+ * status quo, not a regression.
269
+ */
270
+ export const defaultOwnerHostProbe = (owner) => {
271
+ if (!processExists(owner.pid))
272
+ return "dead";
273
+ if (owner.startTime === undefined)
274
+ return "alive";
275
+ const currentStartTime = readProcessStartTime(owner.pid);
276
+ if (currentStartTime === undefined)
277
+ return "unresolvable";
278
+ return currentStartTime === owner.startTime ? "alive" : "dead";
279
+ };
280
+ function delay(milliseconds) {
281
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
282
+ }
283
+ const NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS = 30_000;
284
+ const NATIVE_TERMINAL_FORCE_KILL_POLL_INTERVAL_MS = 50;
4
285
  export class NativeTerminalHost {
5
286
  #generation;
6
287
  #replayBytesPerSession;
7
288
  #maxSessions;
8
289
  #spawner;
9
290
  #sessions = new Map();
291
+ #registryPath;
292
+ #reaper;
293
+ #log;
294
+ #forceKillTimeoutMs;
295
+ #forceKillPollIntervalMs;
296
+ #readLeaderStartTime;
297
+ #findGroupMemberToken;
298
+ #pgidGuardCounters = {
299
+ recycled: 0,
300
+ membershipUnprovable: 0,
301
+ unverifiedLegacy: 0,
302
+ tokenVerified: 0,
303
+ };
10
304
  constructor(options) {
11
305
  if (options.generation.trim().length === 0 ||
12
306
  options.generation.length > NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS) {
@@ -24,10 +318,46 @@ export class NativeTerminalHost {
24
318
  this.#replayBytesPerSession = options.replayBytesPerSession;
25
319
  this.#maxSessions = maxSessions;
26
320
  this.#spawner = options.spawner;
321
+ this.#registryPath = options.registryPath;
322
+ this.#reaper = options.reaper ?? posixProcessGroupReaper;
323
+ this.#log =
324
+ options.log ??
325
+ ((line) => {
326
+ process.stderr.write(`[h2a-pty-host] ${line}\n`);
327
+ });
328
+ this.#forceKillTimeoutMs =
329
+ options.forceKillTimeoutMs ?? NATIVE_TERMINAL_FORCE_KILL_TIMEOUT_MS;
330
+ this.#forceKillPollIntervalMs =
331
+ options.forceKillPollIntervalMs ??
332
+ NATIVE_TERMINAL_FORCE_KILL_POLL_INTERVAL_MS;
333
+ this.#readLeaderStartTime =
334
+ options.readLeaderStartTime ?? readProcessStartTime;
335
+ this.#findGroupMemberToken =
336
+ options.findGroupMemberToken ?? groupCarriesSessionToken;
27
337
  }
28
338
  get generation() {
29
339
  return this.#generation;
30
340
  }
341
+ /**
342
+ * COUNTABLE per arch condition 1/2: the four DISCERNIBLE outcomes of the
343
+ * group-leader-identity guard, so "has this guard ever refused (and why),
344
+ * or ever proceeded WITHOUT the fast leader-start-time proof" is
345
+ * answerable later without re-deriving it from raw logs.
346
+ * `recycled`/`membershipUnprovable` are refusals (the kill was NOT
347
+ * emitted). `tokenVerified` is a PROVEN proceed via the group-membership
348
+ * token (the leader was absent, but a surviving member carried it) —
349
+ * distinct from the ordinary start-time-matched proceed, which is not
350
+ * counted at all (it is the expected common case). `unverifiedLegacy` is
351
+ * an UNPROVEN proceed (the kill WAS emitted) on a row with no baseline —
352
+ * neither a leader-start-time nor a group-token — to check against at
353
+ * all, the fail-open branch of `#verifyGroupLeaderIdentity`. Never
354
+ * collapsed: a proven kill and an unproven one must never look alike
355
+ * here, exactly as a refusal and a proceed must never look alike — see
356
+ * `NativeTerminalReapOutcome`'s `cause`/`verified`.
357
+ */
358
+ get pgidGuardCounters() {
359
+ return { ...this.#pgidGuardCounters };
360
+ }
31
361
  create(options) {
32
362
  if (options.id.trim().length === 0 ||
33
363
  options.id.length > NATIVE_TERMINAL_MAX_IDENTIFIER_CHARS) {
@@ -44,10 +374,56 @@ export class NativeTerminalHost {
44
374
  if (this.#sessions.size >= this.#maxSessions) {
45
375
  throw new Error(`terminal host session limit reached: ${this.#maxSessions}`);
46
376
  }
377
+ // A per-session token, injected into the spawned tree's OWN environment
378
+ // so every descendant inherits it — the group-MEMBERSHIP anchor for the
379
+ // case the leader's start-time cannot cover: the leader (pid==pgid)
380
+ // simply gone, group still alive, no containment ever triggered (an
381
+ // ordinary shell that exits normally leaving a backgrounded descendant).
382
+ // See `groupCarriesSessionToken`/`#verifyGroupLeaderIdentity`.
383
+ const groupToken = randomUUID();
384
+ const pty = this.#spawner({
385
+ command: options.command,
386
+ args: options.args,
387
+ cwd: options.cwd,
388
+ env: { ...options.env, [H2A_SESSION_TOKEN_ENV_VAR]: groupToken },
389
+ cols: options.cols,
390
+ rows: options.rows,
391
+ });
392
+ try {
393
+ // Persist BEFORE this session is usable: "known at creation" must
394
+ // survive to "known at kill time" (a fresh host after this one is
395
+ // killed has no other way to learn this session's pgid). A session
396
+ // whose pgid did not durably persist would be unreapable after a host
397
+ // crash — refuse to create it rather than leave an untracked child.
398
+ // The OWNER attribution (this host's own pid + start-time) rides along
399
+ // on the same durable write: it is what lets a LATER reconcile pass
400
+ // prove THIS host is dead (not just unreachable) before ever reaping
401
+ // this row — see `reconcileDeadHostOrphans`. The GROUP-LEADER's own
402
+ // start-time (leader pid == pgid) and the GROUP token both ride along
403
+ // the same write for the SAME reason one level down: they are what
404
+ // let a LATER kill re-prove the group itself is still the one this
405
+ // row was written for, not a recycled pgid — see
406
+ // `#verifyGroupLeaderIdentity`.
407
+ const ownStartTime = readProcessStartTime(process.pid);
408
+ const leaderStartTime = this.#readLeaderStartTime(pty.pgid);
409
+ persistNativeTerminalPgid(options.id, pty.pgid, this.#registryPath, {
410
+ pid: process.pid,
411
+ ...(ownStartTime === undefined ? {} : { startTime: ownStartTime }),
412
+ }, leaderStartTime, groupToken);
413
+ }
414
+ catch (error) {
415
+ try {
416
+ pty.kill("SIGKILL");
417
+ }
418
+ catch {
419
+ // Best-effort cleanup of the untracked child we are about to refuse.
420
+ }
421
+ throw new Error(`refusing to create terminal session ${options.id}: failed to durably persist its pgid (${String(error)})`);
422
+ }
47
423
  const record = {
48
424
  id: options.id,
49
425
  incarnation: randomUUID(),
50
- pty: this.#spawner(options),
426
+ pty,
51
427
  replay: new TerminalReplayBuffer(this.#replayBytesPerSession),
52
428
  status: "running",
53
429
  exit: null,
@@ -181,7 +557,17 @@ export class NativeTerminalHost {
181
557
  }
182
558
  return this.list();
183
559
  }
184
- forceStopAll(signal = "SIGKILL") {
560
+ /**
561
+ * Force-stop every non-exited session this host currently knows about — the
562
+ * FORCE path. Unlike `stop()`/`stopAll()` (which go through the pty's own
563
+ * kill(), i.e. the SIGUSR1->trap escalation on Linux — still fine for a
564
+ * graceful, cooperative stop), this emits the group SIGKILL itself, from
565
+ * the PARENT, and WAITS to PROVE the whole group is dead before returning —
566
+ * it does not return "done" on the strength of having merely signalled it.
567
+ * A poll timeout is reported as an error (an absence of information, not a
568
+ * claimed success), never silently swallowed.
569
+ */
570
+ async forceStopAll(signal = "SIGKILL") {
185
571
  const errors = [];
186
572
  for (const record of this.#sessions.values()) {
187
573
  if (record.status === "exited")
@@ -192,7 +578,10 @@ export class NativeTerminalHost {
192
578
  }
193
579
  record.stopSignal = signal;
194
580
  try {
195
- record.pty.kill(signal);
581
+ const outcome = await this.#killGroupAndConfirmDead(record.pty.pgid, signal, `session ${record.id}`);
582
+ if (outcome.status !== "dead") {
583
+ errors.push(new Error(`terminal session ${record.id} pgid=${record.pty.pgid} did not confirm dead within ${this.#forceKillTimeoutMs}ms`));
584
+ }
196
585
  }
197
586
  catch (error) {
198
587
  errors.push(error);
@@ -203,6 +592,193 @@ export class NativeTerminalHost {
203
592
  }
204
593
  return this.list();
205
594
  }
595
+ /**
596
+ * Reap a session BY ID from its durably persisted pgid, even though THIS
597
+ * host has no in-memory record of it — the case a fresh host faces after
598
+ * the host that created the session was killed. Never guesses a pgid: if
599
+ * it cannot be resolved (registry unreadable, or genuinely never recorded),
600
+ * this signals NOTHING and returns `"refused"` after logging a LOUD
601
+ * diagnostic — a silent no-op here would recreate the exact
602
+ * invisible-orphan bug this mechanism exists to close.
603
+ */
604
+ async reapOrphan(sessionId, signal = "SIGKILL") {
605
+ const lookup = readNativeTerminalPgid(sessionId, this.#registryPath);
606
+ if (lookup.status === "unresolved") {
607
+ this.#log(`REFUSING to reap terminal session ${sessionId}: pgid could not be resolved (${lookup.reason}). PROCESSES MAY HAVE SURVIVED — no group kill was issued.`);
608
+ return { sessionId, status: "refused", reason: lookup.reason };
609
+ }
610
+ const outcome = await this.#killGroupAndConfirmDead(lookup.pgid, signal, `orphan ${sessionId}`, {
611
+ sessionId,
612
+ persistedLeaderStartTime: lookup.leaderStartTime,
613
+ persistedGroupToken: lookup.groupToken,
614
+ });
615
+ if (outcome.status === "dead") {
616
+ return {
617
+ sessionId,
618
+ status: "reaped",
619
+ pgid: lookup.pgid,
620
+ elapsedMs: outcome.elapsedMs,
621
+ ...(outcome.verified !== undefined ? { verified: outcome.verified } : {}),
622
+ };
623
+ }
624
+ if (outcome.status === "timed-out") {
625
+ return { sessionId, status: "reap-timed-out", pgid: lookup.pgid, elapsedMs: outcome.elapsedMs };
626
+ }
627
+ return {
628
+ sessionId,
629
+ status: "refused",
630
+ reason: `group-leader identity could not be re-proven at kill-time (${outcome.cause}) for pgid=${lookup.pgid}`,
631
+ cause: outcome.cause,
632
+ };
633
+ }
634
+ /**
635
+ * INV-4 applied to the GROUP (mirrors the owning-host doctrine one level
636
+ * up — see `defaultOwnerHostProbe`): "known at creation" (the persisted
637
+ * `pgidLeaderStartTime`/`pgidGroupToken`) must be RE-PROVEN at the moment
638
+ * of acting, never merely inherited. POSITIVE proof only (INV-1) —
639
+ * ambiguity always REFUSES, never kills. In order:
640
+ *
641
+ * 1. Leader **readable**, no start-time baseline was ever persisted (a
642
+ * legacy row, or a write-time read failure) -> nothing to compare
643
+ * against -> cannot be checked for recycling at all, so this PROCEEDS
644
+ * rather than manufacture a refusal from missing data — the exact
645
+ * same asymmetry `defaultOwnerHostProbe` applies to a missing
646
+ * `ownerHostStartTime` (pre-fix status quo, not a regression).
647
+ * Cause `"unverified-legacy"`, `verified: false` — a kill taken here
648
+ * carries the SAME residual risk a `"recycled"` refusal exists to
649
+ * prevent, just un-checked; it must never be silently
650
+ * indistinguishable from a PROVEN match.
651
+ * 2. Leader **readable**, start-time **DIFFERENT** from the persisted
652
+ * baseline -> the OS reused `pgid` for an unrelated process since
653
+ * this row was written -> REFUSE, cause `"recycled"` (the defensive
654
+ * save: without this check, `killGroup` below would have signalled
655
+ * an innocent third party).
656
+ * 3. Leader **readable**, start-time **MATCHES** -> the group is
657
+ * provably the one this row was written for -> PROCEED,
658
+ * `verified: true`.
659
+ * 4. Leader **absent** (unreadable right now) -> there is no start-time
660
+ * left to compare at all — the ORDINARY orphan this whole mechanism
661
+ * exists for (a shell that exits normally leaving a backgrounded
662
+ * descendant: leader gone, group alive, no containment ever
663
+ * triggered). Falls to the group-MEMBERSHIP token instead:
664
+ * - no token baseline was ever persisted either (same "nothing to
665
+ * compare" asymmetry as branch 1) -> PROCEED, cause
666
+ * `"unverified-legacy"`, `verified: false`.
667
+ * - a persisted token exists AND `groupCarriesSessionToken` finds it
668
+ * on a surviving member -> POSITIVE proof of membership,
669
+ * independent of the leader -> PROCEED, `verified: true`, cause
670
+ * `"token-verified"`.
671
+ * - a persisted token exists and NO surviving member carries it ->
672
+ * cannot positively prove this group's identity by any means ->
673
+ * REFUSE, cause `"membership-unprovable"` (conservative
674
+ * refuse-and-leak: this may well be our OWN orphan, already fully
675
+ * dead, but we cannot tell that apart from a live unrelated group
676
+ * we simply cannot identify). DISTINCT from `"recycled"` in the
677
+ * union, its own counter, its own log — "positively someone
678
+ * else's" and "cannot prove it is ours" are different epistemic
679
+ * states.
680
+ */
681
+ #verifyGroupLeaderIdentity(pgid, persistedLeaderStartTime, persistedGroupToken, sessionId) {
682
+ const currentStartTime = this.#readLeaderStartTime(pgid);
683
+ if (currentStartTime !== undefined) {
684
+ if (persistedLeaderStartTime === undefined) {
685
+ this.#pgidGuardCounters.unverifiedLegacy += 1;
686
+ this.#log(`PROCEEDING to kill process group pgid=${pgid} for session ${sessionId} WITHOUT identity proof: no leader start-time baseline was ever persisted for this row (legacy) — cannot be checked for recycling at all. This kill is UNVERIFIED, not a proven match. cause=unverified-legacy sessionId=${sessionId} pgid=${pgid}`);
687
+ return { proceed: true, verified: false, cause: "unverified-legacy" };
688
+ }
689
+ if (currentStartTime !== persistedLeaderStartTime) {
690
+ this.#pgidGuardCounters.recycled += 1;
691
+ this.#log(`REFUSING to kill process group pgid=${pgid} for session ${sessionId}: current leader start-time (${currentStartTime}) DIFFERS from the persisted start-time (${persistedLeaderStartTime}) — pgid was RECYCLED to an unrelated process since this row was written. PROCESSES MAY SURVIVE UNCOLLECTED. cause=recycled sessionId=${sessionId} pgid=${pgid}`);
692
+ return { proceed: false, cause: "recycled" };
693
+ }
694
+ return { proceed: true, verified: true };
695
+ }
696
+ // Leader absent: fall to the group-carried session token — the net for
697
+ // an ordinary orphan whose leader is simply gone.
698
+ if (persistedGroupToken === undefined) {
699
+ this.#pgidGuardCounters.unverifiedLegacy += 1;
700
+ this.#log(`PROCEEDING to kill process group pgid=${pgid} for session ${sessionId} WITHOUT identity proof: the leader is unreadable AND no group-token baseline was ever persisted for this row (legacy) — cannot be checked for membership at all. This kill is UNVERIFIED, not a proven match. cause=unverified-legacy sessionId=${sessionId} pgid=${pgid}`);
701
+ return { proceed: true, verified: false, cause: "unverified-legacy" };
702
+ }
703
+ if (this.#findGroupMemberToken(pgid, persistedGroupToken)) {
704
+ this.#pgidGuardCounters.tokenVerified += 1;
705
+ this.#log(`PROCEEDING to kill process group pgid=${pgid} for session ${sessionId}: the leader is unreadable, but a surviving GROUP MEMBER carries the persisted session token — positive proof of membership. cause=token-verified sessionId=${sessionId} pgid=${pgid}`);
706
+ return { proceed: true, verified: true, cause: "token-verified" };
707
+ }
708
+ this.#pgidGuardCounters.membershipUnprovable += 1;
709
+ this.#log(`REFUSING to kill process group pgid=${pgid} for session ${sessionId}: the group leader is UNREADABLE and no surviving member carries the persisted session token — cannot positively prove this group's identity. PROCESSES MAY SURVIVE UNCOLLECTED. cause=membership-unprovable sessionId=${sessionId} pgid=${pgid}`);
710
+ return { proceed: false, cause: "membership-unprovable" };
711
+ }
712
+ /**
713
+ * Core of both `forceStopAll` and `reapOrphan`: emit the group signal from
714
+ * the parent, then poll `isGroupAlive` (never a fixed sleep-and-hope) until
715
+ * it reports the group empty or `#forceKillTimeoutMs` elapses. On timeout,
716
+ * capture a best-effort survivor list so the caller's diagnostic names
717
+ * exactly what is still alive instead of just "it didn't work".
718
+ *
719
+ * `verify`, when supplied (only `reapOrphan` supplies it — see INV-4 doc on
720
+ * `#verifyGroupLeaderIdentity`), re-proves the group's identity BEFORE the
721
+ * signal is ever emitted. `forceStopAll` never supplies it: it kills a
722
+ * session THIS host is still holding a live in-memory handle to, spawned
723
+ * in this very process — there is no "was this pgid recycled since we last
724
+ * looked" window to close there.
725
+ */
726
+ async #killGroupAndConfirmDead(pgid, signal, label, verify) {
727
+ if (!supportsProcessGroupSignals()) {
728
+ // No POSIX process groups on this platform; nothing more this host can
729
+ // prove. Documented gap, not a silent claim of success.
730
+ this.#log(`skipping group-kill proof for pgid=${pgid} (${label}): process groups are not supported on ${process.platform}`);
731
+ return { status: "dead", elapsedMs: 0 };
732
+ }
733
+ if (!this.#reaper.isGroupAlive(pgid)) {
734
+ // The group-leader-identity guard exists to protect a LIVE process
735
+ // about to be signalled from an innocent kill. A group the OS already
736
+ // positively reports empty (ESRCH — the same proof-of-death this
737
+ // method's own poll loop below relies on) has no such live process:
738
+ // kill(-pgid, sig) against zero members signals nobody, recycled pgid
739
+ // or not. Short-circuit BEFORE the guard (and before ever emitting a
740
+ // signal) rather than manufacture a refusal from a target that is not
741
+ // there to protect — this is a positive OS-confirmed fact, not a
742
+ // guess, so it does not weaken INV-1.
743
+ this.#log(`pgid=${pgid} already confirmed empty before any signal was emitted (${label})`);
744
+ return { status: "dead", elapsedMs: 0 };
745
+ }
746
+ let verified;
747
+ if (verify) {
748
+ const verdict = this.#verifyGroupLeaderIdentity(pgid, verify.persistedLeaderStartTime, verify.persistedGroupToken, verify.sessionId);
749
+ if (!verdict.proceed) {
750
+ return { status: "refused", cause: verdict.cause };
751
+ }
752
+ verified = verdict.verified;
753
+ }
754
+ const start = Date.now();
755
+ try {
756
+ this.#reaper.killGroup(pgid, signal);
757
+ this.#log(`emitted group ${signal} pgid=${pgid} (${label})`);
758
+ }
759
+ catch (error) {
760
+ this.#log(`failed to emit group ${signal} pgid=${pgid} (${label}): ${String(error)}`);
761
+ throw error;
762
+ }
763
+ for (;;) {
764
+ if (!this.#reaper.isGroupAlive(pgid)) {
765
+ const elapsedMs = Date.now() - start;
766
+ this.#log(`confirmed pgid=${pgid} reaped at ${elapsedMs}ms (${label})`);
767
+ return {
768
+ status: "dead",
769
+ elapsedMs,
770
+ ...(verified !== undefined ? { verified } : {}),
771
+ };
772
+ }
773
+ const elapsedMs = Date.now() - start;
774
+ if (elapsedMs >= this.#forceKillTimeoutMs) {
775
+ const survivors = this.#reaper.describeGroup(pgid);
776
+ this.#log(`pgid=${pgid} STILL ALIVE at ${elapsedMs}ms (${label}): ${survivors}`);
777
+ return { status: "timed-out", elapsedMs };
778
+ }
779
+ await delay(this.#forceKillPollIntervalMs);
780
+ }
781
+ }
206
782
  #requireSession(id) {
207
783
  const record = this.#sessions.get(id);
208
784
  if (!record)
@@ -269,7 +845,124 @@ export class NativeTerminalHost {
269
845
  latestSeq: record.replay.latestSeq,
270
846
  exit: record.exit,
271
847
  stopSignal: record.stopSignal,
848
+ controlled: record.controllerId !== null,
272
849
  });
273
850
  }
274
851
  }
852
+ /**
853
+ * Reap orphan PTY groups left behind by a PROVEN-DEAD host — the ONE
854
+ * production trigger for `NativeTerminalHost#reapOrphan`. Called at
855
+ * supervisor takeover (see `NativeTerminalHostSupervisor`), the moment a
856
+ * fresh host is about to be spawned because the previous one could not be
857
+ * reached: exactly the moment a stale registry might exist, and exactly the
858
+ * moment this must NOT be mistaken for "the old host is dead".
859
+ *
860
+ * This mirrors `reapOrphan`'s own conservatism one level up: a lost
861
+ * connection to a host is the UNKNOWN, not proof of death. An
862
+ * unreachable-but-alive host (overloaded, a saturated socket, a paused
863
+ * process) is ALIVE and its sessions are live work — reaping there would be
864
+ * a mass-kill dressed up as cleanup. So this function enumerates EVERY
865
+ * durably-persisted native-terminal-pty row and, for EACH ONE
866
+ * INDEPENDENTLY, proves whether ITS OWNING HOST (not "the host we were just
867
+ * talking to") is dead before touching it:
868
+ *
869
+ * - registry unresolvable (`listNativeTerminalPgidEntries` reports
870
+ * `known:false`) -> reap NOTHING, loud refusal, `status:"refused"`.
871
+ * - an entry with no owner attribution recorded (a legacy row written
872
+ * before this fix, or a write-time start-time-read failure) -> cannot
873
+ * prove death -> skip.
874
+ * - `ownerProbe` reports the owner "alive" or "unresolvable" -> skip
875
+ * (unresolvable is treated exactly like alive: never guess).
876
+ * - `ownerProbe` reports "dead" (pid gone, or recycled — a live pid whose
877
+ * start-time no longer matches) -> reap that entry's orphan process group
878
+ * via `reap`, then prune the entry once the reap is CONFIRMED (`"reaped"`
879
+ * only — a `"reap-timed-out"` leaves the row for a future pass instead of
880
+ * losing the only durable record of an unconfirmed pgid).
881
+ *
882
+ * Best-effort per entry: one entry's failure does not stop the pass over the
883
+ * rest (each entry is an independent host/session; a single problem entry
884
+ * must never mask the others).
885
+ */
886
+ export async function reconcileDeadHostOrphans(options = {}) {
887
+ const signal = options.signal ?? "SIGKILL";
888
+ const ownerProbe = options.ownerProbe ?? defaultOwnerHostProbe;
889
+ const log = options.log ??
890
+ ((line) => {
891
+ process.stderr.write(`[h2a-pty-reconcile] ${line}\n`);
892
+ });
893
+ const reap = options.reap ??
894
+ (() => {
895
+ const reconcileHost = new NativeTerminalHost({
896
+ generation: `reconcile-${randomUUID()}`,
897
+ replayBytesPerSession: 1,
898
+ spawner: () => {
899
+ throw new Error("reconcile host must never spawn a pty");
900
+ },
901
+ ...(options.registryPath !== undefined ? { registryPath: options.registryPath } : {}),
902
+ log,
903
+ });
904
+ return (sessionId, _pgid, sig) => reconcileHost.reapOrphan(sessionId, sig);
905
+ })();
906
+ const snapshot = listNativeTerminalPgidEntries(options.registryPath);
907
+ if (!snapshot.known) {
908
+ log(`REFUSING to reconcile native-terminal orphans: registry unreadable (${snapshot.reason}). NOTHING was reaped.`);
909
+ return { status: "refused", reason: snapshot.reason };
910
+ }
911
+ const outcomes = [];
912
+ for (const entry of snapshot.entries) {
913
+ if (!entry.owner) {
914
+ log(`skipping native-terminal session ${entry.sessionId}: no owning-host attribution recorded (legacy row) — cannot prove death, reaping nothing.`);
915
+ outcomes.push({ sessionId: entry.sessionId, status: "skipped-no-owner" });
916
+ continue;
917
+ }
918
+ const owner = entry.owner;
919
+ const verdict = ownerProbe(owner);
920
+ if (verdict === "alive") {
921
+ log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is still alive — reaping nothing.`);
922
+ outcomes.push({ sessionId: entry.sessionId, status: "skipped-alive", ownerPid: owner.pid });
923
+ continue;
924
+ }
925
+ if (verdict === "unresolvable") {
926
+ log(`skipping native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} liveness is unresolvable — treating conservatively as alive, reaping nothing.`);
927
+ outcomes.push({
928
+ sessionId: entry.sessionId,
929
+ status: "skipped-unresolvable",
930
+ ownerPid: owner.pid,
931
+ });
932
+ continue;
933
+ }
934
+ // verdict === "dead": PROVEN — the owner pid is gone, or was recycled.
935
+ log(`native-terminal session ${entry.sessionId}: owning host pid=${owner.pid} is PROVEN DEAD — reaping its orphan process group pgid=${entry.pgid}.`);
936
+ const outcome = await reap(entry.sessionId, entry.pgid, signal);
937
+ if (outcome.status === "reaped") {
938
+ pruneNativeTerminalPgidEntry(entry.sessionId, options.registryPath);
939
+ outcomes.push({
940
+ sessionId: entry.sessionId,
941
+ status: "reaped",
942
+ ownerPid: owner.pid,
943
+ pgid: entry.pgid,
944
+ });
945
+ }
946
+ else if (outcome.status === "reap-timed-out") {
947
+ log(`native-terminal session ${entry.sessionId}: reap did not confirm dead within the timeout — leaving the record for a future pass.`);
948
+ outcomes.push({
949
+ sessionId: entry.sessionId,
950
+ status: "reap-timed-out",
951
+ ownerPid: owner.pid,
952
+ pgid: entry.pgid,
953
+ });
954
+ }
955
+ else {
956
+ log(`native-terminal session ${entry.sessionId}: reap refused (${outcome.reason}).`);
957
+ outcomes.push({
958
+ sessionId: entry.sessionId,
959
+ status: "reap-refused",
960
+ ownerPid: owner.pid,
961
+ pgid: entry.pgid,
962
+ reason: outcome.reason,
963
+ });
964
+ }
965
+ }
966
+ return { status: "completed", outcomes };
967
+ }
275
968
  //# sourceMappingURL=host.js.map