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.
- package/dist/tui.mjs +373 -324
- package/herdr-plugin.toml +2 -2
- package/package.json +1 -1
- package/src/agent-status.js +131 -0
- package/src/herdr-api.js +244 -0
- package/src/herdr-command.js +72 -0
- package/src/herdr-plugin.js +9 -5
- package/src/host-connector.js +138 -1
- package/src/i18n/en.js +2 -0
- package/src/i18n/zh.js +2 -0
- package/src/service.js +7 -1
package/src/host-connector.js
CHANGED
|
@@ -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 {
|
|
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,
|