herdr-remote 0.2.18 → 0.2.20

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 (95) hide show
  1. package/bin/herdr-remote.js +3 -225
  2. package/dist/agent-status.js +130 -0
  3. package/dist/binary-plist.js +157 -0
  4. package/dist/cli.js +255 -0
  5. package/dist/config.js +270 -0
  6. package/dist/connector/agent-watch.js +110 -0
  7. package/dist/connector/font-server.js +165 -0
  8. package/dist/connector/host-connector.js +430 -0
  9. package/dist/connector/lock.js +66 -0
  10. package/dist/connector/main.js +18 -0
  11. package/dist/connector/sessions.js +366 -0
  12. package/dist/connector/update-report.js +67 -0
  13. package/dist/connector/wire.js +42 -0
  14. package/{src → dist}/exit-codes.js +1 -4
  15. package/dist/font-subset.js +110 -0
  16. package/dist/herdr-api.js +246 -0
  17. package/dist/herdr-command.js +223 -0
  18. package/dist/herdr-plugin.js +116 -0
  19. package/dist/herdr-server.js +189 -0
  20. package/dist/i18n/en.js +223 -0
  21. package/dist/i18n/index.js +48 -0
  22. package/dist/i18n/zh.js +222 -0
  23. package/dist/keepalive/detached.js +78 -0
  24. package/dist/keepalive/environment.js +44 -0
  25. package/dist/keepalive/index.js +110 -0
  26. package/dist/keepalive/launchd.js +117 -0
  27. package/dist/keepalive/systemd.js +108 -0
  28. package/dist/keepalive/types.js +1 -0
  29. package/dist/lib/process.js +12 -0
  30. package/dist/lifecycle.js +59 -0
  31. package/dist/net-interfaces.js +73 -0
  32. package/dist/pasted-files.js +115 -0
  33. package/dist/paths.js +62 -0
  34. package/dist/pty-session.js +96 -0
  35. package/dist/relay-client.js +106 -0
  36. package/dist/relay-urls.js +99 -0
  37. package/dist/runtime.js +106 -0
  38. package/dist/service.js +260 -0
  39. package/dist/settings-model.js +298 -0
  40. package/dist/socket-discovery.js +48 -0
  41. package/dist/supervisor.js +319 -0
  42. package/dist/terminal-font/deps.js +73 -0
  43. package/dist/terminal-font/files.js +354 -0
  44. package/dist/terminal-font/formats.js +335 -0
  45. package/dist/terminal-font/index.js +187 -0
  46. package/dist/terminal-font/readers.js +316 -0
  47. package/dist/terminal-font/terminals.js +128 -0
  48. package/dist/terminal-palette.js +339 -0
  49. package/dist/tui/App.js +195 -0
  50. package/dist/tui/api.js +79 -0
  51. package/dist/tui/components/TextField.js +50 -0
  52. package/dist/tui/components/common.js +80 -0
  53. package/dist/tui/index.js +36 -0
  54. package/dist/tui/mouse/index.js +164 -0
  55. package/dist/tui/mouse/protocol.js +96 -0
  56. package/dist/tui/mouse/source.js +74 -0
  57. package/dist/tui/screens/About.js +166 -0
  58. package/dist/tui/screens/Herdr.js +74 -0
  59. package/dist/tui/screens/Keepalive.js +84 -0
  60. package/dist/tui/screens/Overview.js +36 -0
  61. package/dist/tui/screens/Pair.js +74 -0
  62. package/dist/tui/screens/Relay.js +242 -0
  63. package/dist/tui/screens/Services.js +63 -0
  64. package/dist/tui/screens/Wizard.js +235 -0
  65. package/dist/tui/terminal.js +39 -0
  66. package/dist/tui/theme.js +33 -0
  67. package/dist/updater.js +382 -0
  68. package/herdr-plugin.toml +1 -1
  69. package/package.json +8 -8
  70. package/dist/tui.mjs +0 -5775
  71. package/src/agent-status.js +0 -139
  72. package/src/binary-plist.js +0 -146
  73. package/src/config.js +0 -454
  74. package/src/font-subset.js +0 -110
  75. package/src/herdr-api.js +0 -244
  76. package/src/herdr-command.js +0 -231
  77. package/src/herdr-plugin.js +0 -130
  78. package/src/herdr-server.js +0 -201
  79. package/src/host-connector.js +0 -1069
  80. package/src/i18n/en.js +0 -240
  81. package/src/i18n/index.js +0 -56
  82. package/src/i18n/zh.js +0 -239
  83. package/src/keepalive.js +0 -460
  84. package/src/lifecycle.js +0 -62
  85. package/src/net-interfaces.js +0 -76
  86. package/src/pasted-files.js +0 -176
  87. package/src/pty-session.js +0 -92
  88. package/src/service.js +0 -530
  89. package/src/settings-model.js +0 -323
  90. package/src/socket-discovery.js +0 -60
  91. package/src/state.js +0 -47
  92. package/src/supervisor.js +0 -250
  93. package/src/terminal-font.js +0 -1204
  94. package/src/terminal-palette.js +0 -325
  95. package/src/updater.js +0 -394
@@ -1,1069 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs');
4
- const os = require('node:os');
5
- const path = require('node:path');
6
- const crypto = require('node:crypto');
7
- const { WebSocket } = require('ws');
8
- const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require('./config');
9
- const { ensureDir } = require('./state');
10
- const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
11
- const { PtySession } = require('./pty-session');
12
- const {
13
- resolveHerdrCommand,
14
- verifyHerdrCommand,
15
- herdrNotFoundMessage,
16
- herdrOutdatedMessage,
17
- herdrVersion,
18
- } = require('./herdr-command');
19
- // The wire format lives in the relay package so both ends of the protocol are
20
- // generated from one definition.
21
- const {
22
- packStreamFrame,
23
- unpackStreamFrame,
24
- packStreamFrameV2,
25
- FRAME_TYPE_OUTPUT,
26
- PROTOCOL_VERSION,
27
- TERMINAL_FONT_CHUNK_BYTES,
28
- } = require('herdr-remote-relay/protocol');
29
- const { resolveHostPalette } = require('./terminal-palette');
30
- const { loadHostTerminalFont, publicTerminalFont, readFontChunk } = require('./terminal-font');
31
- const { FontSubsetter } = require('./font-subset');
32
- const { requestHerdr, subscribeHerdr } = require('./herdr-api');
33
- const { sameSummary, summarizeAgents } = require('./agent-status');
34
- const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
35
- const { savePastedFile, cleanPastedDir } = require('./pasted-files');
36
- const { ensureHerdrServer, probeHerdrServer } = require('./herdr-server');
37
- const {
38
- checkForUpdate,
39
- compareVersions,
40
- currentVersion,
41
- installedVersionOnDisk,
42
- updateChecksEnabled,
43
- } = require('./updater');
44
-
45
- /** Windows opening within this long of a check reuse its answer. */
46
- const UPDATE_RECHECK_MS = 60_000;
47
-
48
- /** Cut fonts kept for the slices a browser has yet to fetch, by total size. */
49
- const FONT_SUBSET_CACHE_BYTES = 48 * 1024 * 1024;
50
-
51
- /** How often the agent summary is re-read while a browser is watching. */
52
- const AGENT_STATUS_POLL_MS = 5_000;
53
- const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
54
- const AGENT_STATUS_SUBSCRIPTIONS = [
55
- { type: 'pane.focused' },
56
- { type: 'tab.focused' },
57
- { type: 'workspace.focused' },
58
- { type: 'pane.agent_detected' },
59
- ];
60
-
61
- function randomId(prefix) {
62
- return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
63
- }
64
-
65
- function sendJson(ws, payload) {
66
- if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
67
- }
68
-
69
- function closeSocket(ws, reason = 'host connector stopping') {
70
- if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
71
- try { ws.close(1000, reason); } catch {}
72
- }
73
-
74
- function terminateSocket(ws) {
75
- if (!ws || ws.readyState === WebSocket.CLOSED) return;
76
- try {
77
- if (typeof ws.terminate === 'function') {
78
- ws.terminate();
79
- } else if (typeof ws.destroy === 'function') {
80
- ws.destroy();
81
- } else if (typeof ws.close === 'function') {
82
- ws.close();
83
- }
84
- } catch {}
85
- }
86
-
87
- function pidAlive(pid) {
88
- if (!Number.isInteger(pid) || pid <= 0) return false;
89
- try {
90
- process.kill(pid, 0);
91
- return true;
92
- } catch (error) {
93
- return error.code === 'EPERM';
94
- }
95
- }
96
-
97
- class HostConnector {
98
- /**
99
- * A session that dies this fast never really started. Anything longer is a
100
- * session the user actually used and then left.
101
- */
102
- static FAST_FAILURE_MS = 1500;
103
- /** How many broken starts in a row before we stop calling them exits. */
104
- static FAST_FAILURE_LIMIT = 3;
105
-
106
- constructor(options = {}) {
107
- const config = options.config || loadConfig();
108
- this.config = config;
109
- this.relayUrl = options.relayUrl
110
- || (process.env.RELAY_URL ? hostWebSocketUrl(process.env.RELAY_URL) : resolveHostRelayUrl(config));
111
- this.hostId = options.hostId || process.env.RELAY_HOST_ID || randomId('host');
112
- this.hostToken = options.hostToken || process.env.RELAY_HOST_TOKEN || '';
113
- // Optional; a relay without a password accepts any workstation.
114
- this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
115
- this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
116
- // Where to look for Herdr when the command has to be resolved again; the
117
- // real environment unless a caller narrows it.
118
- this.herdrLookup = options.herdrLookup || {};
119
- this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
120
- this.readHerdrVersion = options.readHerdrVersion || herdrVersion;
121
- this.herdrVersionWarned = false;
122
- this.herdrArgs = options.herdrArgs || config.herdr.args;
123
- this.cwd = options.cwd || config.herdr.cwd;
124
- /**
125
- * What the workstation's own terminal looks like. A browser has no way to
126
- * know it — the PTY carries color indices, not colors — so the host reports
127
- * it and the browser renders the session the way the workstation sees it.
128
- * Normally captured by the start path that still had a terminal and passed
129
- * down in the environment; `null` when nobody could ask.
130
- */
131
- this.terminalPalette = options.terminalPalette !== undefined
132
- ? options.terminalPalette
133
- : resolveHostPalette();
134
- /**
135
- * The terminal's font, with the local files behind it. Only the family,
136
- * size and file hashes leave this process; a browser that lacks the font
137
- * fetches the files by hash, one chunk at a time. Injectable for tests.
138
- */
139
- this.loadTerminalFont = options.loadTerminalFont || loadHostTerminalFont;
140
- this.terminalFont = options.terminalFont !== undefined
141
- ? options.terminalFont
142
- : this.loadTerminalFont();
143
- /** Cuts characters out of large fonts; see font-subset.js. */
144
- this.fontSubsetter = options.fontSubsetter || new FontSubsetter();
145
- /** Subsets too large for one message, by hash, until fetched. */
146
- this.fontSubsets = new Map();
147
- this.ws = null;
148
- this.sessions = new Map();
149
- this.streamIndexToId = new Map();
150
- this.PtySession = options.PtySession || PtySession;
151
- /** Is Herdr's server answering, and how to start it; injectable for tests. */
152
- this.probeHerdr = options.probeHerdr || probeHerdrServer;
153
- this.ensureHerdr = options.ensureHerdr || ensureHerdrServer;
154
- this.herdrLogPath = options.herdrLogPath || path.join(stateDir(), 'herdr-server.log');
155
- /** Windows whose session waits on a Herdr that is not running, by stream. */
156
- this.waitingForHerdr = new Map();
157
- /** Session starts between their liveness check and their PTY, by stream. */
158
- this.probingStarts = new Map();
159
- /** One start at a time, however many windows asked for it. */
160
- this.herdrLaunch = null;
161
- /**
162
- * Whether a newer herdr-remote is out, asked when a window opens. The
163
- * version this process runs is fixed at start; the one on disk moves when
164
- * somebody updates without restarting.
165
- */
166
- this.checkUpdate = options.checkUpdate !== undefined
167
- ? options.checkUpdate
168
- : (updateChecksEnabled() ? checkForUpdate : null);
169
- this.readInstalledVersion = options.readInstalledVersion || installedVersionOnDisk;
170
- this.runningVersion = options.runningVersion || currentVersion();
171
- this.updateCheckedAt = 0;
172
- this.updateCheck = null;
173
- /** The side channel that answers "does anything need me?"; see startAgentStatus. */
174
- this.requestHerdr = options.requestHerdr || requestHerdr;
175
- this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
176
- this.agentStatusTimer = null;
177
- this.agentStatusDebounceTimer = null;
178
- this.agentStatusSubscription = null;
179
- this.agentStatusPending = false;
180
- this.lastAgentSummary = null;
181
- this.fastFailures = 0;
182
- this.reconnectTimer = null;
183
- this.heartbeatTimer = null;
184
- this.keepaliveTimer = null;
185
- this.reconnectAttempts = 0;
186
- this.clientCount = 0;
187
- this.legacyHeartbeat = false;
188
- this.ready = false;
189
- this.authFailure = false;
190
- this.stopping = false;
191
- this.lockPath = options.lockPath
192
- || process.env.HERDR_REMOTE_HOST_LOCK
193
- || path.join(stateDir(), 'host-connector.lock');
194
- this.lockFd = null;
195
- try { cleanPastedDir(); } catch {}
196
- }
197
-
198
- acquireLock() {
199
- if (this.lockFd !== null) return;
200
- ensureDir(path.dirname(this.lockPath));
201
- for (let attempt = 0; attempt < 2; attempt += 1) {
202
- try {
203
- const fd = fs.openSync(this.lockPath, 'wx', 0o600);
204
- fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId: this.hostId, startedAt: new Date().toISOString() })}\n`);
205
- this.lockFd = fd;
206
- return;
207
- } catch (error) {
208
- if (error.code !== 'EEXIST') throw error;
209
- let owner = null;
210
- try { owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8')); } catch {}
211
- if (owner && pidAlive(owner.pid)) {
212
- const duplicate = new Error(`another host connector is already running (pid ${owner.pid})`);
213
- duplicate.code = 'HOST_ALREADY_RUNNING';
214
- throw duplicate;
215
- }
216
- try { fs.rmSync(this.lockPath, { force: true }); } catch {}
217
- }
218
- }
219
- const stale = new Error('could not acquire host connector lock');
220
- stale.code = 'HOST_LOCK_FAILED';
221
- throw stale;
222
- }
223
-
224
- releaseLock() {
225
- const owned = this.lockFd !== null;
226
- if (this.lockFd !== null) {
227
- try { fs.closeSync(this.lockFd); } catch {}
228
- this.lockFd = null;
229
- }
230
- if (!owned) return;
231
- try {
232
- const owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8'));
233
- if (owner.pid !== process.pid) return;
234
- } catch {}
235
- try { fs.rmSync(this.lockPath, { force: true }); } catch {}
236
- }
237
-
238
- start() {
239
- if (!this.hostToken) {
240
- throw new Error('RELAY_HOST_TOKEN is required');
241
- }
242
- this.acquireLock();
243
- this.stopping = false;
244
- this.connect();
245
- // Off unless the user switched it on: Herdr is theirs to start.
246
- if (this.config.herdr?.autoStart) {
247
- this.startHerdr().catch((error) => {
248
- process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
249
- });
250
- }
251
- }
252
-
253
- stop() {
254
- this.stopping = true;
255
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
256
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
257
- this.stopKeepalive();
258
- this.stopAgentStatus();
259
- this.reconnectTimer = null;
260
- this.heartbeatTimer = null;
261
- sendJson(this.ws, { type: 'host_shutdown' });
262
- this.destroySessions();
263
- closeSocket(this.ws, 'host_shutdown');
264
- this.ws = null;
265
- this.ready = false;
266
- this.clientCount = 0;
267
- this.legacyHeartbeat = false;
268
- this.releaseLock();
269
- }
270
-
271
- connect() {
272
- if (this.stopping || (this.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.ws.readyState))) return;
273
- this.stopKeepalive();
274
- let ws;
275
- try {
276
- ws = new WebSocket(this.relayUrl);
277
- } catch (error) {
278
- this.scheduleReconnect(error);
279
- return;
280
- }
281
- this.ws = ws;
282
- ws.isAlive = true;
283
- ws.on('open', () => {
284
- // Disable Nagle's algorithm to prevent small frames from stalling ~40ms when delayed ACK is active.
285
- try { ws._socket?.setNoDelay(true); } catch {}
286
- ws.isAlive = true;
287
- this.ready = false;
288
- this.authFailure = false;
289
- sendJson(ws, {
290
- type: 'host_hello',
291
- protocol: PROTOCOL_VERSION,
292
- hostId: this.hostId,
293
- token: this.hostToken,
294
- password: this.relayPassword || null,
295
- hostname: os.hostname(),
296
- platform: process.platform,
297
- arch: process.arch,
298
- terminalPalette: this.terminalPalette || null,
299
- terminalFont: publicTerminalFont(this.terminalFont),
300
- capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
301
- });
302
- // The relay sends host_ready with the current browser count. No business
303
- // heartbeat is started until that message says somebody is watching.
304
- });
305
- ws.on('pong', () => { ws.isAlive = true; });
306
- ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
307
- ws.on('close', (code, rawReason) => {
308
- // A replacement socket may be live while an older socket is still
309
- // delivering its close event. Never let that stale event destroy the new
310
- // session or clear its heartbeat timer.
311
- if (this.ws !== ws) return;
312
- this.ws = null;
313
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
314
- this.heartbeatTimer = null;
315
- this.stopKeepalive();
316
- // Nothing to report to, so stop asking Herdr. A reconnect brings the
317
- // watcher back with the relay's next client count.
318
- this.stopAgentStatus();
319
- this.ready = false;
320
- this.clientCount = 0;
321
- this.legacyHeartbeat = false;
322
- this.destroySessions();
323
-
324
- // Another connector has claimed this workstation. Reconnecting would just
325
- // displace it in turn, and each swap drops every attached browser, so
326
- // stand down instead of fighting for the slot.
327
- const reason = rawReason ? rawReason.toString() : '';
328
- if (reason === 'host_replaced') {
329
- this.stopping = true;
330
- process.stderr.write('herdr-remote host connector: another instance took over this workstation; exiting\n');
331
- this.stop();
332
- process.exit(EXIT_REPLACED);
333
- }
334
- if (this.authFailure) {
335
- this.stopping = true;
336
- process.stderr.write('herdr-remote host connector: authentication failed; update relay credentials and restart the service\n');
337
- this.releaseLock();
338
- process.exit(EXIT_AUTH_FAILED);
339
- }
340
- this.scheduleReconnect();
341
- });
342
- ws.on('error', (error) => {
343
- process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
344
- });
345
- }
346
-
347
- scheduleReconnect(error = null) {
348
- if (this.stopping || this.reconnectTimer) return;
349
- if (error) process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
350
- const delay = Math.min(30000, 500 * (1.5 ** this.reconnectAttempts) + Math.floor(Math.random() * 250));
351
- this.reconnectAttempts += 1;
352
- this.reconnectTimer = setTimeout(() => {
353
- this.reconnectTimer = null;
354
- this.connect();
355
- }, delay);
356
- }
357
-
358
- handleMessage(raw, isBinary) {
359
- if (isBinary) {
360
- let frame;
361
- try {
362
- frame = unpackStreamFrame(raw);
363
- } catch (error) {
364
- process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
365
- return;
366
- }
367
- if (frame.type === 'input') {
368
- const streamId = frame.version === 2 ? this.streamIndexToId.get(frame.streamIndex) : frame.streamId;
369
- if (streamId) this.sessions.get(streamId)?.pty.write(frame.payload);
370
- }
371
- return;
372
- }
373
- let message;
374
- try { message = JSON.parse(raw.toString('utf8')); } catch { return; }
375
- // A relay-level error (no clientId) is a rejected handshake — a wrong relay
376
- // password, or a host token the relay does not recognise. Without this the
377
- // connection just closed silently and reconnected forever, leaving the user
378
- // with an empty log and no idea what was wrong.
379
- if (message.type === 'error' && !message.clientId) {
380
- this.authFailure = ['relay_password_required', 'host_auth_failed', 'invalid_host_credentials'].includes(message.code);
381
- process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
382
- return;
383
- }
384
- if (message.type === 'host_ready') {
385
- this.ready = true;
386
- this.reconnectAttempts = 0;
387
- this.startKeepalive();
388
- if (Object.hasOwn(message, 'clientCount')) {
389
- this.legacyHeartbeat = false;
390
- this.setClientCount(message.clientCount);
391
- } else {
392
- // An older relay does not know client_count. Keep its historical
393
- // telemetry behavior so rolling upgrades do not silently lose status.
394
- this.legacyHeartbeat = true;
395
- this.sendHeartbeat(true);
396
- this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
397
- }
398
- return;
399
- }
400
- if (message.type === 'client_count') {
401
- this.setClientCount(message.clientCount);
402
- return;
403
- }
404
- if (message.type === 'session_start') return this.startSession(message);
405
- if (message.type === 'herdr_start') return this.handleHerdrStart(message);
406
- if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
407
- else if (message.type === 'resize') this.resizeSession(message);
408
- else if (message.type === 'paste_file') this.handlePasteFile(message);
409
- else if (message.type === 'host_font_chunk_request') this.handleFontChunkRequest(message);
410
- else if (message.type === 'host_font_subset_request') this.handleFontSubsetRequest(message);
411
- else if (message.type === 'host_font_refresh') this.handleFontRefresh();
412
- }
413
-
414
- /**
415
- * A service started at boot runs before anything has seen a terminal, so it
416
- * starts with no font. The first `herdr-remote start` from a terminal (or
417
- * from Herdr's plugin hook) writes one down; the next window to open picks
418
- * it up here instead of waiting for a restart.
419
- */
420
- pickUpTerminalFont() {
421
- if (this.terminalFont) return;
422
- let next = null;
423
- try {
424
- next = this.loadTerminalFont();
425
- } catch {
426
- return;
427
- }
428
- if (!next) return;
429
- this.terminalFont = next;
430
- sendJson(this.ws, { type: 'terminal_font', terminalFont: publicTerminalFont(next) });
431
- }
432
-
433
- /** One slice of a terminal font file, for the window that asked for it. */
434
- handleFontChunkRequest(message) {
435
- const streamId = message.clientId || message.streamId;
436
- const chunk = this.subsetChunk(message.sha256, message.index)
437
- || readFontChunk(this.terminalFont, message.sha256, message.index);
438
- if (!chunk) {
439
- sendJson(this.ws, {
440
- type: 'error',
441
- clientId: streamId,
442
- code: 'host_font_unavailable',
443
- message: 'The terminal font changed or is no longer on this workstation',
444
- });
445
- return;
446
- }
447
- sendJson(this.ws, {
448
- type: 'host_font_chunk',
449
- clientId: streamId,
450
- sha256: message.sha256,
451
- index: message.index,
452
- total: chunk.total,
453
- dataBase64: chunk.data.toString('base64'),
454
- });
455
- }
456
-
457
- /** A slice of a cut font still held for the browser that asked for it. */
458
- subsetChunk(sha256, index) {
459
- const data = this.fontSubsets.get(sha256);
460
- if (!data) return null;
461
- const total = Math.ceil(data.length / TERMINAL_FONT_CHUNK_BYTES);
462
- if (!Number.isInteger(index) || index < 0 || index >= total) return null;
463
- const start = index * TERMINAL_FONT_CHUNK_BYTES;
464
- return { data: data.subarray(start, start + TERMINAL_FONT_CHUNK_BYTES), total };
465
- }
466
-
467
- /**
468
- * The characters a window is about to draw, cut out of a large font (CJK).
469
- * A small result travels in the answer itself; a large one (the common
470
- * characters fetched up front) is held here and pulled in slices.
471
- */
472
- handleFontSubsetRequest(message) {
473
- const streamId = message.clientId || message.streamId;
474
- const source = this.terminalFont?.subsets?.find((candidate) => candidate.sha256 === message.sha256);
475
- const fail = (code, text) => sendJson(this.ws, { type: 'error', clientId: streamId, code, message: text });
476
- if (!source) {
477
- fail('host_font_unavailable', 'The terminal font changed or is no longer on this workstation');
478
- return;
479
- }
480
- let stat;
481
- try { stat = fs.statSync(source.path); } catch {}
482
- if (!stat || stat.size !== source.bytes || stat.mtimeMs !== source.mtimeMs) {
483
- fail('host_font_unavailable', 'The terminal font changed or is no longer on this workstation');
484
- return;
485
- }
486
- const codepoints = [...new Set(Array.from(String(message.text || ''), (char) => char.codePointAt(0)))];
487
- let data;
488
- try {
489
- data = this.fontSubsetter.subset(source, codepoints);
490
- } catch (error) {
491
- fail('host_font_subset_failed', error.message || 'The font could not be subset');
492
- return;
493
- }
494
- const subsetSha = crypto.createHash('sha256').update(data).digest('hex');
495
- const answer = {
496
- type: 'host_font_subset_ready',
497
- clientId: streamId,
498
- requestId: message.requestId,
499
- sha256: source.sha256,
500
- subsetSha,
501
- bytes: data.length,
502
- };
503
- if (data.length <= TERMINAL_FONT_CHUNK_BYTES) {
504
- answer.dataBase64 = data.toString('base64');
505
- } else {
506
- this.fontSubsets.delete(subsetSha);
507
- this.fontSubsets.set(subsetSha, data);
508
- let held = [...this.fontSubsets.values()].reduce((sum, item) => sum + item.length, 0);
509
- for (const [key, item] of this.fontSubsets) {
510
- if (held <= FONT_SUBSET_CACHE_BYTES || key === subsetSha) break;
511
- this.fontSubsets.delete(key);
512
- held -= item.length;
513
- }
514
- }
515
- sendJson(this.ws, answer);
516
- }
517
-
518
- /**
519
- * Read the terminal's font settings again, at a browser's request. Every
520
- * window of this workstation hears the answer, since they all draw with it.
521
- */
522
- handleFontRefresh() {
523
- try {
524
- const next = this.loadTerminalFont({ refresh: true });
525
- if (next) this.terminalFont = next;
526
- } catch (error) {
527
- process.stderr.write(`herdr-remote host connector: could not read the terminal font: ${error.message}\n`);
528
- }
529
- sendJson(this.ws, { type: 'terminal_font', terminalFont: publicTerminalFont(this.terminalFont) });
530
- }
531
-
532
- /**
533
- * Confirm Herdr is really there before the name reaches `pty.spawn`.
534
- *
535
- * node-pty resolves the command with `execvp(3)` inside the forked child, so
536
- * a missing binary is not a spawn error anyone can catch: the child writes
537
- * "execvp(3) failed.: No such file or directory" into the PTY and exits. The
538
- * `session_exit` that follows makes the relay drop the browser's socket, the
539
- * browser reconnects into the same broken start, and that is the reconnect
540
- * loop of issue #1. Refusing to start keeps the socket up and puts the actual
541
- * reason in front of the user.
542
- *
543
- * Re-resolved per session rather than cached from the constructor: a service
544
- * that started before Herdr was installed should pick it up without a restart.
545
- */
546
- ensureHerdrCommand() {
547
- const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
548
- if (resolved.found) {
549
- this.herdrCommand = resolved.command;
550
- this.warnIfHerdrOutdated();
551
- return null;
552
- }
553
- return herdrNotFoundMessage(this.herdrLookup);
554
- }
555
-
556
- /**
557
- * Say once that the installed Herdr is older than this release expects.
558
- *
559
- * Never a refusal to start. An older Herdr still runs — it just misbehaves in
560
- * ways a browser window notices — and the comment on `startSession` explains
561
- * what a refused start does to a reconnecting browser.
562
- */
563
- warnIfHerdrOutdated() {
564
- if (this.herdrVersionWarned) return;
565
- const installed = this.readHerdrVersion({ command: this.herdrCommand });
566
- if (installed.supported) {
567
- // A version we could not read is checked again on the next session: the
568
- // install may simply not have been finished writing.
569
- if (installed.version) this.herdrVersionWarned = true;
570
- return;
571
- }
572
- this.herdrVersionWarned = true;
573
- process.stderr.write(`herdr-remote host connector: ${herdrOutdatedMessage(installed.version)}\n`);
574
- }
575
-
576
- startSession(message) {
577
- const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
578
- if (!streamId) return undefined;
579
- this.stopSession(streamId);
580
- // A window opening is when a person is looking; that is when to ask.
581
- this.reportUpdateStatus();
582
- this.pickUpTerminalFont();
583
- const missingHerdr = this.ensureHerdrCommand();
584
- if (missingHerdr) {
585
- process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
586
- sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_not_found', message: missingHerdr });
587
- return undefined;
588
- }
589
- const socketInfo = inspectSocket(this.socketPath);
590
- if (!socketInfo.ok && !socketInfo.missing) {
591
- sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
592
- return undefined;
593
- }
594
- if (!socketInfo.ok) {
595
- this.waitForHerdr(streamId, message);
596
- return undefined;
597
- }
598
- // The socket file alone proves nothing: a `herdr` client pointed at a dead
599
- // one starts a server of its own, inside this process's service, where the
600
- // next restart of herdr-remote would kill it. Only a server that accepts a
601
- // connection gets a client.
602
- const pending = { message };
603
- this.probingStarts.set(streamId, pending);
604
- return this.probeHerdr(this.socketPath).then((probe) => {
605
- // Stopped, or started again, while the answer was in flight.
606
- if (this.probingStarts.get(streamId) !== pending) return;
607
- this.probingStarts.delete(streamId);
608
- if (probe.state === 'running') this.spawnSession(pending.message);
609
- else if (probe.state === 'stopped') this.waitForHerdr(streamId, pending.message);
610
- else sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: probe.reason });
611
- });
612
- }
613
-
614
- /**
615
- * Tell every window whether this workstation's herdr-remote is behind the
616
- * newest release. Asked at most once a minute however many windows open;
617
- * the relay keeps the last answer for windows that open in between. A failed
618
- * check says nothing: offline is not news.
619
- */
620
- reportUpdateStatus({ now = Date.now() } = {}) {
621
- if (!this.checkUpdate || this.updateCheck) return this.updateCheck;
622
- if (now - this.updateCheckedAt < UPDATE_RECHECK_MS) return null;
623
- this.updateCheckedAt = now;
624
- this.updateCheck = Promise.resolve()
625
- .then(() => this.checkUpdate())
626
- .then((result) => {
627
- if (!result?.ok || !result.latest) return;
628
- const running = this.runningVersion;
629
- const installed = this.readInstalledVersion() || running;
630
- sendJson(this.ws, {
631
- type: 'update_status',
632
- current: running,
633
- installed,
634
- latest: result.latest,
635
- updateAvailable: compareVersions(result.latest, running) > 0,
636
- // Updated on disk, still running the old code: a restart finishes it.
637
- restartPending: compareVersions(installed, running) > 0,
638
- });
639
- })
640
- .catch(() => {})
641
- .finally(() => { this.updateCheck = null; });
642
- return this.updateCheck;
643
- }
644
-
645
- /**
646
- * Hold a window's session until Herdr runs, and ask the browser whether it
647
- * should be started. Nothing starts until somebody says yes.
648
- */
649
- waitForHerdr(streamId, message) {
650
- this.waitingForHerdr.set(streamId, message);
651
- sendJson(this.ws, {
652
- type: 'error',
653
- clientId: streamId,
654
- code: 'herdr_not_running',
655
- message: `Herdr is not running on this workstation (${this.socketPath}).`,
656
- });
657
- }
658
-
659
- /**
660
- * Start this workstation's Herdr server, once, however many windows or
661
- * callers ask at the same time.
662
- */
663
- startHerdr() {
664
- if (!this.herdrLaunch) {
665
- this.herdrLaunch = Promise.resolve()
666
- .then(() => {
667
- const missing = this.ensureHerdrCommand();
668
- if (missing) throw new Error(missing);
669
- return this.ensureHerdr({
670
- command: this.herdrCommand,
671
- args: this.herdrArgs,
672
- socketPath: this.socketPath,
673
- cwd: this.cwd,
674
- logPath: this.herdrLogPath,
675
- });
676
- })
677
- .finally(() => { this.herdrLaunch = null; });
678
- }
679
- return this.herdrLaunch;
680
- }
681
-
682
- /**
683
- * A browser said yes. The relay only ever routes this from a window paired
684
- * to *this* workstation, so what starts is this user's own Herdr, at the
685
- * socket this connector is configured for — never one named by the message.
686
- * Once it runs, every window waiting on it gets its session.
687
- */
688
- async handleHerdrStart(message) {
689
- const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
690
- try {
691
- const result = await this.startHerdr();
692
- if (result.started) process.stderr.write('herdr-remote host connector: started Herdr at a browser\'s request\n');
693
- } catch (error) {
694
- process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
695
- if (streamId) {
696
- sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message: error.message });
697
- }
698
- return;
699
- }
700
- const waiting = [...this.waitingForHerdr.values()];
701
- this.waitingForHerdr.clear();
702
- for (const held of waiting) this.spawnSession(held);
703
- }
704
-
705
- /** Start the PTY for a window once Herdr is known to be running. */
706
- spawnSession(message) {
707
- const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
708
- const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
709
- const pty = new this.PtySession({
710
- command: this.herdrCommand,
711
- args: this.herdrArgs,
712
- cwd: this.cwd,
713
- socketPath: this.socketPath,
714
- });
715
- const session = {
716
- id: streamId,
717
- streamIndex,
718
- pty,
719
- cols: Number(message.cols) || PtySession.DEFAULT_COLS,
720
- rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
721
- createdAt: new Date().toISOString(),
722
- startedAtMs: Date.now(),
723
- clientId: streamId,
724
- pendingOutput: [],
725
- flushImmediate: null,
726
- };
727
- const flushOutput = () => {
728
- if (session.flushImmediate) {
729
- clearImmediate(session.flushImmediate);
730
- session.flushImmediate = null;
731
- }
732
- if (session.pendingOutput.length === 0) return;
733
- const chunks = session.pendingOutput;
734
- session.pendingOutput = [];
735
- if (this.ws?.readyState === WebSocket.OPEN) {
736
- const payload = Buffer.concat(chunks);
737
- const frame = typeof session.streamIndex === 'number'
738
- ? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
739
- : packStreamFrame('output', streamId, payload);
740
- this.ws.send(frame);
741
- }
742
- };
743
- try {
744
- pty.start({
745
- cols: session.cols,
746
- rows: session.rows,
747
- onData: (data) => {
748
- const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
749
- if (chunk.length === 0) return;
750
- session.pendingOutput.push(chunk);
751
- if (!session.flushImmediate) {
752
- // Coalesce burst output from the same tick into a single frame to reduce
753
- // packet overhead without introducing timer latency.
754
- session.flushImmediate = setImmediate(flushOutput);
755
- }
756
- },
757
- onExit: ({ exitCode }) => {
758
- if (this.sessions.get(streamId) !== session) return;
759
- this.sessions.delete(streamId);
760
- if (typeof session.streamIndex === 'number') {
761
- this.streamIndexToId.delete(session.streamIndex);
762
- }
763
- if (session.flushImmediate) {
764
- clearImmediate(session.flushImmediate);
765
- session.flushImmediate = null;
766
- }
767
- session.pendingOutput = [];
768
- this.reportSessionExit(session, exitCode);
769
- this.sendHeartbeat();
770
- },
771
- });
772
- } catch (error) {
773
- if (session.flushImmediate) {
774
- clearImmediate(session.flushImmediate);
775
- session.flushImmediate = null;
776
- }
777
- session.pendingOutput = [];
778
- sendJson(this.ws, { type: 'error', clientId: streamId, code: 'pty_start_failed', message: error.message });
779
- pty.kill();
780
- return;
781
- }
782
- this.sessions.set(streamId, session);
783
- if (typeof streamIndex === 'number') {
784
- this.streamIndexToId.set(streamIndex, streamId);
785
- }
786
- sendJson(this.ws, { type: 'session_ready', clientId: streamId });
787
- this.sendHeartbeat();
788
- }
789
-
790
- /**
791
- * Tell the relay how a session ended.
792
- *
793
- * `session_exit` is the honest answer for a session that ran, and the relay
794
- * closes the browser's socket on it so the window can start a fresh one. That
795
- * is also what turns a start that keeps failing into a reconnect loop: exit,
796
- * close, reconnect, exit. Once a few starts in a row have died immediately
797
- * with a non-zero code the failure is systemic, not a session ending, so it is
798
- * reported as an error — which the relay forwards without dropping the socket,
799
- * leaving the user with a message instead of a spinner.
800
- */
801
- reportSessionExit(session, exitCode) {
802
- const streamId = session.id;
803
- const lifetimeMs = Date.now() - session.startedAtMs;
804
- const failedFast = exitCode !== 0 && lifetimeMs < HostConnector.FAST_FAILURE_MS;
805
- this.fastFailures = failedFast ? this.fastFailures + 1 : 0;
806
- if (this.fastFailures < HostConnector.FAST_FAILURE_LIMIT) {
807
- sendJson(this.ws, { type: 'session_exit', clientId: streamId, code: exitCode });
808
- return;
809
- }
810
- const message = `"${this.herdrCommand}" exited immediately with code ${exitCode} on ${this.fastFailures} attempts in a row. `
811
- + 'Check that it runs from a terminal, and see the host connector log for what it printed.';
812
- process.stderr.write(`herdr-remote host connector: ${message}\n`);
813
- sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_start_failed', message });
814
- }
815
-
816
- stopSession(streamId) {
817
- this.waitingForHerdr.delete(streamId);
818
- this.probingStarts.delete(streamId);
819
- const session = this.sessions.get(streamId);
820
- if (!session) return;
821
- this.sessions.delete(streamId);
822
- if (typeof session.streamIndex === 'number') {
823
- this.streamIndexToId.delete(session.streamIndex);
824
- }
825
- if (session.flushImmediate) {
826
- clearImmediate(session.flushImmediate);
827
- session.flushImmediate = null;
828
- }
829
- session.pendingOutput = [];
830
- session.pty.kill();
831
- this.sendHeartbeat();
832
- }
833
-
834
- startKeepalive() {
835
- this.stopKeepalive();
836
- if (!this.ws || this.stopping) return;
837
- this.ws.isAlive = true;
838
- const interval = this.config?.cleanup?.heartbeatIntervalMs || 30_000;
839
- this.keepaliveTimer = setInterval(() => this.tickKeepalive(), interval);
840
- }
841
-
842
- stopKeepalive() {
843
- if (this.keepaliveTimer) {
844
- clearInterval(this.keepaliveTimer);
845
- this.keepaliveTimer = null;
846
- }
847
- }
848
-
849
- tickKeepalive() {
850
- const ws = this.ws;
851
- if (!ws || this.stopping) return;
852
- if (ws.readyState === WebSocket.CLOSING) {
853
- terminateSocket(ws);
854
- return;
855
- }
856
- if (ws.readyState !== WebSocket.OPEN) return;
857
-
858
- if (!ws.isAlive) {
859
- terminateSocket(ws);
860
- return;
861
- }
862
-
863
- ws.isAlive = false;
864
- try {
865
- if (typeof ws.ping === 'function') ws.ping();
866
- } catch {}
867
-
868
- if (this.clientCount <= 0 && !this.legacyHeartbeat) {
869
- sendJson(ws, {
870
- type: 'heartbeat',
871
- load: {},
872
- ptys: [],
873
- });
874
- }
875
- }
876
-
877
- setClientCount(value) {
878
- const next = Number.isInteger(value) ? Math.max(0, value) : 0;
879
- if (next === this.clientCount && (next === 0 || this.heartbeatTimer)) return;
880
- const wasActive = this.clientCount > 0;
881
- this.clientCount = next;
882
- if (next > 0) {
883
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
884
- this.sendHeartbeat(true);
885
- this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
886
- } else {
887
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
888
- this.heartbeatTimer = null;
889
- if (wasActive) this.sendHeartbeat(true);
890
- }
891
- // Nobody watching, nothing to ask Herdr about itself.
892
- if (next > 0) this.startAgentStatus();
893
- else this.stopAgentStatus();
894
- }
895
-
896
- /**
897
- * Watch what the agents on this workstation are doing.
898
- *
899
- * A side channel, on its own socket, alive only while a browser is attached.
900
- * Herdr's server can be restarted underneath a running herdr-remote — the
901
- * PTYs survive it — so every failure here is a retry, never anything a
902
- * terminal session notices.
903
- *
904
- * A long-lived subscription provides fast refreshes on pane and workspace
905
- * focus changes; the five-second poll still repairs missed events and a
906
- * restarted Herdr server. Both paths only read the side-channel snapshot.
907
- */
908
- startAgentStatus() {
909
- if (this.agentStatusTimer || this.stopping) return;
910
- this.agentStatusTimer = setInterval(() => this.readAgentStatus(), AGENT_STATUS_POLL_MS);
911
- if (typeof this.agentStatusTimer.unref === 'function') this.agentStatusTimer.unref();
912
- try {
913
- this.agentStatusSubscription = this.subscribeHerdr(
914
- this.socketPath,
915
- AGENT_STATUS_SUBSCRIPTIONS,
916
- () => this.scheduleAgentStatusRead(),
917
- );
918
- } catch {
919
- // A missing or restarting Herdr server leaves the regular poll in place.
920
- }
921
- this.readAgentStatus();
922
- }
923
-
924
- scheduleAgentStatusRead() {
925
- if (!this.agentStatusTimer) return;
926
- if (this.agentStatusDebounceTimer) clearTimeout(this.agentStatusDebounceTimer);
927
- this.agentStatusDebounceTimer = setTimeout(() => {
928
- this.agentStatusDebounceTimer = null;
929
- this.readAgentStatus();
930
- }, AGENT_STATUS_EVENT_DEBOUNCE_MS);
931
- if (typeof this.agentStatusDebounceTimer.unref === 'function') this.agentStatusDebounceTimer.unref();
932
- }
933
-
934
- stopAgentStatus() {
935
- if (this.agentStatusTimer) {
936
- clearInterval(this.agentStatusTimer);
937
- this.agentStatusTimer = null;
938
- }
939
- if (this.agentStatusDebounceTimer) {
940
- clearTimeout(this.agentStatusDebounceTimer);
941
- this.agentStatusDebounceTimer = null;
942
- }
943
- if (this.agentStatusSubscription) {
944
- this.agentStatusSubscription.close();
945
- this.agentStatusSubscription = null;
946
- }
947
- this.lastAgentSummary = null;
948
- }
949
-
950
- /**
951
- * Read the summary and forward it if it changed.
952
- *
953
- * A slow answer must not stack up behind the next tick, and a failed one
954
- * leaves the last summary standing rather than blanking the browser's chip:
955
- * Herdr's server not running is an ordinary state, not news.
956
- */
957
- async readAgentStatus() {
958
- if (this.agentStatusPending || !this.agentStatusTimer) return;
959
- this.agentStatusPending = true;
960
- let snapshot;
961
- try {
962
- const result = await this.requestHerdr(this.socketPath, 'session.snapshot', {});
963
- snapshot = result?.snapshot || result;
964
- } catch {
965
- return;
966
- } finally {
967
- this.agentStatusPending = false;
968
- }
969
- // Stopped while the answer was in flight; the browser it was for is gone.
970
- if (!this.agentStatusTimer) return;
971
- const summary = summarizeAgents(snapshot);
972
- if (sameSummary(this.lastAgentSummary, summary)) return;
973
- this.lastAgentSummary = summary;
974
- sendJson(this.ws, { type: 'agent_status', ...summary });
975
- }
976
-
977
- resizeSession(message) {
978
- const id = message.clientId || message.streamId;
979
- // A window still waiting on Herdr starts at the size it has by then.
980
- const held = this.waitingForHerdr.get(id) || this.probingStarts.get(id)?.message;
981
- if (held) {
982
- if (Number.isInteger(message.cols)) held.cols = message.cols;
983
- if (Number.isInteger(message.rows)) held.rows = message.rows;
984
- }
985
- const session = this.sessions.get(id);
986
- if (!session) return;
987
- session.cols = Number.isInteger(message.cols) ? Math.min(500, Math.max(2, message.cols)) : session.cols;
988
- session.rows = Number.isInteger(message.rows) ? Math.min(500, Math.max(2, message.rows)) : session.rows;
989
- session.pty.resize(session.cols, session.rows);
990
- }
991
-
992
- handlePasteFile(message) {
993
- const streamId = message.clientId || message.streamId;
994
- try {
995
- const savedPath = savePastedFile({
996
- mime: message.mime,
997
- dataBase64: message.dataBase64,
998
- });
999
- sendJson(this.ws, {
1000
- type: 'paste_file_ready',
1001
- clientId: streamId,
1002
- path: savedPath,
1003
- });
1004
- } catch (error) {
1005
- sendJson(this.ws, {
1006
- type: 'error',
1007
- clientId: streamId,
1008
- code: 'paste_file_write_failed',
1009
- message: error.message || 'Failed to save pasted file',
1010
- });
1011
- }
1012
- }
1013
-
1014
- destroySessions() {
1015
- for (const session of this.sessions.values()) {
1016
- if (session.flushImmediate) {
1017
- clearImmediate(session.flushImmediate);
1018
- session.flushImmediate = null;
1019
- }
1020
- session.pendingOutput = [];
1021
- session.pty.kill();
1022
- }
1023
- this.sessions.clear();
1024
- this.streamIndexToId.clear();
1025
- this.waitingForHerdr.clear();
1026
- this.probingStarts.clear();
1027
- }
1028
-
1029
- sendHeartbeat(force = false) {
1030
- if (!force && this.clientCount <= 0 && !this.legacyHeartbeat) return;
1031
- const memory = process.memoryUsage();
1032
- const load = os.loadavg();
1033
- sendJson(this.ws, {
1034
- type: 'heartbeat',
1035
- load: {
1036
- load1m: load[0] || 0,
1037
- load5m: load[1] || 0,
1038
- load15m: load[2] || 0,
1039
- rssBytes: memory.rss,
1040
- heapUsedBytes: memory.heapUsed,
1041
- },
1042
- ptys: [...this.sessions.values()].map((session) => ({
1043
- id: session.id,
1044
- pid: session.pty.terminal?.pid || null,
1045
- command: session.pty.command,
1046
- cols: session.cols,
1047
- rows: session.rows,
1048
- cwd: session.pty.cwd,
1049
- createdAt: session.createdAt,
1050
- activeClients: 1,
1051
- })),
1052
- });
1053
- }
1054
- }
1055
-
1056
- if (require.main === module) {
1057
- const connector = new HostConnector();
1058
- try {
1059
- connector.start();
1060
- } catch (error) {
1061
- process.stderr.write(`herdr-remote host connector failed: ${error.message}\n`);
1062
- process.exitCode = error.code === 'HOST_ALREADY_RUNNING' ? EXIT_REPLACED : 1;
1063
- }
1064
- const stop = () => { connector.stop(); process.exit(0); };
1065
- process.once('SIGINT', stop);
1066
- process.once('SIGTERM', stop);
1067
- }
1068
-
1069
- module.exports = { HostConnector, PROTOCOL_VERSION };