dsh-ssh-tui 0.7.2 → 0.7.4

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 (112) hide show
  1. package/README.en.md +113 -27
  2. package/README.md +90 -27
  3. package/cordis.patch.yml +15 -0
  4. package/docs/remote-ops.md +27 -1
  5. package/docs/terminals.md +126 -0
  6. package/docs/windows.md +128 -0
  7. package/lib/attach.js +14 -1
  8. package/lib/attach.js.map +1 -1
  9. package/lib/commands.js +1 -0
  10. package/lib/commands.js.map +1 -1
  11. package/lib/copy-text.js +2 -0
  12. package/lib/copy-text.js.map +1 -1
  13. package/lib/diag.js +38 -0
  14. package/lib/diag.js.map +1 -1
  15. package/lib/dialogs.js.map +1 -1
  16. package/lib/display-sock.js +572 -44
  17. package/lib/display-sock.js.map +1 -1
  18. package/lib/doctor.js +71 -37
  19. package/lib/doctor.js.map +1 -1
  20. package/lib/dsh-compat.js +252 -88
  21. package/lib/dsh-compat.js.map +1 -1
  22. package/lib/footer.js +4 -2
  23. package/lib/footer.js.map +1 -1
  24. package/lib/i18n/en.js +54 -10
  25. package/lib/i18n/en.js.map +1 -1
  26. package/lib/i18n/index.js +18 -7
  27. package/lib/i18n/index.js.map +1 -1
  28. package/lib/i18n/zh.js +54 -10
  29. package/lib/i18n/zh.js.map +1 -1
  30. package/lib/index.js +111 -26
  31. package/lib/index.js.map +1 -1
  32. package/lib/line-mode.js +4 -0
  33. package/lib/line-mode.js.map +1 -1
  34. package/lib/paint.js +37 -3
  35. package/lib/paint.js.map +1 -1
  36. package/lib/picker.js +109 -18
  37. package/lib/picker.js.map +1 -1
  38. package/lib/plan.js +8 -0
  39. package/lib/plan.js.map +1 -1
  40. package/lib/platform.js +377 -11
  41. package/lib/platform.js.map +1 -1
  42. package/lib/preset-authoring.js +10 -14
  43. package/lib/preset-authoring.js.map +1 -1
  44. package/lib/preset-compat.js +100 -0
  45. package/lib/preset-compat.js.map +1 -0
  46. package/lib/preset-picker.js +5 -1
  47. package/lib/preset-picker.js.map +1 -1
  48. package/lib/preset-rows.js +119 -19
  49. package/lib/preset-rows.js.map +1 -1
  50. package/lib/provider-catalog.js +4 -4
  51. package/lib/question-wait.js +418 -0
  52. package/lib/question-wait.js.map +1 -0
  53. package/lib/route-memory.js +3 -3
  54. package/lib/route-memory.js.map +1 -1
  55. package/lib/selection.js +26 -8
  56. package/lib/selection.js.map +1 -1
  57. package/lib/session-index.js +5 -0
  58. package/lib/session-index.js.map +1 -1
  59. package/lib/session-lock.js +4 -1
  60. package/lib/session-lock.js.map +1 -1
  61. package/lib/session-route.js +3 -0
  62. package/lib/session-route.js.map +1 -1
  63. package/lib/settings-routes.js +10 -0
  64. package/lib/settings-routes.js.map +1 -0
  65. package/lib/settings-subagent.js +10 -0
  66. package/lib/settings-subagent.js.map +1 -0
  67. package/lib/subagent-model.js +5 -5
  68. package/lib/subagent-model.js.map +1 -1
  69. package/lib/supergrok-token.js +4 -0
  70. package/lib/supergrok-token.js.map +1 -1
  71. package/lib/term-text.js +79 -8
  72. package/lib/term-text.js.map +1 -1
  73. package/lib/terminal-caps.js +358 -0
  74. package/lib/terminal-caps.js.map +1 -0
  75. package/lib/terminal-input.js +13 -0
  76. package/lib/terminal-input.js.map +1 -1
  77. package/lib/tui.js +775 -165
  78. package/lib/tui.js.map +1 -1
  79. package/lib/types/attach.d.ts +8 -0
  80. package/lib/types/commands.d.ts +3 -0
  81. package/lib/types/diag.d.ts +20 -1
  82. package/lib/types/dialogs.d.ts +16 -0
  83. package/lib/types/display-sock.d.ts +141 -24
  84. package/lib/types/doctor.d.ts +8 -1
  85. package/lib/types/dsh-compat.d.ts +173 -44
  86. package/lib/types/footer.d.ts +3 -1
  87. package/lib/types/i18n/index.d.ts +31 -15
  88. package/lib/types/index.d.ts +45 -0
  89. package/lib/types/paint.d.ts +21 -0
  90. package/lib/types/picker.d.ts +25 -0
  91. package/lib/types/platform.d.ts +197 -10
  92. package/lib/types/preset-authoring.d.ts +8 -14
  93. package/lib/types/preset-compat.d.ts +58 -0
  94. package/lib/types/preset-picker.d.ts +1 -1
  95. package/lib/types/preset-rows.d.ts +63 -13
  96. package/lib/types/question-wait.d.ts +221 -0
  97. package/lib/types/selection.d.ts +12 -4
  98. package/lib/types/settings-routes.d.ts +16 -0
  99. package/lib/types/settings-subagent.d.ts +24 -0
  100. package/lib/types/subagent-model.d.ts +10 -10
  101. package/lib/types/term-text.d.ts +14 -0
  102. package/lib/types/terminal-caps.d.ts +105 -0
  103. package/lib/types/terminal-input.d.ts +10 -0
  104. package/lib/types/transcript-types.d.ts +22 -0
  105. package/lib/types/tui.d.ts +167 -10
  106. package/lib/types/update-check.d.ts +19 -4
  107. package/lib/types/workspace-changes.d.ts +135 -0
  108. package/lib/update-check.js +25 -8
  109. package/lib/update-check.js.map +1 -1
  110. package/lib/workspace-changes.js +120 -0
  111. package/lib/workspace-changes.js.map +1 -0
  112. package/package.json +124 -59
@@ -6,7 +6,9 @@
6
6
  * and a named pipe at `\\.\pipe\dsh-tui-<home>-<id>` on Windows. Node requires
7
7
  * the `\\.\pipe\` form there — a plain file path cannot be listened on — and
8
8
  * `fs.access()` cannot see pipes, so channel liveness always goes through
9
- * `displaySockExists()` instead of a raw filesystem check.
9
+ * `displaySockExists()` instead of a raw filesystem check. The POSIX name is
10
+ * budgeted against `sun_path` (107 bytes on Linux, 103 on macOS): a deep
11
+ * `DSH_HOME` shortens the readable label, it never fails `listen()`.
10
12
  *
11
13
  * Frame: u32be length | u8 type | payload
12
14
  * 1 stdin — relay → host (key bytes)
@@ -16,16 +18,22 @@
16
18
  * 5 goodbye — host → relay, then close (user /exit)
17
19
  * 6 rtt — relay → host (u32be milliseconds; 0xffffffff = unknown)
18
20
  * 7 replaced — host → relay, then close (a newer Display took the session)
21
+ * 8 probe — host → relay: round-trip your terminal and report it
22
+ * 9 probe? — relay → host: 1 live, 2 silent (answered before, not now), 0 unknown
23
+ * 10 query — any process → host: is a window really on this session?
24
+ * 11 query? — host → that process: 1 attached, 2 detached, 0 cannot tell
19
25
  */
20
- import { spawn } from 'node:child_process';
26
+ import { spawn, spawnSync } from 'node:child_process';
21
27
  import { StringDecoder } from 'node:string_decoder';
22
28
  import { createHash } from 'node:crypto';
23
29
  import { createConnection, createServer } from 'node:net';
24
30
  import { mkdir, readFile, unlink } from 'node:fs/promises';
25
- import { closeSync, mkdirSync, openSync } from 'node:fs';
31
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync } from 'node:fs';
26
32
  import { homedir } from 'node:os';
27
- import { TerminalInputFilter, TerminalInputPump } from './terminal-input.js';
33
+ import { RTT_SLOW_SAMPLE_TIMEOUT_MS, TerminalInputFilter, TerminalInputPump, } from './terminal-input.js';
28
34
  import { dirname, join, resolve } from 'node:path';
35
+ import { bootstrapEnv, hostBootstrapCommand, hostSpawnOptions, restrictPathToUserSync, usesSigwinch, } from './platform.js';
36
+ import { terminalCapabilities } from './terminal-caps.js';
29
37
  export const FRAME_STDIN = 1;
30
38
  export const FRAME_STDOUT = 2;
31
39
  export const FRAME_RESIZE = 3;
@@ -33,6 +41,14 @@ export const FRAME_HELLO = 4;
33
41
  export const FRAME_GOODBYE = 5;
34
42
  export const FRAME_RTT = 6;
35
43
  export const FRAME_REPLACED = 7;
44
+ /** Host → relay: round-trip your terminal now and say what it answered. */
45
+ export const FRAME_PROBE = 8;
46
+ /** Relay → Host: the answer to {@link FRAME_PROBE}. */
47
+ export const FRAME_PROBE_REPLY = 9;
48
+ /** A prober → Host: is a window *really* on this session? Does not claim it. */
49
+ export const FRAME_QUERY = 10;
50
+ /** Host → prober: the answer to {@link FRAME_QUERY}. */
51
+ export const FRAME_QUERY_REPLY = 11;
36
52
  const MAX_FRAME = 1024 * 1024;
37
53
  /**
38
54
  * Grace period for a kicked relay to read FRAME_REPLACED before its socket is
@@ -53,6 +69,40 @@ const REPLACED_GRACE_MS = 250;
53
69
  * attach at all — hence the wider grace.
54
70
  */
55
71
  const DISPLAY_HELLO_GRACE_MS = 6_000;
72
+ /**
73
+ * How long the Host waits for a relay's terminal round trip.
74
+ *
75
+ * The relay's own probe is two windows at most (a fast one, then a widened one
76
+ * for a slow link), so this only has to cover those plus the socket hop.
77
+ */
78
+ const DISPLAY_PROBE_TIMEOUT_MS = 3_000;
79
+ /**
80
+ * The link is measured again this soon after an attach, then on the slower
81
+ * cadence below.
82
+ *
83
+ * The first measurement is taken while the session is still booting, and a
84
+ * jittery moment there used to be the only measurement there ever was: the
85
+ * footer chip stayed red and the paint cadence — with the per-frame byte budget
86
+ * — stayed at the slowest tier for hours. The early re-check corrects exactly
87
+ * that case without waiting a full cadence.
88
+ */
89
+ const RTT_RECHECK_FIRST_MS = 8_000;
90
+ /** How often an attached relay re-measures the link for the Host's chip. */
91
+ const RTT_RECHECK_MS = 20_000;
92
+ /** After this many silent measurements, stop asking so often. */
93
+ const RTT_RECHECK_MAX_MISSES = 3;
94
+ /** A terminal that does not answer DSR is asked again only this rarely. */
95
+ const RTT_RECHECK_BACKOFF_MS = 600_000;
96
+ /**
97
+ * A measurement that moved by at least this much is confirmed soon after.
98
+ *
99
+ * The Host smooths over the last few measurements, so the *second* sample is
100
+ * what makes a real change stick — or what retires a one-off spike — and waiting
101
+ * a whole cadence for it keeps the chip on the wrong tier for that long.
102
+ */
103
+ const RTT_MOVED_MS = 50;
104
+ /** How soon a moved measurement is confirmed. */
105
+ const RTT_RECHECK_CONFIRM_MS = 5_000;
56
106
  /**
57
107
  * Drop launcher SIGTERM/SIGINT/SIGHUP so closing SSH cannot dispose the tree
58
108
  * before hangup handling. Leaving the session with setsid() is best-effort:
@@ -82,8 +132,36 @@ export function isTuiHostProcess(env = process.env) {
82
132
  * path fails with ENOENT/EACCES and the Host never listens.
83
133
  */
84
134
  export const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\';
135
+ /**
136
+ * How long the hidden-console bootstrap may take to report the Host's pid. It
137
+ * covers a cold PowerShell start on a busy machine, and nothing of the Host's
138
+ * own boot: `Start-Process -PassThru` returns as soon as the process exists.
139
+ */
140
+ const HOST_BOOTSTRAP_TIMEOUT_MS = 15_000;
85
141
  /** Windows rejects pipe names longer than 256 chars; leave generous headroom. */
86
142
  const WINDOWS_PIPE_MAX = 200;
143
+ /**
144
+ * `sockaddr_un.sun_path` counts its NUL terminator, so a POSIX socket address
145
+ * may occupy 107 bytes on Linux and 103 on macOS. Overrunning it fails
146
+ * `listen()` with EINVAL before the Host can print anything, which the launcher
147
+ * could only report as "the socket did not appear" — hence the budget below.
148
+ */
149
+ function posixSocketPathMax(platform) {
150
+ return platform === 'darwin' ? 103 : 107;
151
+ }
152
+ /** Every socket name ends in this; it is never part of the head budget. */
153
+ const SOCK_SUFFIX = '.sock';
154
+ /**
155
+ * Longest readable head a socket name asks for. A home shallow enough to have
156
+ * room for it keeps exactly the name it had before the budget existed.
157
+ */
158
+ const SOCK_LABEL_MAX = 80;
159
+ /**
160
+ * Shortest socket name worth emitting: a five-character head, the separator and
161
+ * the digest. Below this the head is noise and the address is refused with the
162
+ * real reason instead of being handed to `listen()` to fail on.
163
+ */
164
+ const SOCK_LABEL_MIN = 14;
87
165
  /** True for a Windows named-pipe address (`\\.\pipe\x`, `\\?\pipe\x`, `//./pipe/x`). */
88
166
  export function isPipePath(path) {
89
167
  const normalized = path.replaceAll('/', '\\').toLowerCase();
@@ -112,6 +190,13 @@ export function safeSessionId(sessionId) {
112
190
  const safe = sessionId.replaceAll(/[^A-Za-z0-9._-]/g, '_');
113
191
  return safe === '' ? 'session' : safe;
114
192
  }
193
+ /**
194
+ * Digest that keeps two sessions with collapsing names apart. It is appended to
195
+ * every label, however much head a tight path budget has to cut.
196
+ */
197
+ function sessionDigest(sessionId) {
198
+ return createHash('sha1').update(sessionId).digest('hex').slice(0, 8);
199
+ }
115
200
  /**
116
201
  * Readable, collision-free short label for one session: a sanitized head plus a
117
202
  * digest of the raw id. The digest is always appended because sanitizing alone
@@ -121,13 +206,36 @@ export function safeSessionId(sessionId) {
121
206
  * the wrong session.
122
207
  */
123
208
  export function sessionLabel(sessionId, maxLength) {
124
- const digest = createHash('sha1').update(sessionId).digest('hex').slice(0, 8);
209
+ const digest = sessionDigest(sessionId);
125
210
  const head = safeSessionId(sessionId)
126
211
  .replaceAll(/\.{2,}/gu, '_')
127
212
  .replace(/^[._-]+|[._-]+$/gu, '')
128
213
  .slice(0, Math.max(0, maxLength - digest.length - 1));
129
214
  return `${head === '' ? 'session' : head}-${digest}`;
130
215
  }
216
+ /**
217
+ * One POSIX socket *name* (the directory is prepended by the caller).
218
+ *
219
+ * The directory is measured in bytes, not characters: `sun_path` counts the
220
+ * encoded address, and a home with non-ASCII characters spends more than one
221
+ * byte per character. As the directory grows the readable head shrinks — a deep
222
+ * `DSH_HOME` loses the label, never the channel — while a home that used to fit
223
+ * keeps its old name byte for byte. A home too deep even for a short head is
224
+ * refused here, with the real reason, instead of by `listen()` EINVAL.
225
+ */
226
+ function posixSocketName(sessionId, dir, platform) {
227
+ const limit = posixSocketPathMax(platform);
228
+ // The separator and the suffix are not negotiable; only the head is.
229
+ const room = limit - Buffer.byteLength(dir) - 1 - SOCK_SUFFIX.length;
230
+ if (room < SOCK_LABEL_MIN) {
231
+ throw new Error(`dsh-ssh-tui: ${dir} is too deep for a Unix socket: only ${Math.max(0, room)} of the ${limit} bytes `
232
+ + `sun_path allows are left for the name, and a usable name needs ${SOCK_LABEL_MIN}. `
233
+ + 'Point DSH_HOME at a shorter path.');
234
+ }
235
+ // Safe session ids are ASCII by construction (`safeSessionId`), so the label's
236
+ // length in characters is its length in bytes.
237
+ return `${sessionLabel(sessionId, Math.min(SOCK_LABEL_MAX, room))}${SOCK_SUFFIX}`;
238
+ }
131
239
  /**
132
240
  * Named pipes live in one flat, machine-wide namespace, so the DSH_HOME is
133
241
  * folded into the name (two homes must not fight over one session id) and the
@@ -135,9 +243,12 @@ export function sessionLabel(sessionId, maxLength) {
135
243
  * into collisions.
136
244
  */
137
245
  function windowsPipePath(sessionId, dshHome) {
138
- const homeTag = createHash('sha1').update(resolve(dshHome).toLowerCase()).digest('hex').slice(0, 8);
139
- const room = WINDOWS_PIPE_MAX - WINDOWS_PIPE_PREFIX.length - 'dsh-tui-'.length - homeTag.length - 1;
140
- return `${WINDOWS_PIPE_PREFIX}dsh-tui-${homeTag}-${sessionLabel(sessionId, room)}`;
246
+ const homeTag = sessionDigest(resolve(dshHome).toLowerCase());
247
+ // `\\.\pipe\` is a flat, machine-wide namespace with a 256-character name
248
+ // limit; WINDOWS_PIPE_MAX keeps the whole address well inside it, and the
249
+ // label's digest keeps two long ids apart however much head gets cut.
250
+ const head = `${WINDOWS_PIPE_PREFIX}dsh-tui-${homeTag}-`;
251
+ return `${head}${sessionLabel(sessionId, WINDOWS_PIPE_MAX - head.length)}`;
141
252
  }
142
253
  /**
143
254
  * Address of the per-session display channel: a filesystem path on POSIX, a
@@ -147,8 +258,8 @@ function windowsPipePath(sessionId, dshHome) {
147
258
  export function sessionSockPath(sessionId, dshHome = defaultDshHome(), platform = process.platform) {
148
259
  if (platform === 'win32')
149
260
  return windowsPipePath(sessionId, dshHome);
150
- // UNIX_PATH_MAX is 108 on Linux; leave headroom for the directory prefix.
151
- return join(sessionSockDir(dshHome), `${sessionLabel(sessionId, 80)}.sock`);
261
+ const dir = sessionSockDir(dshHome);
262
+ return join(dir, posixSocketName(sessionId, dir, platform));
152
263
  }
153
264
  /**
154
265
  * Pre-digest POSIX socket path (`tui-socks/<safeId>.sock`).
@@ -188,6 +299,19 @@ export function sessionErrPath(sessionId, dshHome = defaultDshHome(), platform =
188
299
  return join(sessionSockDir(dshHome), `${sessionLabel(sessionId, 64)}.err`);
189
300
  return `${sock}.err`;
190
301
  }
302
+ /**
303
+ * Where the hidden-console bootstrap writes the Host's pid.
304
+ *
305
+ * On `\\.\pipe\` Windows there is no socket file to derive a name from, and the
306
+ * state directory already holds the per-session lock and stderr log, so the pid
307
+ * file lives beside them. It is removed as soon as it has been read.
308
+ */
309
+ export function sessionBootstrapPidPath(sessionId, dshHome = defaultDshHome()) {
310
+ // Same label budget as the stderr log next to it: these live in the state
311
+ // directory rather than next to a socket file, and a long name here is
312
+ // MAX_PATH budget spent for nothing.
313
+ return join(sessionSockDir(dshHome), `${sessionLabel(sessionId, 64)}.boot.pid`);
314
+ }
191
315
  /** Pre-digest Host stderr log next to the 0.7.1 socket, when that name differs. */
192
316
  export function legacySessionErrPath(sessionId, dshHome = defaultDshHome(), platform = process.platform) {
193
317
  const legacy = legacySessionSockPath(sessionId, dshHome, platform);
@@ -229,6 +353,33 @@ export function decodeRtt(payload) {
229
353
  const value = payload.readUInt32BE(0);
230
354
  return value === 0xffffffff ? undefined : value;
231
355
  }
356
+ /** One byte of verdict: 1 live, 2 silent, 0 anything else (unknown). */
357
+ function encodeVerdict(type, value) {
358
+ return encodeFrame(type, Buffer.from([value]));
359
+ }
360
+ function decodeVerdict(payload) {
361
+ return payload.length > 0 ? payload.readUInt8(0) : 0;
362
+ }
363
+ export function encodeProbe() {
364
+ return encodeFrame(FRAME_PROBE);
365
+ }
366
+ export function encodeProbeReply(verdict) {
367
+ return encodeVerdict(FRAME_PROBE_REPLY, verdict === 'live' ? 1 : verdict === 'silent' ? 2 : 0);
368
+ }
369
+ export function decodeProbeReply(payload) {
370
+ const value = decodeVerdict(payload);
371
+ return value === 1 ? 'live' : value === 2 ? 'silent' : 'unknown';
372
+ }
373
+ export function encodeQuery() {
374
+ return encodeFrame(FRAME_QUERY);
375
+ }
376
+ export function encodeQueryReply(verdict) {
377
+ return encodeVerdict(FRAME_QUERY_REPLY, verdict === 'attached' ? 1 : verdict === 'detached' ? 2 : 0);
378
+ }
379
+ export function decodeQueryReply(payload) {
380
+ const value = decodeVerdict(payload);
381
+ return value === 1 ? 'attached' : value === 2 ? 'detached' : 'unknown';
382
+ }
232
383
  /** Incremental decoder for one socket. */
233
384
  export class FrameReader {
234
385
  buffer = Buffer.alloc(0);
@@ -276,8 +427,12 @@ export class DisplayHost {
276
427
  socket;
277
428
  reader = new FrameReader();
278
429
  attached = false;
430
+ /** Set while a {@link FRAME_PROBE} round trip is waiting for its answer. */
431
+ probeSettle;
432
+ probeInFlight;
279
433
  constructor(path, handlers,
280
- /** Test seam: how long a silent connection may wait for its HELLO. */
434
+ /** Test seams: how long a silent connection may wait for its HELLO, and
435
+ * how long a relay may take to report its terminal round trip. */
281
436
  options = {}) {
282
437
  this.path = path;
283
438
  this.handlers = handlers;
@@ -404,6 +559,12 @@ export class DisplayHost {
404
559
  for (const frame of frames) {
405
560
  if (frame.type === FRAME_HELLO)
406
561
  claim();
562
+ else if (frame.type === FRAME_QUERY) {
563
+ // A prober asking whether a window is really on this session. It is
564
+ // deliberately not a claim: answering must never steal the display
565
+ // from the window the question is about.
566
+ void this.answerQuery(socket);
567
+ }
407
568
  else if (!claimed)
408
569
  continue;
409
570
  else if (frame.type === FRAME_STDIN)
@@ -416,6 +577,9 @@ export class DisplayHost {
416
577
  else if (frame.type === FRAME_RTT) {
417
578
  this.handlers.onRtt?.(decodeRtt(frame.payload));
418
579
  }
580
+ else if (frame.type === FRAME_PROBE_REPLY) {
581
+ this.settleProbe(decodeProbeReply(frame.payload));
582
+ }
419
583
  }
420
584
  });
421
585
  socket.on('close', () => {
@@ -432,6 +596,79 @@ export class DisplayHost {
432
596
  const probeGrace = setTimeout(dropProbe, this.options.helloGraceMs ?? DISPLAY_HELLO_GRACE_MS);
433
597
  probeGrace.unref?.();
434
598
  }
599
+ /**
600
+ * Whether a window is really on this session — asked of the window itself.
601
+ *
602
+ * "The socket answers" is not evidence. A relay whose SSH link was cut stays
603
+ * connected (its launcher sees no hangup until sshd does), and the Host then
604
+ * truthfully reports a display that no longer has a screen behind it. Only the
605
+ * far end can settle it, so this round-trips the terminal through the relay.
606
+ */
607
+ async attachmentVerdict() {
608
+ if (this.socket === undefined || this.attached !== true)
609
+ return 'detached';
610
+ const terminal = await this.probeTerminal();
611
+ if (terminal === 'live')
612
+ return 'attached';
613
+ if (terminal === 'silent')
614
+ return 'detached';
615
+ return 'unknown';
616
+ }
617
+ /** One answer to one prober; never claims the display, never throws. */
618
+ async answerQuery(socket) {
619
+ const verdict = await this.attachmentVerdict();
620
+ try {
621
+ socket.write(encodeQueryReply(verdict));
622
+ }
623
+ catch {
624
+ // The prober gave up on its own deadline; nothing left to answer to.
625
+ }
626
+ }
627
+ /**
628
+ * Ask the claimed relay to round-trip its terminal, with a deadline.
629
+ *
630
+ * Sharing one in-flight round trip matters: a second question while the first
631
+ * is unanswered would be settled by the first reply, and two windows asking at
632
+ * once is a normal resume, not an error.
633
+ */
634
+ async probeTerminal() {
635
+ if (this.probeInFlight !== undefined)
636
+ return await this.probeInFlight;
637
+ const socket = this.socket;
638
+ if (socket === undefined)
639
+ return 'unknown';
640
+ const pending = new Promise(resolve => {
641
+ // Referenced on purpose: the timer *completes* the round trip, and an
642
+ // unref'd one would let the loop drain with the caller still waiting.
643
+ const timer = setTimeout(() => {
644
+ this.probeSettle = undefined;
645
+ resolve('unknown');
646
+ }, this.options.probeTimeoutMs ?? DISPLAY_PROBE_TIMEOUT_MS);
647
+ this.probeSettle = verdict => {
648
+ clearTimeout(timer);
649
+ this.probeSettle = undefined;
650
+ resolve(verdict);
651
+ };
652
+ });
653
+ this.probeInFlight = pending;
654
+ try {
655
+ socket.write(encodeProbe());
656
+ }
657
+ catch {
658
+ this.settleProbe('unknown');
659
+ }
660
+ try {
661
+ return await pending;
662
+ }
663
+ finally {
664
+ this.probeInFlight = undefined;
665
+ }
666
+ }
667
+ settleProbe(verdict) {
668
+ const settle = this.probeSettle;
669
+ this.probeSettle = undefined;
670
+ settle?.(verdict);
671
+ }
435
672
  sendStdout(bytes) {
436
673
  const socket = this.socket;
437
674
  if (socket === undefined || this.attached !== true)
@@ -506,6 +743,65 @@ function isPidAlive(pid) {
506
743
  export async function displaySockExists(path, timeoutMs = 250) {
507
744
  return await probeDisplaySock(path, timeoutMs);
508
745
  }
746
+ /**
747
+ * How long a prober waits for the Host's answer to {@link FRAME_QUERY}.
748
+ *
749
+ * It has to cover the relay's own two probe windows (see
750
+ * {@link DISPLAY_PROBE_TIMEOUT_MS}) plus the hop; the answer only takes this
751
+ * long when the display really is silent.
752
+ */
753
+ export const DISPLAY_QUERY_TIMEOUT_MS = 4_000;
754
+ /**
755
+ * Ask a live Host whether a window is really on its session.
756
+ *
757
+ * `undefined` means the Host did not answer — an older Host that does not know
758
+ * the frame, or one whose event loop is stuck. Every caller must read that as
759
+ * "cannot tell" and never as "free": the cost of the doubt is one confirmation
760
+ * prompt, and the cost of guessing wrong is taking a session away from a window
761
+ * somebody is still typing in.
762
+ */
763
+ export async function queryDisplayAttachment(path, timeoutMs = DISPLAY_QUERY_TIMEOUT_MS) {
764
+ return await new Promise(resolve => {
765
+ const socket = createConnection(path);
766
+ const reader = new FrameReader();
767
+ let settled = false;
768
+ const finish = (verdict) => {
769
+ if (settled)
770
+ return;
771
+ settled = true;
772
+ clearTimeout(timer);
773
+ socket.destroy();
774
+ resolve(verdict);
775
+ };
776
+ const timer = setTimeout(() => finish(undefined), timeoutMs);
777
+ socket.once('connect', () => {
778
+ try {
779
+ socket.write(encodeQuery());
780
+ }
781
+ catch {
782
+ finish(undefined);
783
+ }
784
+ });
785
+ socket.on('data', chunk => {
786
+ let frames;
787
+ try {
788
+ frames = reader.push(chunk);
789
+ }
790
+ catch {
791
+ finish(undefined);
792
+ return;
793
+ }
794
+ for (const frame of frames) {
795
+ if (frame.type === FRAME_QUERY_REPLY) {
796
+ finish(decodeQueryReply(frame.payload));
797
+ return;
798
+ }
799
+ }
800
+ });
801
+ socket.once('error', () => finish(undefined));
802
+ socket.once('close', () => finish(undefined));
803
+ });
804
+ }
509
805
  function watchHostExit(child) {
510
806
  let settle = () => { };
511
807
  const exited = new Promise(resolve => { settle = resolve; });
@@ -521,6 +817,52 @@ function watchHostExit(child) {
521
817
  },
522
818
  };
523
819
  }
820
+ /**
821
+ * Watch a Host this process did not spawn itself.
822
+ *
823
+ * The hidden-console bootstrap (see `hostBootstrapCommand`) starts the Host
824
+ * through PowerShell, so there is no `ChildProcess` handle to listen on — the
825
+ * only handle on the Host is the pid it printed. Polling that pid is enough for
826
+ * what the watch is for: a Host that dies before its display socket appears
827
+ * should be reported at once instead of after the whole boot timeout. The code
828
+ * is unknown here (null), and the poll interval is the detection delay; the
829
+ * watch is disposed as soon as the channel is up, so it never runs for the life
830
+ * of the session.
831
+ *
832
+ * The poll timer is deliberately **not** unref'd. `exited` is a promise this
833
+ * watch is the only thing that can resolve, and an unref'd timer does not keep
834
+ * the event loop alive: a caller that awaits nothing else — a test, or a
835
+ * launcher whose only remaining work is the boot — let the loop drain first and
836
+ * never saw the answer (node:test reports that as "Promise resolution is still
837
+ * pending but the event loop has already resolved", which is how this was
838
+ * found). Every caller disposes the watch once the channel is up, and
839
+ * `waitForDisplaySock` disposes it in its `finally`, so holding the loop for the
840
+ * boot window is the point rather than a leak.
841
+ */
842
+ const HOST_PID_POLL_MS = 250;
843
+ /** Exported for the test that pins its loop-ref behaviour; not public API. */
844
+ export function watchHostPid(pid) {
845
+ let settle = () => { };
846
+ const exited = new Promise(resolve => { settle = resolve; });
847
+ let timer;
848
+ const tick = () => {
849
+ if (!isPidAlive(pid)) {
850
+ timer = undefined;
851
+ settle(null);
852
+ return;
853
+ }
854
+ timer = setTimeout(tick, HOST_PID_POLL_MS);
855
+ };
856
+ tick();
857
+ return {
858
+ exited,
859
+ dispose() {
860
+ if (timer !== undefined)
861
+ clearTimeout(timer);
862
+ timer = undefined;
863
+ },
864
+ };
865
+ }
524
866
  /**
525
867
  * How long a dead-pid report waits for the child's `exit` event before giving
526
868
  * up on its code. The event is normally delivered within a tick; the bound only
@@ -717,32 +1059,75 @@ export function restoreTerminalInput(stdin = process.stdin) {
717
1059
  }
718
1060
  }
719
1061
  /**
720
- * How to start the background Host so it outlives this process *and* does not
721
- * make its own children flash console windows on Windows.
722
- *
723
- * POSIX wants `detached: true` (setsid) so the Host survives the launcher and a
724
- * hung-up terminal.
1062
+ * Start the Host through the hidden-console bootstrap and return its pid, or
1063
+ * `undefined` when the bootstrap could not report one.
725
1064
  *
726
- * Windows is the opposite: `detached: true` maps to DETACHED_PROCESS, which
727
- * gives the Host **no console at all**, and Windows ignores CREATE_NO_WINDOW
728
- * (what `windowsHide` sets) when DETACHED_PROCESS is present. Every console
729
- * child the Host then starts — each tool call, every shell, node, git — has to
730
- * allocate its own console, which is a visible window flashing over the TUI.
731
- * Dropping `detached` there lets `windowsHide` do its job: the Host gets its
732
- * own invisible console, and descendants inherit it instead of creating one.
733
- * The Host still outlives the launcher: Windows does not kill children with
734
- * their parent, and its console is its own, so closing the user's terminal does
735
- * not reach it either.
1065
+ * `spawnSync` on purpose. The pid has to be in hand before this function
1066
+ * returns (the caller watches it, and the fallback must never leave two Hosts
1067
+ * for one session), and the cost is one bounded wait while the boot splash is
1068
+ * already on screen. PowerShell exits as soon as `Start-Process` has created the
1069
+ * Host, so the wait is its own start-up, not the Host's.
736
1070
  *
737
- * Pure and platform-parameterised so the Windows branch can be asserted from
738
- * Linux (see docs/platform.md).
1071
+ * Falling back is safe exactly when nothing was printed: `Start-Process -PassThru`
1072
+ * either starts the Host and prints its id, or throws before starting anything
1073
+ * (`$ErrorActionPreference = 'Stop'`). A *timeout* is the one case where a Host
1074
+ * might exist and the pid was lost, so it does not fall back — it reports.
739
1075
  */
740
- export function hostSpawnOptions(platform = process.platform) {
741
- const windows = platform === 'win32';
742
- return { detached: !windows, windowsHide: true };
1076
+ export function spawnHostThroughBootstrap(bootstrap, options) {
1077
+ // A pid file left by an earlier boot would be read as this one's answer.
1078
+ try {
1079
+ rmSync(options.pidFile, { force: true });
1080
+ }
1081
+ catch { /* best effort */ }
1082
+ const result = spawnSync(bootstrap.command, bootstrap.args, {
1083
+ env: options.env,
1084
+ ...hostSpawnOptions(options.platform),
1085
+ // No pipes at all. `Start-Process` may hand this script's stdio to the Host,
1086
+ // and a live reader of that pipe would wait for the Host to exit instead of
1087
+ // for the bootstrap: measured here as a 15 s stall before the pid was read.
1088
+ // The pid travels through a file, and the Host's own stderr is redirected by
1089
+ // the script itself.
1090
+ stdio: ['ignore', 'ignore', 'ignore'],
1091
+ timeout: options.timeoutMs,
1092
+ });
1093
+ let written = '';
1094
+ try {
1095
+ written = readFileSync(options.pidFile, 'utf8').trim();
1096
+ }
1097
+ catch {
1098
+ // No file: nothing was started (or the script failed before writing it).
1099
+ }
1100
+ try {
1101
+ rmSync(options.pidFile, { force: true });
1102
+ }
1103
+ catch { /* best effort */ }
1104
+ const match = /^(\d+)$/u.exec(written);
1105
+ if (match !== null) {
1106
+ const pid = Number(match[1]);
1107
+ // Checked before the timeout: a bootstrap that wrote the pid and then hung
1108
+ // still started exactly one Host, and that pid is the answer.
1109
+ if (Number.isInteger(pid) && pid > 0)
1110
+ return { pid };
1111
+ }
1112
+ const timedOut = result.error?.code === 'ETIMEDOUT';
1113
+ if (timedOut || result.signal !== null) {
1114
+ throw new Error(`dsh-ssh-tui: the hidden-console bootstrap did not report a host pid in ${options.timeoutMs}ms;`
1115
+ + ' refusing to start a second host for this session');
1116
+ }
1117
+ return undefined;
743
1118
  }
744
- /** Spawn a detached Host copy of this `dsh` invocation and return its sock path. */
745
- export function spawnDetachedHost(sessionId, platform = process.platform) {
1119
+ /**
1120
+ * Spawn a detached Host copy of this `dsh` invocation and return its sock path.
1121
+ *
1122
+ * On Windows the Host goes through {@link hostBootstrapCommand} when the OS
1123
+ * PowerShell is available: a direct spawn there cannot both survive the
1124
+ * launcher (libuv's `KILL_ON_JOB_CLOSE` job takes a non-detached child with it)
1125
+ * and avoid flashing console windows (`detached` is DETACHED_PROCESS, which makes
1126
+ * Windows ignore `CREATE_NO_WINDOW`). The bootstrap gives the Host a console of
1127
+ * its own, hidden — see `docs/platform.md`. Without it, the direct spawn below
1128
+ * is still what runs, with the old semantics.
1129
+ */
1130
+ export function spawnDetachedHost(sessionId, platform = process.platform, options = {}) {
746
1131
  const sock = sessionSockPath(sessionId);
747
1132
  // On Windows the channel is a pipe name, which is not a file path: the log
748
1133
  // must live in the state directory next to the locks instead.
@@ -750,17 +1135,63 @@ export function spawnDetachedHost(sessionId, platform = process.platform) {
750
1135
  let errFd;
751
1136
  try {
752
1137
  mkdirSync(dirname(errFile), { recursive: true, mode: 0o700 });
1138
+ // The Host's stderr can quote a provider error; the directory and the log
1139
+ // both get the intent applied, since `mode` is POSIX-only.
1140
+ restrictPathToUserSync(dirname(errFile), { mode: 0o700, directory: true });
753
1141
  errFd = openSync(errFile, 'w');
1142
+ restrictPathToUserSync(errFile, { mode: 0o600 });
754
1143
  }
755
1144
  catch {
756
1145
  errFd = undefined;
757
1146
  }
758
- const child = spawn(process.execPath, hostArgvForSession(sessionId), {
759
- // DSH_HOME is pinned to the resolved absolute path: the Host chdirs into
760
- // the session's working directory before it listens, so an unset, blank or
761
- // relative home would otherwise resolve differently there and the two
762
- // processes would compute different channel names.
763
- env: { ...process.env, [TUI_HOST_ENV]: '1', DSH_HOME: resolveDshHome() },
1147
+ // DSH_HOME is pinned to the resolved absolute path: the Host chdirs into the
1148
+ // session's working directory before it listens, so an unset, blank or
1149
+ // relative home would otherwise resolve differently there and the two
1150
+ // processes would compute different channel names.
1151
+ const env = { ...process.env, [TUI_HOST_ENV]: '1', DSH_HOME: resolveDshHome() };
1152
+ const argv = hostArgvForSession(sessionId);
1153
+ // Without the stderr log there is nowhere to redirect the Host's stderr, and
1154
+ // leaving it un-redirected would hand it this process's stdio; the direct
1155
+ // spawn is the honest fallback there.
1156
+ const pidFile = sessionBootstrapPidPath(sessionId);
1157
+ const bootstrap = options.bootstrap === null
1158
+ ? undefined
1159
+ : options.bootstrap ?? (errFd === undefined ? undefined : hostBootstrapCommand({
1160
+ platform,
1161
+ execPath: process.execPath,
1162
+ argv,
1163
+ stderrFile: errFile,
1164
+ pidFile,
1165
+ }));
1166
+ let started;
1167
+ if (bootstrap !== undefined) {
1168
+ started = spawnHostThroughBootstrap(bootstrap, {
1169
+ // The marker rides the bootstrap's environment, which `Start-Process`
1170
+ // passes on to the Host.
1171
+ env: bootstrapEnv(env),
1172
+ platform,
1173
+ timeoutMs: options.bootstrapTimeoutMs ?? HOST_BOOTSTRAP_TIMEOUT_MS,
1174
+ pidFile,
1175
+ });
1176
+ if (started !== undefined) {
1177
+ // The bootstrap redirects the Host's stderr to this path itself, so this
1178
+ // process must not keep a descriptor on it.
1179
+ if (errFd !== undefined) {
1180
+ try {
1181
+ closeSync(errFd);
1182
+ }
1183
+ catch { /* ignore */ }
1184
+ }
1185
+ return {
1186
+ pid: started.pid,
1187
+ sock,
1188
+ exitWatch: watchHostPid(started.pid),
1189
+ ...errFd === undefined ? {} : { errFile },
1190
+ };
1191
+ }
1192
+ }
1193
+ const child = spawn(process.execPath, argv, {
1194
+ env,
764
1195
  ...hostSpawnOptions(platform),
765
1196
  stdio: ['ignore', 'ignore', errFd ?? 'ignore'],
766
1197
  });
@@ -808,13 +1239,21 @@ export async function runDisplayRelay(path, options = {}) {
808
1239
  const stdin = options.stdin ?? process.stdin;
809
1240
  const stdout = options.stdout ?? process.stdout;
810
1241
  const signals = options.signals ?? process;
811
- const useAltScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1'
812
- && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1242
+ // The relay and the Host must agree on the screen: both ask the capability
1243
+ // table, so a console without an alternate screen never gets half of one.
1244
+ const useAltScreen = terminalCapabilities().alternateScreen;
813
1245
  return await new Promise((resolve, reject) => {
814
1246
  const socket = createConnection(path);
815
1247
  const reader = new FrameReader();
816
1248
  let settled = false;
817
1249
  let live = false;
1250
+ /** Whether this terminal has ever answered a cursor probe. */
1251
+ let terminalAnswered = false;
1252
+ let probeInFlight = false;
1253
+ /** The value the Host already has, so only real changes are reported. */
1254
+ let reportedRtt;
1255
+ let recheckMisses = 0;
1256
+ let rttTimer;
818
1257
  // Set when the Host tells us a newer Display took this session over. The
819
1258
  // terminal restore below is the one write that must NOT happen then: this
820
1259
  // relay no longer owns any screen, and if the link is dead (window killed,
@@ -845,6 +1284,9 @@ export async function runDisplayRelay(path, options = {}) {
845
1284
  reject(error);
846
1285
  };
847
1286
  const cleanup = () => {
1287
+ if (rttTimer !== undefined)
1288
+ clearTimeout(rttTimer);
1289
+ rttTimer = undefined;
848
1290
  pump.stop();
849
1291
  stdout.removeListener('resize', onResize);
850
1292
  stdin.removeListener('end', onLocalHangup);
@@ -863,7 +1305,7 @@ export async function runDisplayRelay(path, options = {}) {
863
1305
  resizeTimer = undefined;
864
1306
  }
865
1307
  stdout.off('resize', onResize);
866
- if (process.platform !== 'win32') {
1308
+ if (usesSigwinch()) {
867
1309
  signals.off('SIGWINCH', onResize);
868
1310
  }
869
1311
  try {
@@ -881,7 +1323,10 @@ export async function runDisplayRelay(path, options = {}) {
881
1323
  try {
882
1324
  stdout.write('\x1b]0;\x07');
883
1325
  stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
884
- stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h${useAltScreen ? '\x1b[?1049l' : ''}`);
1326
+ // Leaving is always safe (an unused mode is ignored) and skipping it
1327
+ // is not: this relay may be the only one that gets to restore a screen
1328
+ // the Host entered under a different classification.
1329
+ stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[?1049l`);
885
1330
  }
886
1331
  catch {
887
1332
  // TTY may already be gone
@@ -941,6 +1386,80 @@ export async function runDisplayRelay(path, options = {}) {
941
1386
  sendResize();
942
1387
  }, 20);
943
1388
  };
1389
+ /**
1390
+ * Keep the Host's picture of the link current.
1391
+ *
1392
+ * The chip and the paint cadence (and the byte budget per frame) all come
1393
+ * from this number, and a link measured once at attach is a link measured at
1394
+ * its worst moment forever. The probe is a cursor round trip on a quiet
1395
+ * line: it costs one write and one reply, and the pump routes anything the
1396
+ * user typed meanwhile to the Host as usual.
1397
+ */
1398
+ const recheckRtt = async () => {
1399
+ const measured = await pump.measure();
1400
+ if (settled)
1401
+ return;
1402
+ if (measured === undefined) {
1403
+ recheckMisses += 1;
1404
+ // A terminal that never answers DSR (a pipe, a dumb terminal) must not
1405
+ // be probed every twenty seconds for the rest of the session; one that
1406
+ // answers sometimes — a flapping link — keeps the normal cadence.
1407
+ scheduleRttRecheck(recheckMisses >= RTT_RECHECK_MAX_MISSES ? RTT_RECHECK_BACKOFF_MS : (options.rttRecheckMs ?? RTT_RECHECK_MS));
1408
+ return;
1409
+ }
1410
+ recheckMisses = 0;
1411
+ const moved = reportedRtt !== undefined && Math.abs(measured - reportedRtt) >= RTT_MOVED_MS;
1412
+ reportedRtt = measured;
1413
+ // Reported every time, even when it did not move: the Host takes the
1414
+ // median of the last few measurements, and a value sent once can never
1415
+ // outvote the stale one it replaced. An unchanged frame costs the screen
1416
+ // nothing (the painter is incremental), so the repetition is free.
1417
+ try {
1418
+ socket.write(encodeRtt(measured));
1419
+ }
1420
+ catch {
1421
+ // The socket is gone; the close handler settles the relay.
1422
+ }
1423
+ scheduleRttRecheck(moved
1424
+ ? RTT_RECHECK_CONFIRM_MS
1425
+ : (options.rttRecheckMs ?? RTT_RECHECK_MS));
1426
+ };
1427
+ const scheduleRttRecheck = (delayMs) => {
1428
+ if (settled)
1429
+ return;
1430
+ rttTimer = setTimeout(() => {
1431
+ rttTimer = undefined;
1432
+ void recheckRtt();
1433
+ }, delayMs);
1434
+ rttTimer.unref?.();
1435
+ };
1436
+ /**
1437
+ * The Host asking whether this display still has a terminal.
1438
+ *
1439
+ * The answer has to come from a real round trip. A cut SSH link leaves this
1440
+ * relay connected and its event loop healthy — the launcher sees no hangup
1441
+ * until sshd does, which can be hours — so anything cheaper (a ping its own
1442
+ * loop answers) would keep reporting a window nobody can see any more.
1443
+ */
1444
+ const answerProbe = async () => {
1445
+ if (probeInFlight)
1446
+ return;
1447
+ probeInFlight = true;
1448
+ try {
1449
+ // A slow link gets the widened window before its silence is believed:
1450
+ // "silent" is what lets a resume take the session over, and a live
1451
+ // window must never be misread into it.
1452
+ const alive = await pump.measureOnce() || await pump.measureOnce(RTT_SLOW_SAMPLE_TIMEOUT_MS);
1453
+ terminalAnswered ||= alive;
1454
+ socket.write(encodeProbeReply(alive ? 'live' : terminalAnswered ? 'silent' : 'unknown'));
1455
+ }
1456
+ catch {
1457
+ // The socket is gone; the close handler settles the relay.
1458
+ }
1459
+ finally {
1460
+ probeInFlight = false;
1461
+ }
1462
+ };
944
1463
  const onLocalHangup = () => {
945
1464
  finish('signal');
946
1465
  };
@@ -977,6 +1496,11 @@ export async function runDisplayRelay(path, options = {}) {
977
1496
  const rtt = await pump.measure();
978
1497
  if (settled)
979
1498
  return;
1499
+ // The baseline a liveness check is compared against: a terminal that
1500
+ // answered once and is silent later is gone, one that never answered
1501
+ // tells us nothing (see `TerminalVerdict`).
1502
+ terminalAnswered = rtt !== undefined;
1503
+ reportedRtt = rtt;
980
1504
  const columns = stdout.columns || 80;
981
1505
  const rows = stdout.rows || 24;
982
1506
  socket.write(Buffer.concat([
@@ -985,6 +1509,7 @@ export async function runDisplayRelay(path, options = {}) {
985
1509
  encodeRtt(rtt),
986
1510
  ]));
987
1511
  live = true;
1512
+ scheduleRttRecheck(options.rttFirstRecheckMs ?? RTT_RECHECK_FIRST_MS);
988
1513
  if (options.announce === true) {
989
1514
  try {
990
1515
  stdout.write('\r\x1b[2K');
@@ -1004,7 +1529,7 @@ export async function runDisplayRelay(path, options = {}) {
1004
1529
  }
1005
1530
  pendingBytes = 0;
1006
1531
  stdout.on('resize', onResize);
1007
- if (process.platform !== 'win32') {
1532
+ if (usesSigwinch()) {
1008
1533
  signals.on('SIGWINCH', onResize);
1009
1534
  }
1010
1535
  }
@@ -1044,6 +1569,9 @@ export async function runDisplayRelay(path, options = {}) {
1044
1569
  finish('replaced');
1045
1570
  return;
1046
1571
  }
1572
+ else if (frame.type === FRAME_PROBE) {
1573
+ void answerProbe();
1574
+ }
1047
1575
  }
1048
1576
  });
1049
1577
  socket.on('close', () => finish('host-closed'));