herdr-remote 0.2.12 → 0.2.14

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.
@@ -9,7 +9,13 @@ const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require(
9
9
  const { ensureDir } = require('./state');
10
10
  const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
11
11
  const { PtySession } = require('./pty-session');
12
- const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = require('./herdr-command');
12
+ const {
13
+ resolveHerdrCommand,
14
+ verifyHerdrCommand,
15
+ herdrNotFoundMessage,
16
+ herdrOutdatedMessage,
17
+ herdrVersion,
18
+ } = require('./herdr-command');
13
19
  // The wire format lives in the relay package so both ends of the protocol are
14
20
  // generated from one definition.
15
21
  const {
@@ -20,9 +26,21 @@ const {
20
26
  PROTOCOL_VERSION,
21
27
  } = require('herdr-remote-relay/protocol');
22
28
  const { resolveHostPalette } = require('./terminal-palette');
29
+ const { requestHerdr, subscribeHerdr } = require('./herdr-api');
30
+ const { sameSummary, summarizeAgents } = require('./agent-status');
23
31
  const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
24
32
  const { savePastedFile, cleanPastedDir } = require('./pasted-files');
25
33
 
34
+ /** How often the agent summary is re-read while a browser is watching. */
35
+ const AGENT_STATUS_POLL_MS = 5_000;
36
+ const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
37
+ const AGENT_STATUS_SUBSCRIPTIONS = [
38
+ { type: 'pane.focused' },
39
+ { type: 'tab.focused' },
40
+ { type: 'workspace.focused' },
41
+ { type: 'pane.agent_detected' },
42
+ ];
43
+
26
44
  function randomId(prefix) {
27
45
  return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
28
46
  }
@@ -82,6 +100,8 @@ class HostConnector {
82
100
  // real environment unless a caller narrows it.
83
101
  this.herdrLookup = options.herdrLookup || {};
84
102
  this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
103
+ this.readHerdrVersion = options.readHerdrVersion || herdrVersion;
104
+ this.herdrVersionWarned = false;
85
105
  this.herdrArgs = options.herdrArgs || config.herdr.args;
86
106
  this.cwd = options.cwd || config.herdr.cwd;
87
107
  /**
@@ -98,6 +118,14 @@ class HostConnector {
98
118
  this.sessions = new Map();
99
119
  this.streamIndexToId = new Map();
100
120
  this.PtySession = options.PtySession || PtySession;
121
+ /** The side channel that answers "does anything need me?"; see startAgentStatus. */
122
+ this.requestHerdr = options.requestHerdr || requestHerdr;
123
+ this.subscribeHerdr = options.subscribeHerdr || subscribeHerdr;
124
+ this.agentStatusTimer = null;
125
+ this.agentStatusDebounceTimer = null;
126
+ this.agentStatusSubscription = null;
127
+ this.agentStatusPending = false;
128
+ this.lastAgentSummary = null;
101
129
  this.fastFailures = 0;
102
130
  this.reconnectTimer = null;
103
131
  this.heartbeatTimer = null;
@@ -169,6 +197,7 @@ class HostConnector {
169
197
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
170
198
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
171
199
  this.stopKeepalive();
200
+ this.stopAgentStatus();
172
201
  this.reconnectTimer = null;
173
202
  this.heartbeatTimer = null;
174
203
  sendJson(this.ws, { type: 'host_shutdown' });
@@ -225,6 +254,9 @@ class HostConnector {
225
254
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
226
255
  this.heartbeatTimer = null;
227
256
  this.stopKeepalive();
257
+ // Nothing to report to, so stop asking Herdr. A reconnect brings the
258
+ // watcher back with the relay's next client count.
259
+ this.stopAgentStatus();
228
260
  this.ready = false;
229
261
  this.clientCount = 0;
230
262
  this.legacyHeartbeat = false;
@@ -334,11 +366,32 @@ class HostConnector {
334
366
  const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
335
367
  if (resolved.found) {
336
368
  this.herdrCommand = resolved.command;
369
+ this.warnIfHerdrOutdated();
337
370
  return null;
338
371
  }
339
372
  return herdrNotFoundMessage(this.herdrLookup);
340
373
  }
341
374
 
375
+ /**
376
+ * Say once that the installed Herdr is older than this release expects.
377
+ *
378
+ * Never a refusal to start. An older Herdr still runs — it just misbehaves in
379
+ * ways a browser window notices — and the comment on `startSession` explains
380
+ * what a refused start does to a reconnecting browser.
381
+ */
382
+ warnIfHerdrOutdated() {
383
+ if (this.herdrVersionWarned) return;
384
+ const installed = this.readHerdrVersion({ command: this.herdrCommand });
385
+ if (installed.supported) {
386
+ // A version we could not read is checked again on the next session: the
387
+ // install may simply not have been finished writing.
388
+ if (installed.version) this.herdrVersionWarned = true;
389
+ return;
390
+ }
391
+ this.herdrVersionWarned = true;
392
+ process.stderr.write(`herdr-remote host connector: ${herdrOutdatedMessage(installed.version)}\n`);
393
+ }
394
+
342
395
  startSession(message) {
343
396
  const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
344
397
  if (!streamId) return;
@@ -535,6 +588,90 @@ class HostConnector {
535
588
  this.heartbeatTimer = null;
536
589
  if (wasActive) this.sendHeartbeat(true);
537
590
  }
591
+ // Nobody watching, nothing to ask Herdr about itself.
592
+ if (next > 0) this.startAgentStatus();
593
+ else this.stopAgentStatus();
594
+ }
595
+
596
+ /**
597
+ * Watch what the agents on this workstation are doing.
598
+ *
599
+ * A side channel, on its own socket, alive only while a browser is attached.
600
+ * Herdr's server can be restarted underneath a running herdr-remote — the
601
+ * PTYs survive it — so every failure here is a retry, never anything a
602
+ * terminal session notices.
603
+ *
604
+ * A long-lived subscription provides fast refreshes on pane and workspace
605
+ * focus changes; the five-second poll still repairs missed events and a
606
+ * restarted Herdr server. Both paths only read the side-channel snapshot.
607
+ */
608
+ startAgentStatus() {
609
+ if (this.agentStatusTimer || this.stopping) return;
610
+ this.agentStatusTimer = setInterval(() => this.readAgentStatus(), AGENT_STATUS_POLL_MS);
611
+ if (typeof this.agentStatusTimer.unref === 'function') this.agentStatusTimer.unref();
612
+ try {
613
+ this.agentStatusSubscription = this.subscribeHerdr(
614
+ this.socketPath,
615
+ AGENT_STATUS_SUBSCRIPTIONS,
616
+ () => this.scheduleAgentStatusRead(),
617
+ );
618
+ } catch {
619
+ // A missing or restarting Herdr server leaves the regular poll in place.
620
+ }
621
+ this.readAgentStatus();
622
+ }
623
+
624
+ scheduleAgentStatusRead() {
625
+ if (!this.agentStatusTimer) return;
626
+ if (this.agentStatusDebounceTimer) clearTimeout(this.agentStatusDebounceTimer);
627
+ this.agentStatusDebounceTimer = setTimeout(() => {
628
+ this.agentStatusDebounceTimer = null;
629
+ this.readAgentStatus();
630
+ }, AGENT_STATUS_EVENT_DEBOUNCE_MS);
631
+ if (typeof this.agentStatusDebounceTimer.unref === 'function') this.agentStatusDebounceTimer.unref();
632
+ }
633
+
634
+ stopAgentStatus() {
635
+ if (this.agentStatusTimer) {
636
+ clearInterval(this.agentStatusTimer);
637
+ this.agentStatusTimer = null;
638
+ }
639
+ if (this.agentStatusDebounceTimer) {
640
+ clearTimeout(this.agentStatusDebounceTimer);
641
+ this.agentStatusDebounceTimer = null;
642
+ }
643
+ if (this.agentStatusSubscription) {
644
+ this.agentStatusSubscription.close();
645
+ this.agentStatusSubscription = null;
646
+ }
647
+ this.lastAgentSummary = null;
648
+ }
649
+
650
+ /**
651
+ * Read the summary and forward it if it changed.
652
+ *
653
+ * A slow answer must not stack up behind the next tick, and a failed one
654
+ * leaves the last summary standing rather than blanking the browser's chip:
655
+ * Herdr's server not running is an ordinary state, not news.
656
+ */
657
+ async readAgentStatus() {
658
+ if (this.agentStatusPending || !this.agentStatusTimer) return;
659
+ this.agentStatusPending = true;
660
+ let snapshot;
661
+ try {
662
+ const result = await this.requestHerdr(this.socketPath, 'session.snapshot', {});
663
+ snapshot = result?.snapshot || result;
664
+ } catch {
665
+ return;
666
+ } finally {
667
+ this.agentStatusPending = false;
668
+ }
669
+ // Stopped while the answer was in flight; the browser it was for is gone.
670
+ if (!this.agentStatusTimer) return;
671
+ const summary = summarizeAgents(snapshot);
672
+ if (sameSummary(this.lastAgentSummary, summary)) return;
673
+ this.lastAgentSummary = summary;
674
+ sendJson(this.ws, { type: 'agent_status', ...summary });
538
675
  }
539
676
 
540
677
  resizeSession(message) {
package/src/i18n/en.js CHANGED
@@ -155,6 +155,8 @@ module.exports = {
155
155
  'herdr.unregisterDone': 'Plugin unregistered.',
156
156
  'herdr.registerFailed': 'Registration failed: {message}',
157
157
  'herdr.cliMissing': 'Command herdr not found. Set HERDR_BIN_PATH to its full path.',
158
+ 'herdr.version': 'Herdr version',
159
+ 'herdr.versionOutdated': 'Herdr {version} is older than {minimum}. Run "herdr update": window titles, background machine activation and large pastes misbehave in browser windows before that release.',
158
160
 
159
161
  'about.title': 'Language & about',
160
162
  'about.language': 'Interface language',
package/src/i18n/zh.js CHANGED
@@ -154,6 +154,8 @@ module.exports = {
154
154
  'herdr.unregisterDone': '已取消注册。',
155
155
  'herdr.registerFailed': '注册失败:{message}',
156
156
  'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
157
+ 'herdr.version': 'Herdr 版本',
158
+ 'herdr.versionOutdated': 'Herdr {version} 低于 {minimum},请运行 “herdr update”:在该版本之前,浏览器窗口的标题、后台机器激活与大段粘贴都会出问题。',
157
159
 
158
160
  'about.title': '语言与关于',
159
161
  'about.language': '界面语言',
package/src/service.js CHANGED
@@ -26,7 +26,7 @@ const {
26
26
  } = require('./terminal-palette');
27
27
  const { resolveSocketPath } = require('./socket-discovery');
28
28
  const { preferredLanAddress } = require('./net-interfaces');
29
- const { findHerdrCommand, resolveHerdrCommand } = require('./herdr-command');
29
+ const { MIN_HERDR_VERSION, findHerdrCommand, herdrVersion, resolveHerdrCommand } = require('./herdr-command');
30
30
 
31
31
  const RUNTIME_VERSION = 2;
32
32
 
@@ -418,6 +418,9 @@ async function statusServices() {
418
418
  // Where Herdr was found, and whether it was found at all: under a service
419
419
  // manager this is the first thing to check when sessions will not start.
420
420
  const herdr = findHerdrCommand();
421
+ // Only worth spawning when there is something to spawn; an unresolved command
422
+ // has already answered the question the version would.
423
+ const installed = herdr.found ? herdrVersion({ command: herdr.command }) : { version: null, supported: true };
421
424
  return {
422
425
  ok: true,
423
426
  mode: config.relay.mode,
@@ -439,6 +442,9 @@ async function statusServices() {
439
442
  herdrCommand: herdr.command,
440
443
  herdrCommandFound: herdr.found,
441
444
  herdrCommandSource: herdr.source,
445
+ herdrVersion: installed.version,
446
+ herdrVersionSupported: installed.supported,
447
+ herdrVersionMinimum: MIN_HERDR_VERSION,
442
448
  },
443
449
  publicUrl: resolvePublicUrl(config, lanAddress),
444
450
  startedAt: state.startedAt || null,