@sentropic/h2a-runtime 0.98.0 → 0.98.2

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 (69) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +52 -18
  3. package/dist/index.js.map +1 -1
  4. package/dist/launch-guard.d.ts +3 -6
  5. package/dist/launch-guard.d.ts.map +1 -1
  6. package/dist/launch-guard.js +6 -4
  7. package/dist/launch-guard.js.map +1 -1
  8. package/dist/launch-layout.d.ts +2 -0
  9. package/dist/launch-layout.d.ts.map +1 -1
  10. package/dist/launch-layout.js +15 -2
  11. package/dist/launch-layout.js.map +1 -1
  12. package/dist/native-host.d.ts +79 -14
  13. package/dist/native-host.d.ts.map +1 -1
  14. package/dist/native-host.js +218 -64
  15. package/dist/native-host.js.map +1 -1
  16. package/dist/native-restart.d.ts +1 -0
  17. package/dist/native-restart.d.ts.map +1 -1
  18. package/dist/native-restart.js.map +1 -1
  19. package/dist/native-terminal/attach-recovery.d.ts +9 -0
  20. package/dist/native-terminal/attach-recovery.d.ts.map +1 -0
  21. package/dist/native-terminal/attach-recovery.js +54 -0
  22. package/dist/native-terminal/attach-recovery.js.map +1 -0
  23. package/dist/native-terminal/fleet.d.ts +32 -0
  24. package/dist/native-terminal/fleet.d.ts.map +1 -0
  25. package/dist/native-terminal/fleet.js +40 -0
  26. package/dist/native-terminal/fleet.js.map +1 -0
  27. package/dist/native-terminal/host.d.ts +1 -0
  28. package/dist/native-terminal/host.d.ts.map +1 -1
  29. package/dist/native-terminal/host.js +5 -2
  30. package/dist/native-terminal/host.js.map +1 -1
  31. package/dist/native-terminal/journal.d.ts +48 -0
  32. package/dist/native-terminal/journal.d.ts.map +1 -0
  33. package/dist/native-terminal/journal.js +305 -0
  34. package/dist/native-terminal/journal.js.map +1 -0
  35. package/dist/native-terminal/op.d.ts +3 -0
  36. package/dist/native-terminal/op.d.ts.map +1 -1
  37. package/dist/native-terminal/op.js +279 -93
  38. package/dist/native-terminal/op.js.map +1 -1
  39. package/dist/native-terminal/process.d.ts.map +1 -1
  40. package/dist/native-terminal/process.js +134 -46
  41. package/dist/native-terminal/process.js.map +1 -1
  42. package/dist/native-terminal/replay-buffer.d.ts +3 -0
  43. package/dist/native-terminal/replay-buffer.d.ts.map +1 -1
  44. package/dist/native-terminal/replay-buffer.js +11 -0
  45. package/dist/native-terminal/replay-buffer.js.map +1 -1
  46. package/dist/native-terminal/reset-relay.d.ts +11 -0
  47. package/dist/native-terminal/reset-relay.d.ts.map +1 -0
  48. package/dist/native-terminal/reset-relay.js +38 -0
  49. package/dist/native-terminal/reset-relay.js.map +1 -0
  50. package/dist/native-terminal/server.d.ts +8 -0
  51. package/dist/native-terminal/server.d.ts.map +1 -1
  52. package/dist/native-terminal/server.js +535 -58
  53. package/dist/native-terminal/server.js.map +1 -1
  54. package/dist/native-terminal/socket-path.d.ts +3 -0
  55. package/dist/native-terminal/socket-path.d.ts.map +1 -1
  56. package/dist/native-terminal/socket-path.js +21 -1
  57. package/dist/native-terminal/socket-path.js.map +1 -1
  58. package/dist/native-terminal/terminal-modes.d.ts +15 -0
  59. package/dist/native-terminal/terminal-modes.d.ts.map +1 -0
  60. package/dist/native-terminal/terminal-modes.js +180 -0
  61. package/dist/native-terminal/terminal-modes.js.map +1 -0
  62. package/dist/restore.d.ts.map +1 -1
  63. package/dist/restore.js +8 -2
  64. package/dist/restore.js.map +1 -1
  65. package/dist/tmux.d.ts +20 -2
  66. package/dist/tmux.d.ts.map +1 -1
  67. package/dist/tmux.js +14 -6
  68. package/dist/tmux.js.map +1 -1
  69. package/package.json +1 -1
@@ -29,15 +29,20 @@
29
29
  * (detach: Ctrl-\\ ; exits when session exits)
30
30
  * host-stop SIGTERM the host process (sessions stop)
31
31
  */
32
- import { readFileSync } from "node:fs";
32
+ import { existsSync, readFileSync, writeSync } from "node:fs";
33
33
  import { dirname } from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  import { NativeTerminalClient } from "./client.js";
36
36
  import { loadRegistry, registryEntriesForNativeTarget } from "../registry.js";
37
- import { NATIVE_TERMINAL_INTERACTIVE_READ_TIMEOUT_MS, NativeTerminalRemoteError, } from "./protocol.js";
37
+ import { NATIVE_TERMINAL_INTERACTIVE_READ_TIMEOUT_MS, NATIVE_TERMINAL_PROTOCOL_VERSION, NativeTerminalRemoteError, } from "./protocol.js";
38
38
  import { NativeTerminalHostSupervisor } from "./supervisor.js";
39
- import { defaultNativeTerminalSocketPath } from "./socket-path.js";
39
+ import { proveNativeTerminalEndpointAbsent } from "./server.js";
40
+ import { defaultNativeTerminalSocketPath, knownNativeTerminalSocketPaths, inspectPrivateNativeTerminalSocket, sameNativeTerminalSocket } from "./socket-path.js";
41
+ import { collectNativeInventory, resolveNativeOwner } from "./fleet.js";
40
42
  import { renderTerminalScreen } from "./screen.js";
43
+ import { TerminalModeTracker } from "./terminal-modes.js";
44
+ import { publishAttachReset } from "./attach-recovery.js";
45
+ import { TerminalResetRelay } from "./reset-relay.js";
41
46
  const DETACH_BYTE = 0x1c; // Ctrl-\
42
47
  function parseArgv(argv) {
43
48
  const op = argv[2];
@@ -95,11 +100,81 @@ export function closeAllNativeTerminalOpClients() {
95
100
  client.close();
96
101
  openClients.clear();
97
102
  }
98
- async function connectExisting(socketPath) {
103
+ async function connectExisting(socketPath, tracked = true) {
104
+ const identity = await inspectPrivateNativeTerminalSocket(socketPath);
99
105
  const client = await NativeTerminalClient.connect(socketPath);
100
- openClients.add(client);
106
+ if (tracked)
107
+ openClients.add(client);
108
+ try {
109
+ const ping = await client.ping();
110
+ if (ping.protocolVersion !== NATIVE_TERMINAL_PROTOCOL_VERSION || !ping.generation?.trim() ||
111
+ !Number.isSafeInteger(ping.hostPid) || ping.hostPid <= 0 ||
112
+ !sameNativeTerminalSocket(identity, await inspectPrivateNativeTerminalSocket(socketPath))) {
113
+ throw new Error("native host identity changed or handshake is invalid");
114
+ }
115
+ }
116
+ catch (error) {
117
+ client.close();
118
+ throw error;
119
+ }
101
120
  return client;
102
121
  }
122
+ class NativeOwnerError extends Error {
123
+ refusal;
124
+ constructor(refusal) {
125
+ super(`native session ${refusal.id}: ${refusal.code}${refusal.reason ? ` (${refusal.reason})` : ""}`);
126
+ this.refusal = refusal;
127
+ }
128
+ }
129
+ async function inventoryFor(parsed) {
130
+ const pinned = parsed.flags.get("socket");
131
+ return collectNativeInventory(pinned ? [pinned] : knownNativeTerminalSocketPaths(), connectExisting, proveNativeTerminalEndpointAbsent);
132
+ }
133
+ async function owningClient(parsed, id) {
134
+ const owner = resolveNativeOwner(id, await inventoryFor(parsed));
135
+ if (owner.state !== "found")
136
+ throw new NativeOwnerError({ code: owner.state, id,
137
+ ...(owner.state === "unknown" ? { reason: owner.reason } : {}),
138
+ ...(owner.state === "ambiguous-owner" ? { sockets: owner.sockets } : {}) });
139
+ return owner;
140
+ }
141
+ async function selectLaunchClient(fenced) {
142
+ const paths = knownNativeTerminalSocketPaths();
143
+ const imposed = process.env["H2A_NATIVE_SOCKET"];
144
+ if (imposed) {
145
+ const client = await launchClient(imposed);
146
+ // An imposed endpoint can never silently redirect, even when its mate is
147
+ // available. Capability certification is performed by the caller.
148
+ return { client, socketPath: imposed };
149
+ }
150
+ const historical = paths[0];
151
+ const client = await launchClient(historical);
152
+ const ping = await client.ping();
153
+ if (!fenced || paths.length === 1)
154
+ return { client, socketPath: historical };
155
+ const compatible = paths[1];
156
+ // Initialize the second known endpoint so admission obtains a complete
157
+ // inventory even when the historical host already provides the fence.
158
+ const second = await launchClient(compatible);
159
+ return ping.launchFence === true ? { client, socketPath: historical } : { client: second, socketPath: compatible };
160
+ }
161
+ async function launchClient(socketPath) {
162
+ try {
163
+ return await connectExisting(socketPath);
164
+ }
165
+ catch (error) {
166
+ let absent = false;
167
+ try {
168
+ absent = await proveNativeTerminalEndpointAbsent(socketPath, error);
169
+ }
170
+ catch (proofError) {
171
+ error = new Error(`${error instanceof Error ? error.message : String(error)}; absence proof failed: ${proofError instanceof Error ? proofError.message : String(proofError)}`);
172
+ }
173
+ if (absent)
174
+ return ensureClient(socketPath);
175
+ throw Object.assign(new Error(`${socketPath}: ${error instanceof Error ? error.message : String(error)}; owner death is unproven. Inspect endpoint permissions and the owning process identity, restore probe access, then retry; do not remove a socket or lock while ownership is unproven.`), { socketPath });
176
+ }
177
+ }
103
178
  async function ensureClient(socketPath) {
104
179
  const supervisor = new NativeTerminalHostSupervisor({
105
180
  socketPath,
@@ -176,17 +251,37 @@ const ATTACH_RECONNECT_MIN_MS = 100;
176
251
  const ATTACH_RECONNECT_MAX_MS = 2_000;
177
252
  const ATTACH_PENDING_INPUT_MAX_BYTES = 64 * 1024;
178
253
  function defaultAttachRuntime(socketPath) {
254
+ const signals = { SIGTERM: 15, SIGHUP: 1, SIGINT: 2, SIGQUIT: 3 };
255
+ const handlers = new Map();
179
256
  return {
180
257
  // runAttach owns and closes every reconnecting client. Keeping these
181
258
  // long-lived clients in the one-shot op registry would retain each closed
182
259
  // connection until the user eventually detaches.
183
- connect: () => NativeTerminalClient.connect(socketPath),
260
+ connect: () => connectExisting(socketPath, false),
184
261
  stdin: process.stdin,
185
262
  stdout: process.stdout,
186
263
  now: () => Date.now(),
187
264
  delay,
188
265
  onResize: (listener) => process.on("SIGWINCH", listener),
189
266
  offResize: (listener) => process.removeListener("SIGWINCH", listener),
267
+ saveTerminalReset: publishAttachReset,
268
+ onTerminate: (listener) => {
269
+ for (const [signal, number] of Object.entries(signals)) {
270
+ const handler = () => { listener(); process.exit(128 + number); };
271
+ handlers.set(signal, handler);
272
+ process.on(signal, handler);
273
+ }
274
+ process.on("exit", listener);
275
+ // Observe the fatal path without suppressing Node's error/exit behavior.
276
+ process.on("uncaughtExceptionMonitor", listener);
277
+ },
278
+ offTerminate: (listener) => {
279
+ for (const [signal, handler] of handlers)
280
+ process.removeListener(signal, handler);
281
+ handlers.clear();
282
+ process.removeListener("exit", listener);
283
+ process.removeListener("uncaughtExceptionMonitor", listener);
284
+ },
190
285
  };
191
286
  }
192
287
  function isDefinitiveNativeTerminalAbsence(error) {
@@ -205,10 +300,50 @@ function isDefinitiveNativeTerminalAbsence(error) {
205
300
  export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFromEnv())) {
206
301
  const stdin = runtime.stdin;
207
302
  const stdout = runtime.stdout;
303
+ let modes = new TerminalModeTracker();
304
+ let modesSuspended = false;
305
+ let savedReset = "";
306
+ const saveReset = () => {
307
+ const reset = modesSuspended ? "" : modes.resetSequence();
308
+ if (reset === savedReset)
309
+ return;
310
+ runtime.saveTerminalReset?.(reset);
311
+ savedReset = reset;
312
+ };
313
+ const relayOutput = (data) => {
314
+ modes.feed(data);
315
+ saveReset();
316
+ stdout.write(data);
317
+ };
318
+ // Older protocol-v1 hosts relay the wrapper marker verbatim. New hosts
319
+ // consume it themselves so detached sessions also return to a clean shell.
320
+ let resetRelay = new TerminalResetRelay(relayOutput, () => modes.resetSequence());
321
+ const resetModes = () => {
322
+ if (modesSuspended)
323
+ return;
324
+ modesSuspended = true;
325
+ const reset = modes.resetSequence();
326
+ if (!reset) {
327
+ saveReset();
328
+ return;
329
+ }
330
+ // Signals and fatal exits cannot wait for an asynchronous pipe flush.
331
+ const fd = stdout.fd;
332
+ if (typeof fd === "number")
333
+ writeSync(fd, reset);
334
+ else
335
+ stdout.write(reset);
336
+ saveReset();
337
+ };
338
+ const resumeModes = () => {
339
+ if (!modesSuspended)
340
+ return;
341
+ modesSuspended = false;
342
+ saveReset();
343
+ stdout.write(modes.restoreSequence() + modes.replayPrefix());
344
+ };
208
345
  const isRawCapable = stdin.isTTY === true;
209
- if (isRawCapable)
210
- stdin.setRawMode(true);
211
- stdin.resume();
346
+ const wasRaw = stdin.isRaw === true;
212
347
  let detached = false;
213
348
  let terminalEnded = false;
214
349
  let exitCode = 0;
@@ -223,6 +358,20 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
223
358
  active = undefined;
224
359
  current?.client.close();
225
360
  };
361
+ const terminate = () => {
362
+ detached = true;
363
+ try {
364
+ resetRelay.flush();
365
+ resetModes();
366
+ }
367
+ finally {
368
+ if (isRawCapable)
369
+ stdin.setRawMode(wasRaw);
370
+ stdin.pause();
371
+ closeActive();
372
+ }
373
+ };
374
+ runtime.onTerminate?.(terminate);
226
375
  const markTransportFailure = (connection, error, inputDeliveryUncertain = false) => {
227
376
  if (inputDeliveryUncertain && !detached) {
228
377
  stdout.write("\r\n[h2a] terminal input delivery became uncertain during reconnect; " +
@@ -287,6 +436,8 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
287
436
  pumpInput();
288
437
  };
289
438
  stdin.on("data", onInput);
439
+ const onEnd = () => { detached = true; closeActive(); };
440
+ stdin.on("end", onEnd);
290
441
  const resize = () => {
291
442
  const current = active;
292
443
  if (current && stdout.columns && stdout.rows) {
@@ -319,6 +470,9 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
319
470
  // Paint the existing scrollback once, then follow the stream.
320
471
  let seq = 0;
321
472
  try {
473
+ if (isRawCapable)
474
+ stdin.setRawMode(true);
475
+ stdin.resume();
322
476
  const initial = await connectAndAcquire();
323
477
  if (initial.status === "exited")
324
478
  return initial.exit?.exitCode ?? 0;
@@ -334,6 +488,7 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
334
488
  "without restarting the session\r\n");
335
489
  recoveryAnnounced = true;
336
490
  }
491
+ resetModes();
337
492
  let reconnectDelayMs = ATTACH_RECONNECT_MIN_MS;
338
493
  for (;;) {
339
494
  if (detached)
@@ -346,6 +501,7 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
346
501
  break;
347
502
  }
348
503
  stdout.write("\r\n[h2a] native terminal reconnected\r\n");
504
+ resumeModes();
349
505
  recoveryAnnounced = false;
350
506
  idlePollMs = ATTACH_IDLE_POLL_MIN_MS;
351
507
  nextStatePollAt = runtime.now() + ATTACH_STATE_POLL_MS;
@@ -353,7 +509,6 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
353
509
  }
354
510
  catch (error) {
355
511
  if (isDefinitiveNativeTerminalAbsence(error)) {
356
- stdout.write("\r\n[h2a] native session is no longer available; attach stopped\r\n");
357
512
  exitCode = 1;
358
513
  terminalEnded = true;
359
514
  break;
@@ -369,10 +524,22 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
369
524
  const current = active;
370
525
  try {
371
526
  const replay = await current.client.readOutput(id, seq, NATIVE_TERMINAL_INTERACTIVE_READ_TIMEOUT_MS);
527
+ if (detached)
528
+ break;
529
+ if (replay.gap) {
530
+ resetModes();
531
+ modes = new TerminalModeTracker();
532
+ resetRelay = new TerminalResetRelay(relayOutput, () => modes.resetSequence());
533
+ modesSuspended = false;
534
+ if (replay.terminalModePrefix) {
535
+ resetRelay.feed(replay.terminalModePrefix);
536
+ }
537
+ }
372
538
  for (const chunk of replay.chunks) {
373
- stdout.write(chunk.data);
539
+ resetRelay.feed(chunk.data);
374
540
  seq = chunk.seq;
375
541
  }
542
+ seq = replay.latestSeq;
376
543
  if (replay.chunks.length === 0) {
377
544
  await runtime.delay(idlePollMs);
378
545
  idlePollMs = Math.min(ATTACH_IDLE_POLL_MAX_MS, idlePollMs * 2);
@@ -406,55 +573,65 @@ export async function runAttach(id, runtime = defaultAttachRuntime(socketPathFro
406
573
  }
407
574
  }
408
575
  finally {
409
- runtime.offResize(resize);
410
- stdin.removeListener("data", onInput);
411
- if (isRawCapable)
412
- stdin.setRawMode(false);
413
- stdin.pause();
414
- if (inputPump !== undefined)
415
- await inputPump.catch(() => { });
416
- const current = active;
417
- active = undefined;
418
- if (current !== undefined) {
419
- await current.client.releaseController(current.lease).catch(() => { });
420
- current.client.close();
576
+ try {
577
+ resetRelay.flush();
578
+ if (detached)
579
+ stdout.write("\r\n[h2a] detached from native session (session keeps running)\r\n");
580
+ resetModes();
581
+ }
582
+ finally {
583
+ runtime.offTerminate?.(terminate);
584
+ runtime.offResize(resize);
585
+ stdin.removeListener("data", onInput);
586
+ stdin.removeListener("end", onEnd);
587
+ if (isRawCapable)
588
+ stdin.setRawMode(wasRaw);
589
+ stdin.pause();
590
+ if (inputPump !== undefined)
591
+ await inputPump.catch(() => { });
592
+ const current = active;
593
+ active = undefined;
594
+ if (current !== undefined) {
595
+ await current.client.releaseController(current.lease).catch(() => { });
596
+ current.client.close();
597
+ }
421
598
  }
422
- }
423
- if (detached) {
424
- stdout.write("\r\n[h2a] detached from native session (session keeps running)\r\n");
425
599
  }
426
600
  return exitCode;
427
601
  }
428
602
  export async function runNativeTerminalOp(argv) {
429
603
  const parsed = parseArgv(argv);
430
- const socketPath = socketPathFromEnv();
604
+ const socketPath = parsed.flags.get("socket") ?? socketPathFromEnv();
431
605
  switch (parsed.op) {
432
606
  case "ensure-host": {
433
- const client = await ensureClient(socketPath);
607
+ let selected;
608
+ try {
609
+ selected = parsed.flags.has("socket") ? { client: await connectExisting(socketPath), socketPath }
610
+ : await selectLaunchClient(parsed.flags.get("fenced") === "true");
611
+ }
612
+ catch (error) {
613
+ emit({ code: "native-inventory-unknown", hosts: [{ socketPath: error?.socketPath ?? socketPath,
614
+ reason: error instanceof Error ? error.message : String(error) }] });
615
+ return 1;
616
+ }
617
+ const { client } = selected;
434
618
  const ping = await client.ping();
435
619
  client.close();
436
- emit({ hostPid: ping.hostPid, generation: ping.generation, socketPath, launchFence: ping.launchFence === true });
620
+ emit({ hostPid: ping.hostPid, generation: ping.generation, socketPath: selected.socketPath, launchFence: ping.launchFence === true });
437
621
  return 0;
438
622
  }
439
623
  case "list": {
440
- let client;
441
- try {
442
- client = await connectExisting(socketPath);
443
- }
444
- catch {
445
- emit({ sessions: [] });
446
- return 0;
447
- }
448
- const sessions = await client.list();
449
- client.close();
450
- emit({ sessions });
624
+ const inventory = await inventoryFor(parsed);
625
+ emit({ sessions: inventory.sessions, complete: inventory.complete,
626
+ hosts: inventory.hosts.map(({ socketPath, reason, absent }) => ({ socketPath, reachable: !absent && reason === undefined, ...(absent ? { absent } : {}), ...(reason ? { reason } : {}) })) });
451
627
  return 0;
452
628
  }
453
629
  case "state": {
454
- const client = await connectExisting(socketPath);
630
+ const owner = await owningClient(parsed, required(parsed, "id"));
631
+ const { client } = owner;
455
632
  const state = await client.state(required(parsed, "id"));
456
633
  client.close();
457
- emit(state);
634
+ emit({ ...state, socketPath: owner.socketPath });
458
635
  return 0;
459
636
  }
460
637
  case "probe": {
@@ -462,53 +639,46 @@ export async function runNativeTerminalOp(argv) {
462
639
  // generic nonzero exit, so the synchronous caller never has to guess
463
640
  // whether a failure meant "no session" or "the op broke":
464
641
  // - live: a reachable host answered and the session is running
465
- // - dead: POSITIVE proof — a reachable host does not know the
466
- // session, or no host is listening at all (a PTY cannot
467
- // outlive its host process — the tmux-server death rule)
468
- // - unknown: anything else; never proof of death.
642
+ // - dead: every known reachable endpoint proves absence, or the
643
+ // owning endpoint reports an exited session
644
+ // - unknown: an incomplete inventory without a found owner; even
645
+ // transport errors without an independent owner-death proof.
469
646
  const id = required(parsed, "id");
470
- let client;
471
- try {
472
- client = await connectExisting(socketPath);
473
- }
474
- catch (error) {
475
- const code = error.code;
476
- if (code === "ECONNREFUSED" || code === "ENOENT") {
477
- emit({ verdict: "dead", reason: `native host is not running (${code})` });
478
- }
479
- else {
480
- emit({
481
- verdict: "unknown",
482
- reason: error instanceof Error ? error.message : String(error),
483
- });
484
- }
485
- return 0;
486
- }
487
- try {
488
- const state = await client.state(id);
489
- emit({
490
- verdict: state.status === "running" ? "live" : "dead",
491
- state,
492
- });
493
- }
494
- catch (error) {
495
- const message = error instanceof Error ? error.message : String(error);
496
- // The host's own "unknown terminal session" refusal is a POSITIVE
497
- // absence verdict from a reachable host; any other remote/protocol
498
- // error proves nothing (fail closed on the caller side).
499
- emit(error instanceof NativeTerminalRemoteError &&
500
- message.includes("unknown terminal session")
501
- ? { verdict: "dead", reason: message }
502
- : { verdict: "unknown", reason: message });
503
- }
504
- finally {
505
- client.close();
506
- }
647
+ const owner = resolveNativeOwner(id, await inventoryFor(parsed));
648
+ if (owner.state === "found")
649
+ emit({ verdict: owner.session.status === "running" ? "live" : "dead",
650
+ state: { ...owner.session, socketPath: owner.socketPath } });
651
+ else if (owner.state === "absent")
652
+ emit({ verdict: "dead" });
653
+ else
654
+ emit({ verdict: "unknown", code: owner.state,
655
+ ...(owner.state === "unknown" ? { reason: owner.reason } : { sockets: owner.sockets, reason: "ambiguous-owner" }) });
656
+ return 0;
657
+ }
658
+ case "admit": {
659
+ const inventory = await inventoryFor({ ...parsed, flags: new Map() });
660
+ const ids = [required(parsed, "id"), parsed.flags.get("sidecar")].filter((id) => !!id);
661
+ const collision = inventory.sessions.find(session => ids.includes(session.id) &&
662
+ (session.status !== "exited" || session.socketPath !== parsed.flags.get("launch-socket")));
663
+ emit(collision ? { code: "native-name-collision", id: collision.id, socketPath: collision.socketPath }
664
+ : !inventory.complete ? { code: "native-inventory-unknown", hosts: inventory.hosts.map(({ socketPath, reason }) => ({ socketPath, reason })) }
665
+ : { admitted: true });
507
666
  return 0;
508
667
  }
509
668
  case "create": {
510
- const client = await ensureClient(socketPath);
669
+ const client = parsed.flags.has("socket") ? await connectExisting(socketPath) : await ensureClient(socketPath);
511
670
  const id = required(parsed, "id");
671
+ if (parsed.flags.has("incarnation") || socketPath.endsWith("/native-terminal.lf1.sock")) {
672
+ if (parsed.flags.has("incarnation") && (await client.ping()).launchFence !== true)
673
+ throw new Error("selected native host does not provide launchFence");
674
+ const inventory = await inventoryFor({ ...parsed, flags: new Map() });
675
+ const ids = [id, ...(parsed.flags.has("sidecar") ? [required(parsed, "sidecar")]
676
+ : !id.endsWith(".h2a") ? [`${id}.h2a`] : [])];
677
+ if (!inventory.complete || inventory.sessions.some(session => ids.includes(session.id) &&
678
+ (session.status !== "exited" || session.socketPath !== socketPath))) {
679
+ throw new Error("native creation admission changed; refusing possible name collision");
680
+ }
681
+ }
512
682
  const cwd = required(parsed, "cwd");
513
683
  const cols = Number(parsed.flags.get("cols") ?? 160);
514
684
  const rows = Number(parsed.flags.get("rows") ?? 48);
@@ -535,7 +705,8 @@ export async function runNativeTerminalOp(argv) {
535
705
  return 0;
536
706
  }
537
707
  case "drive": {
538
- const target = nativeDriveSessionId(required(parsed, "target"));
708
+ const target = parsed.flags.has("id")
709
+ ? { state: "found", id: required(parsed, "id") } : nativeDriveSessionId(required(parsed, "target"));
539
710
  if (target.state === "unresolved") {
540
711
  emit({ outcome: "unresolved" });
541
712
  return 0;
@@ -552,7 +723,7 @@ export async function runNativeTerminalOp(argv) {
552
723
  });
553
724
  return 0;
554
725
  }
555
- const client = await connectExisting(socketPath);
726
+ const { client } = await owningClient(parsed, target.id);
556
727
  try {
557
728
  let lease;
558
729
  try {
@@ -581,8 +752,8 @@ export async function runNativeTerminalOp(argv) {
581
752
  }
582
753
  case "write":
583
754
  case "paste": {
584
- const client = await connectExisting(socketPath);
585
755
  const id = required(parsed, "id");
756
+ const { client } = await owningClient(parsed, id);
586
757
  const text = Buffer.from(required(parsed, "b64"), "base64").toString("utf8");
587
758
  await withController(client, id, async (lease) => {
588
759
  if (parsed.op === "paste") {
@@ -598,14 +769,14 @@ export async function runNativeTerminalOp(argv) {
598
769
  return 0;
599
770
  }
600
771
  case "enter": {
601
- const client = await connectExisting(socketPath);
772
+ const { client } = await owningClient(parsed, required(parsed, "id"));
602
773
  await withController(client, required(parsed, "id"), (lease) => client.write(lease, "\r"));
603
774
  client.close();
604
775
  emit({ ok: true });
605
776
  return 0;
606
777
  }
607
778
  case "capture": {
608
- const client = await connectExisting(socketPath);
779
+ const { client } = await owningClient(parsed, required(parsed, "id"));
609
780
  const raw = await readAll(client, required(parsed, "id"));
610
781
  client.close();
611
782
  const budget = Number(parsed.flags.get("bytes") ?? 16384);
@@ -615,15 +786,23 @@ export async function runNativeTerminalOp(argv) {
615
786
  return 0;
616
787
  }
617
788
  case "pid": {
618
- const client = await connectExisting(socketPath);
789
+ const { client } = await owningClient(parsed, required(parsed, "id"));
619
790
  const state = await client.state(required(parsed, "id"));
620
791
  client.close();
621
792
  emit({ pid: state.pid, status: state.status });
622
793
  return 0;
623
794
  }
795
+ case "resize": {
796
+ const id = required(parsed, "id");
797
+ const { client } = await owningClient(parsed, id);
798
+ await withController(client, id, lease => client.resize(lease, Number(required(parsed, "cols")), Number(required(parsed, "rows"))));
799
+ emit({ ok: true });
800
+ return 0;
801
+ }
802
+ case "stop":
624
803
  case "kill": {
625
- const client = await connectExisting(socketPath);
626
804
  const id = required(parsed, "id");
805
+ const { client } = await owningClient(parsed, id);
627
806
  const signal = parsed.flags.get("signal") ?? "SIGTERM";
628
807
  if (signal !== "SIGTERM" && signal !== "SIGKILL" && signal !== "SIGINT" && signal !== "SIGHUP") {
629
808
  throw new Error(`unsupported signal: ${signal}`);
@@ -653,9 +832,10 @@ export async function runNativeTerminalOp(argv) {
653
832
  emit(after);
654
833
  return after.status === "exited" ? 0 : 1;
655
834
  }
835
+ case "stop-if-incarnation":
656
836
  case "kill-if-incarnation": {
657
- const client = await connectExisting(socketPath);
658
837
  const id = required(parsed, "id");
838
+ const { client } = await owningClient(parsed, id);
659
839
  const generation = required(parsed, "generation");
660
840
  const incarnation = required(parsed, "incarnation");
661
841
  const signal = parsed.flags.get("signal") ?? "SIGTERM";
@@ -687,7 +867,11 @@ export async function runNativeTerminalOp(argv) {
687
867
  return after.status === "exited" ? 0 : 1;
688
868
  }
689
869
  case "attach": {
690
- return runAttach(required(parsed, "id"), defaultAttachRuntime(socketPath));
870
+ const id = required(parsed, "id");
871
+ const owner = await owningClient(parsed, id);
872
+ owner.client.close();
873
+ // Reconnects retain this owner for the entire interactive operation.
874
+ return runAttach(id, defaultAttachRuntime(owner.socketPath));
691
875
  }
692
876
  case "host-stop": {
693
877
  let client;
@@ -726,6 +910,8 @@ if (isEntryPoint()) {
726
910
  .then((code) => {
727
911
  process.exitCode = code;
728
912
  }, (error) => {
913
+ if (error instanceof NativeOwnerError)
914
+ emit(error.refusal);
729
915
  process.stderr.write(`[h2a-native-op] ${error instanceof Error ? error.message : String(error)}\n`);
730
916
  process.exitCode = 1;
731
917
  })