herdr-remote 0.2.11 → 0.2.13
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 +114 -0
- package/src/herdr-api.js +123 -0
- package/src/herdr-command.js +72 -0
- package/src/herdr-plugin.js +9 -5
- package/src/host-connector.js +162 -1
- package/src/i18n/en.js +2 -0
- package/src/i18n/zh.js +2 -0
- package/src/service.js +7 -1
package/herdr-plugin.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
id = "herdr.remote.web"
|
|
2
2
|
name = "Herdr Remote Web"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
min_herdr_version = "0.9.
|
|
3
|
+
version = "0.2.13"
|
|
4
|
+
min_herdr_version = "0.9.1"
|
|
5
5
|
description = "Browser access to Herdr workspaces via local or self-hosted relay"
|
|
6
6
|
platforms = ["linux", "macos"]
|
|
7
7
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the agents on this workstation are doing, small enough to put in a
|
|
5
|
+
* status bar.
|
|
6
|
+
*
|
|
7
|
+
* The question a phone actually has is "does anything need me?", and the
|
|
8
|
+
* terminal is a poor way to ask it: finding a blocked agent means navigating
|
|
9
|
+
* Herdr's sidebar through a viewport the width of a hand. Herdr already knows
|
|
10
|
+
* the answer — it classifies every pane as idle, working, blocked, done or
|
|
11
|
+
* unknown — so this turns a session snapshot into the two-line version.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately a pure function over a snapshot. The socket, the debounce and
|
|
14
|
+
* the retry live in the host connector; what counts as "needs me" lives here,
|
|
15
|
+
* where it can be tested without either.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Herdr's own vocabulary, in the order a person cares about them. */
|
|
19
|
+
const AGENT_STATUSES = ['blocked', 'done', 'working', 'idle', 'unknown'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How many agents travel with the summary.
|
|
23
|
+
*
|
|
24
|
+
* The counts are always exact; this caps only the named list, which exists so a
|
|
25
|
+
* future screen can say *which* agent is blocked. A workstation with more than
|
|
26
|
+
* this many live agents is not going to be read off a phone anyway.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_LISTED_AGENTS = 16;
|
|
29
|
+
|
|
30
|
+
/** Titles come from a remote program; keep them short and printable. */
|
|
31
|
+
const MAX_TITLE_LENGTH = 48;
|
|
32
|
+
|
|
33
|
+
function cleanText(value, limit = MAX_TITLE_LENGTH) {
|
|
34
|
+
if (typeof value !== 'string') return null;
|
|
35
|
+
// eslint-disable-next-line no-control-regex
|
|
36
|
+
const collapsed = value.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
37
|
+
if (!collapsed) return null;
|
|
38
|
+
return collapsed.length > limit ? collapsed.slice(0, limit) : collapsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeStatus(value) {
|
|
42
|
+
return AGENT_STATUSES.includes(value) ? value : 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyCounts() {
|
|
46
|
+
return Object.fromEntries(AGENT_STATUSES.map((status) => [status, 0]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Turn `session.snapshot` into the summary the browser receives.
|
|
51
|
+
*
|
|
52
|
+
* Sorted by how much each status wants attention, so the capped list keeps the
|
|
53
|
+
* agents worth naming rather than the first ones Herdr happened to report.
|
|
54
|
+
*/
|
|
55
|
+
function summarizeAgents(snapshot) {
|
|
56
|
+
const entries = Array.isArray(snapshot?.agents) ? snapshot.agents : [];
|
|
57
|
+
const counts = emptyCounts();
|
|
58
|
+
const ranked = [];
|
|
59
|
+
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry || typeof entry.pane_id !== 'string') continue;
|
|
62
|
+
const status = normalizeStatus(entry.agent_status);
|
|
63
|
+
counts[status] += 1;
|
|
64
|
+
ranked.push({
|
|
65
|
+
paneId: entry.pane_id,
|
|
66
|
+
workspaceId: typeof entry.workspace_id === 'string' ? entry.workspace_id : null,
|
|
67
|
+
agent: cleanText(entry.display_agent || entry.agent, 24),
|
|
68
|
+
title: cleanText(entry.terminal_title_stripped || entry.terminal_title),
|
|
69
|
+
status,
|
|
70
|
+
focused: entry.focused === true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ranked.sort((left, right) => AGENT_STATUSES.indexOf(left.status) - AGENT_STATUSES.indexOf(right.status));
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
counts,
|
|
78
|
+
// Total rather than a sum the browser has to compute, and the one number a
|
|
79
|
+
// collapsed chip can show on its own.
|
|
80
|
+
total: ranked.length,
|
|
81
|
+
agents: ranked.slice(0, MAX_LISTED_AGENTS),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether two summaries say the same thing, so an unchanged one is not sent. */
|
|
86
|
+
function sameSummary(left, right) {
|
|
87
|
+
if (!left || !right) return left === right;
|
|
88
|
+
if (left.total !== right.total) return false;
|
|
89
|
+
for (const status of AGENT_STATUSES) {
|
|
90
|
+
if ((left.counts?.[status] || 0) !== (right.counts?.[status] || 0)) return false;
|
|
91
|
+
}
|
|
92
|
+
if (left.agents.length !== right.agents.length) return false;
|
|
93
|
+
return left.agents.every((agent, index) => {
|
|
94
|
+
const other = right.agents[index];
|
|
95
|
+
return other
|
|
96
|
+
&& agent.paneId === other.paneId
|
|
97
|
+
&& agent.status === other.status
|
|
98
|
+
&& agent.agent === other.agent
|
|
99
|
+
&& agent.title === other.title;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The summary of a workstation with nothing to report, or nothing to ask. */
|
|
104
|
+
function emptySummary() {
|
|
105
|
+
return { counts: emptyCounts(), total: 0, agents: [] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
AGENT_STATUSES,
|
|
110
|
+
MAX_LISTED_AGENTS,
|
|
111
|
+
emptySummary,
|
|
112
|
+
sameSummary,
|
|
113
|
+
summarizeAgents,
|
|
114
|
+
};
|
package/src/herdr-api.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One question for Herdr's local socket API.
|
|
5
|
+
*
|
|
6
|
+
* Everything else in this package talks to Herdr by spawning a client into a
|
|
7
|
+
* PTY and shipping the bytes. That is the terminal. This is the side channel:
|
|
8
|
+
* the one place that asks Herdr *about* itself rather than drawing it, so a
|
|
9
|
+
* phone can answer "does anything need me?" without navigating a TUI through a
|
|
10
|
+
* 40-column viewport.
|
|
11
|
+
*
|
|
12
|
+
* The wire format is newline-delimited JSON over the Unix socket:
|
|
13
|
+
*
|
|
14
|
+
* request {"id":"…","method":"session.snapshot","params":{}}
|
|
15
|
+
* response {"id":"…","result":{…}} or {"id":"…","error":{"code","message"}}
|
|
16
|
+
*
|
|
17
|
+
* One request per connection, which is why this is a function and not a client.
|
|
18
|
+
* Herdr closes the socket once it has answered: against 0.9.1 a second request
|
|
19
|
+
* written to the same connection meets `EPIPE`. A long-lived client that
|
|
20
|
+
* reconnects on close therefore turns into a connect-answer-close loop about
|
|
21
|
+
* once a second, busy regardless of how rarely it actually asks anything. The
|
|
22
|
+
* `herdr` CLI connects per command for the same reason, and so does this.
|
|
23
|
+
*
|
|
24
|
+
* Subscriptions are the exception Herdr does keep open — `events.subscribe`
|
|
25
|
+
* holds the connection — but they are no use here: the event that tracks agent
|
|
26
|
+
* state is scoped to a single `pane_id`, and the whole-session pane events stay
|
|
27
|
+
* silent through minutes of continuous agent work. The caller polls.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here may affect a terminal session. Herdr's server is an ordinary
|
|
30
|
+
* process that can be restarted under a running herdr-remote, and when it is,
|
|
31
|
+
* the PTYs keep streaming while this quietly fails and is asked again later.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const net = require('node:net');
|
|
35
|
+
|
|
36
|
+
/** How long one question waits, connection included. */
|
|
37
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
38
|
+
|
|
39
|
+
/** An answer this long is not an answer. Guards a socket that never newlines. */
|
|
40
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
let requestCounter = 0;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ask Herdr one thing.
|
|
46
|
+
*
|
|
47
|
+
* Resolves with the `result` object, rejects for a refused connection, a
|
|
48
|
+
* timeout, an unparseable answer, or an `error` response — in which case
|
|
49
|
+
* `error.code` is Herdr's own code.
|
|
50
|
+
*/
|
|
51
|
+
function requestHerdr(socketPath, method, params = {}, options = {}) {
|
|
52
|
+
const { timeout = REQUEST_TIMEOUT_MS, connect = net.connect } = options;
|
|
53
|
+
requestCounter += 1;
|
|
54
|
+
const id = `herdr-remote:${requestCounter}`;
|
|
55
|
+
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let socket;
|
|
58
|
+
let buffer = '';
|
|
59
|
+
let settled = false;
|
|
60
|
+
|
|
61
|
+
const finish = (error, result) => {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
if (socket) {
|
|
66
|
+
socket.removeAllListeners();
|
|
67
|
+
socket.destroy();
|
|
68
|
+
}
|
|
69
|
+
if (error) reject(error);
|
|
70
|
+
else resolve(result);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const timer = setTimeout(
|
|
74
|
+
() => finish(new Error(`Herdr API did not answer ${method} in time`)),
|
|
75
|
+
timeout,
|
|
76
|
+
);
|
|
77
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
socket = connect(socketPath);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
finish(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
socket.setEncoding('utf8');
|
|
87
|
+
socket.on('connect', () => {
|
|
88
|
+
socket.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
89
|
+
});
|
|
90
|
+
socket.on('data', (chunk) => {
|
|
91
|
+
buffer += chunk;
|
|
92
|
+
if (buffer.length > MAX_RESPONSE_BYTES) {
|
|
93
|
+
finish(new Error('Herdr API sent an oversized response'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const newline = buffer.indexOf('\n');
|
|
97
|
+
if (newline === -1) return;
|
|
98
|
+
let message;
|
|
99
|
+
try {
|
|
100
|
+
message = JSON.parse(buffer.slice(0, newline));
|
|
101
|
+
} catch {
|
|
102
|
+
finish(new Error('Herdr API sent a line that is not JSON'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (message.error) {
|
|
106
|
+
const error = new Error(message.error.message || 'Herdr API request failed');
|
|
107
|
+
error.code = message.error.code || 'herdr_api_error';
|
|
108
|
+
finish(error);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
finish(null, message.result);
|
|
112
|
+
});
|
|
113
|
+
socket.on('error', (error) => finish(error));
|
|
114
|
+
// Herdr hanging up before it answered is the ordinary shape of "the server
|
|
115
|
+
// is not running", not something that needs its own diagnosis.
|
|
116
|
+
socket.on('close', () => finish(new Error(`Herdr API closed before answering ${method}`)));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
REQUEST_TIMEOUT_MS,
|
|
122
|
+
requestHerdr,
|
|
123
|
+
};
|
package/src/herdr-command.js
CHANGED
|
@@ -18,9 +18,25 @@
|
|
|
18
18
|
const fs = require('node:fs');
|
|
19
19
|
const os = require('node:os');
|
|
20
20
|
const path = require('node:path');
|
|
21
|
+
const { spawnSync } = require('node:child_process');
|
|
22
|
+
// The same MAJOR.MINOR.PATCH comparison the self-updater uses. A second copy
|
|
23
|
+
// would be a second place for 0.9.10 to sort below 0.9.9.
|
|
24
|
+
const { compareVersions } = require('./updater');
|
|
21
25
|
|
|
22
26
|
const COMMAND_NAME = 'herdr';
|
|
23
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The oldest Herdr this release is written against.
|
|
30
|
+
*
|
|
31
|
+
* 0.9.0 gave every client its own view, which is what one PTY per browser
|
|
32
|
+
* window rests on. 0.9.1 is what makes that model behave: window titles follow
|
|
33
|
+
* each client's own view instead of another client's selection, activating a
|
|
34
|
+
* machine in the background stops resizing somebody else's focused pane, and a
|
|
35
|
+
* large paste no longer disconnects the client — and pasting is how an image
|
|
36
|
+
* reaches an agent from a phone.
|
|
37
|
+
*/
|
|
38
|
+
const MIN_HERDR_VERSION = '0.9.1';
|
|
39
|
+
|
|
24
40
|
/** Where a user-level install lands, in the order we trust it. */
|
|
25
41
|
const FALLBACK_DIRECTORIES = [
|
|
26
42
|
'~/.local/bin',
|
|
@@ -138,6 +154,57 @@ function verifyHerdrCommand(command, options = {}) {
|
|
|
138
154
|
return findHerdrCommand(options);
|
|
139
155
|
}
|
|
140
156
|
|
|
157
|
+
/** `herdr 0.9.1`, `herdr 0.10.0-preview.2` — the three numbers are all we compare. */
|
|
158
|
+
function parseHerdrVersion(output) {
|
|
159
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(output || ''));
|
|
160
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether an install is new enough.
|
|
165
|
+
*
|
|
166
|
+
* An unreadable version counts as new enough. Not knowing is not evidence of an
|
|
167
|
+
* old Herdr, and a warning nobody can act on is worse than no warning.
|
|
168
|
+
*/
|
|
169
|
+
function meetsMinimum(version, minimum = MIN_HERDR_VERSION) {
|
|
170
|
+
if (!version) return true;
|
|
171
|
+
return compareVersions(version, minimum) >= 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Ask an install what it is.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately the binary and not the socket: `--version` answers before the
|
|
178
|
+
* server is up, which is when the TUI and the plugin registration ask.
|
|
179
|
+
*
|
|
180
|
+
* `ok` is whether the command ran at all — the presence check registration has
|
|
181
|
+
* always used. `version` is null whenever the output could not be read, and
|
|
182
|
+
* `supported` is true in that case for the reason `meetsMinimum` explains.
|
|
183
|
+
*/
|
|
184
|
+
function herdrVersion({ command, timeout = 10_000, ...lookup } = {}) {
|
|
185
|
+
const resolved = command || resolveHerdrCommand(lookup);
|
|
186
|
+
const unknown = { command: resolved, version: null, raw: null, supported: true, ok: false };
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
result = spawnSync(resolved, ['--version'], { encoding: 'utf8', timeout });
|
|
190
|
+
} catch {
|
|
191
|
+
return unknown;
|
|
192
|
+
}
|
|
193
|
+
// Status 1 has always counted as present: a binary that answers at all is
|
|
194
|
+
// installed, whatever it thinks of the flag.
|
|
195
|
+
if (result.error || (result.status !== 0 && result.status !== 1)) return unknown;
|
|
196
|
+
const raw = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
197
|
+
const version = parseHerdrVersion(raw);
|
|
198
|
+
return { command: resolved, version, raw: raw || null, supported: meetsMinimum(version), ok: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The one line that tells a user why an older Herdr is a problem here. */
|
|
202
|
+
function herdrOutdatedMessage(version) {
|
|
203
|
+
return `Herdr ${version} is older than ${MIN_HERDR_VERSION}, which herdr-remote is written against. `
|
|
204
|
+
+ 'Run "herdr update" — window titles, background machine activation and large pastes all '
|
|
205
|
+
+ 'misbehave in browser windows before that release.';
|
|
206
|
+
}
|
|
207
|
+
|
|
141
208
|
/** The one message a user needs to fix a missing install themselves. */
|
|
142
209
|
function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home) } = {}) {
|
|
143
210
|
const override = String(env.HERDR_BIN_PATH || '').trim();
|
|
@@ -151,9 +218,14 @@ function herdrNotFoundMessage({ env = process.env, home = os.homedir(), director
|
|
|
151
218
|
module.exports = {
|
|
152
219
|
COMMAND_NAME,
|
|
153
220
|
FALLBACK_DIRECTORIES,
|
|
221
|
+
MIN_HERDR_VERSION,
|
|
154
222
|
fallbackDirectories,
|
|
155
223
|
findHerdrCommand,
|
|
156
224
|
herdrNotFoundMessage,
|
|
225
|
+
herdrOutdatedMessage,
|
|
226
|
+
herdrVersion,
|
|
227
|
+
meetsMinimum,
|
|
228
|
+
parseHerdrVersion,
|
|
157
229
|
resolveHerdrCommand,
|
|
158
230
|
verifyHerdrCommand,
|
|
159
231
|
};
|
package/src/herdr-plugin.js
CHANGED
|
@@ -10,7 +10,7 @@ const fs = require('node:fs');
|
|
|
10
10
|
const path = require('node:path');
|
|
11
11
|
const { spawnSync } = require('node:child_process');
|
|
12
12
|
const { PACKAGE_ROOT } = require('./config');
|
|
13
|
-
const { resolveHerdrCommand } = require('./herdr-command');
|
|
13
|
+
const { herdrVersion, resolveHerdrCommand } = require('./herdr-command');
|
|
14
14
|
|
|
15
15
|
const PLUGIN_ID = 'herdr.remote.web';
|
|
16
16
|
const MANIFEST_NAME = 'herdr-plugin.toml';
|
|
@@ -23,8 +23,7 @@ function manifestPath() {
|
|
|
23
23
|
// manager, neither of which is guaranteed the PATH the user installed with, so
|
|
24
24
|
// the command is resolved rather than named.
|
|
25
25
|
function herdrAvailable() {
|
|
26
|
-
|
|
27
|
-
return result.status === 0 || result.status === 1;
|
|
26
|
+
return herdrVersion().ok;
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
function runHerdr(args, { timeout = 15_000 } = {}) {
|
|
@@ -68,6 +67,10 @@ function registrationStatus() {
|
|
|
68
67
|
if (!fs.existsSync(manifestPath())) {
|
|
69
68
|
return { available: false, registered: false, reason: 'manifest missing' };
|
|
70
69
|
}
|
|
70
|
+
// Carried on every answer: the screen that shows registration is also the one
|
|
71
|
+
// that has to say the installed Herdr is too old for this plugin.
|
|
72
|
+
const { version, supported } = herdrVersion();
|
|
73
|
+
const installed = { version, versionSupported: supported };
|
|
71
74
|
let result;
|
|
72
75
|
try {
|
|
73
76
|
result = runHerdr(['plugin', 'list']);
|
|
@@ -76,11 +79,11 @@ function registrationStatus() {
|
|
|
76
79
|
throw error;
|
|
77
80
|
}
|
|
78
81
|
if (result.status !== 0) {
|
|
79
|
-
return { available: true, registered: false, reason: String(result.stderr || '').trim() };
|
|
82
|
+
return { available: true, registered: false, reason: String(result.stderr || '').trim(), ...installed };
|
|
80
83
|
}
|
|
81
84
|
const plugins = parsePluginList(result.stdout);
|
|
82
85
|
const entry = plugins.find((plugin) => plugin.id === PLUGIN_ID);
|
|
83
|
-
if (!entry) return { available: true, registered: false, packageRoot: PACKAGE_ROOT };
|
|
86
|
+
if (!entry) return { available: true, registered: false, packageRoot: PACKAGE_ROOT, ...installed };
|
|
84
87
|
return {
|
|
85
88
|
available: true,
|
|
86
89
|
registered: true,
|
|
@@ -90,6 +93,7 @@ function registrationStatus() {
|
|
|
90
93
|
// switching from a source install to npm.
|
|
91
94
|
stale: Boolean(entry.localPath) && path.resolve(entry.localPath) !== path.resolve(PACKAGE_ROOT),
|
|
92
95
|
packageRoot: PACKAGE_ROOT,
|
|
96
|
+
...installed,
|
|
93
97
|
};
|
|
94
98
|
}
|
|
95
99
|
|
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,14 @@ const {
|
|
|
20
26
|
PROTOCOL_VERSION,
|
|
21
27
|
} = require('herdr-remote-relay/protocol');
|
|
22
28
|
const { resolveHostPalette } = require('./terminal-palette');
|
|
29
|
+
const { requestHerdr } = 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
|
+
|
|
26
37
|
function randomId(prefix) {
|
|
27
38
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
28
39
|
}
|
|
@@ -36,6 +47,19 @@ function closeSocket(ws, reason = 'host connector stopping') {
|
|
|
36
47
|
try { ws.close(1000, reason); } catch {}
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
function terminateSocket(ws) {
|
|
51
|
+
if (!ws || ws.readyState === WebSocket.CLOSED) return;
|
|
52
|
+
try {
|
|
53
|
+
if (typeof ws.terminate === 'function') {
|
|
54
|
+
ws.terminate();
|
|
55
|
+
} else if (typeof ws.destroy === 'function') {
|
|
56
|
+
ws.destroy();
|
|
57
|
+
} else if (typeof ws.close === 'function') {
|
|
58
|
+
ws.close();
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
function pidAlive(pid) {
|
|
40
64
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
41
65
|
try {
|
|
@@ -69,6 +93,8 @@ class HostConnector {
|
|
|
69
93
|
// real environment unless a caller narrows it.
|
|
70
94
|
this.herdrLookup = options.herdrLookup || {};
|
|
71
95
|
this.herdrCommand = options.herdrCommand || resolveHerdrCommand(this.herdrLookup);
|
|
96
|
+
this.readHerdrVersion = options.readHerdrVersion || herdrVersion;
|
|
97
|
+
this.herdrVersionWarned = false;
|
|
72
98
|
this.herdrArgs = options.herdrArgs || config.herdr.args;
|
|
73
99
|
this.cwd = options.cwd || config.herdr.cwd;
|
|
74
100
|
/**
|
|
@@ -85,9 +111,15 @@ class HostConnector {
|
|
|
85
111
|
this.sessions = new Map();
|
|
86
112
|
this.streamIndexToId = new Map();
|
|
87
113
|
this.PtySession = options.PtySession || PtySession;
|
|
114
|
+
/** The side channel that answers "does anything need me?"; see startAgentStatus. */
|
|
115
|
+
this.requestHerdr = options.requestHerdr || requestHerdr;
|
|
116
|
+
this.agentStatusTimer = null;
|
|
117
|
+
this.agentStatusPending = false;
|
|
118
|
+
this.lastAgentSummary = null;
|
|
88
119
|
this.fastFailures = 0;
|
|
89
120
|
this.reconnectTimer = null;
|
|
90
121
|
this.heartbeatTimer = null;
|
|
122
|
+
this.keepaliveTimer = null;
|
|
91
123
|
this.reconnectAttempts = 0;
|
|
92
124
|
this.clientCount = 0;
|
|
93
125
|
this.legacyHeartbeat = false;
|
|
@@ -154,6 +186,8 @@ class HostConnector {
|
|
|
154
186
|
this.stopping = true;
|
|
155
187
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
156
188
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
189
|
+
this.stopKeepalive();
|
|
190
|
+
this.stopAgentStatus();
|
|
157
191
|
this.reconnectTimer = null;
|
|
158
192
|
this.heartbeatTimer = null;
|
|
159
193
|
sendJson(this.ws, { type: 'host_shutdown' });
|
|
@@ -168,6 +202,7 @@ class HostConnector {
|
|
|
168
202
|
|
|
169
203
|
connect() {
|
|
170
204
|
if (this.stopping || (this.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.ws.readyState))) return;
|
|
205
|
+
this.stopKeepalive();
|
|
171
206
|
let ws;
|
|
172
207
|
try {
|
|
173
208
|
ws = new WebSocket(this.relayUrl);
|
|
@@ -208,6 +243,10 @@ class HostConnector {
|
|
|
208
243
|
this.ws = null;
|
|
209
244
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
210
245
|
this.heartbeatTimer = null;
|
|
246
|
+
this.stopKeepalive();
|
|
247
|
+
// Nothing to report to, so stop asking Herdr. A reconnect brings the
|
|
248
|
+
// watcher back with the relay's next client count.
|
|
249
|
+
this.stopAgentStatus();
|
|
211
250
|
this.ready = false;
|
|
212
251
|
this.clientCount = 0;
|
|
213
252
|
this.legacyHeartbeat = false;
|
|
@@ -276,6 +315,7 @@ class HostConnector {
|
|
|
276
315
|
if (message.type === 'host_ready') {
|
|
277
316
|
this.ready = true;
|
|
278
317
|
this.reconnectAttempts = 0;
|
|
318
|
+
this.startKeepalive();
|
|
279
319
|
if (Object.hasOwn(message, 'clientCount')) {
|
|
280
320
|
this.legacyHeartbeat = false;
|
|
281
321
|
this.setClientCount(message.clientCount);
|
|
@@ -316,11 +356,32 @@ class HostConnector {
|
|
|
316
356
|
const resolved = verifyHerdrCommand(this.herdrCommand, this.herdrLookup);
|
|
317
357
|
if (resolved.found) {
|
|
318
358
|
this.herdrCommand = resolved.command;
|
|
359
|
+
this.warnIfHerdrOutdated();
|
|
319
360
|
return null;
|
|
320
361
|
}
|
|
321
362
|
return herdrNotFoundMessage(this.herdrLookup);
|
|
322
363
|
}
|
|
323
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Say once that the installed Herdr is older than this release expects.
|
|
367
|
+
*
|
|
368
|
+
* Never a refusal to start. An older Herdr still runs — it just misbehaves in
|
|
369
|
+
* ways a browser window notices — and the comment on `startSession` explains
|
|
370
|
+
* what a refused start does to a reconnecting browser.
|
|
371
|
+
*/
|
|
372
|
+
warnIfHerdrOutdated() {
|
|
373
|
+
if (this.herdrVersionWarned) return;
|
|
374
|
+
const installed = this.readHerdrVersion({ command: this.herdrCommand });
|
|
375
|
+
if (installed.supported) {
|
|
376
|
+
// A version we could not read is checked again on the next session: the
|
|
377
|
+
// install may simply not have been finished writing.
|
|
378
|
+
if (installed.version) this.herdrVersionWarned = true;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
this.herdrVersionWarned = true;
|
|
382
|
+
process.stderr.write(`herdr-remote host connector: ${herdrOutdatedMessage(installed.version)}\n`);
|
|
383
|
+
}
|
|
384
|
+
|
|
324
385
|
startSession(message) {
|
|
325
386
|
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
326
387
|
if (!streamId) return;
|
|
@@ -460,6 +521,49 @@ class HostConnector {
|
|
|
460
521
|
this.sendHeartbeat();
|
|
461
522
|
}
|
|
462
523
|
|
|
524
|
+
startKeepalive() {
|
|
525
|
+
this.stopKeepalive();
|
|
526
|
+
if (!this.ws || this.stopping) return;
|
|
527
|
+
this.ws.isAlive = true;
|
|
528
|
+
const interval = this.config?.cleanup?.heartbeatIntervalMs || 30_000;
|
|
529
|
+
this.keepaliveTimer = setInterval(() => this.tickKeepalive(), interval);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
stopKeepalive() {
|
|
533
|
+
if (this.keepaliveTimer) {
|
|
534
|
+
clearInterval(this.keepaliveTimer);
|
|
535
|
+
this.keepaliveTimer = null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
tickKeepalive() {
|
|
540
|
+
const ws = this.ws;
|
|
541
|
+
if (!ws || this.stopping) return;
|
|
542
|
+
if (ws.readyState === WebSocket.CLOSING) {
|
|
543
|
+
terminateSocket(ws);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
547
|
+
|
|
548
|
+
if (!ws.isAlive) {
|
|
549
|
+
terminateSocket(ws);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
ws.isAlive = false;
|
|
554
|
+
try {
|
|
555
|
+
if (typeof ws.ping === 'function') ws.ping();
|
|
556
|
+
} catch {}
|
|
557
|
+
|
|
558
|
+
if (this.clientCount <= 0 && !this.legacyHeartbeat) {
|
|
559
|
+
sendJson(ws, {
|
|
560
|
+
type: 'heartbeat',
|
|
561
|
+
load: {},
|
|
562
|
+
ptys: [],
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
463
567
|
setClientCount(value) {
|
|
464
568
|
const next = Number.isInteger(value) ? Math.max(0, value) : 0;
|
|
465
569
|
if (next === this.clientCount && (next === 0 || this.heartbeatTimer)) return;
|
|
@@ -474,6 +578,63 @@ class HostConnector {
|
|
|
474
578
|
this.heartbeatTimer = null;
|
|
475
579
|
if (wasActive) this.sendHeartbeat(true);
|
|
476
580
|
}
|
|
581
|
+
// Nobody watching, nothing to ask Herdr about itself.
|
|
582
|
+
if (next > 0) this.startAgentStatus();
|
|
583
|
+
else this.stopAgentStatus();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Watch what the agents on this workstation are doing.
|
|
588
|
+
*
|
|
589
|
+
* A side channel, on its own socket, alive only while a browser is attached.
|
|
590
|
+
* Herdr's server can be restarted underneath a running herdr-remote — the
|
|
591
|
+
* PTYs survive it — so every failure here is a retry, never anything a
|
|
592
|
+
* terminal session notices.
|
|
593
|
+
*
|
|
594
|
+
* Polled, and one connection per poll: Herdr answers a socket request and
|
|
595
|
+
* hangs up, and its only long-lived connection — a subscription — carries no
|
|
596
|
+
* event that tracks agent state without naming a pane. See `herdr-api.js`.
|
|
597
|
+
*/
|
|
598
|
+
startAgentStatus() {
|
|
599
|
+
if (this.agentStatusTimer || this.stopping) return;
|
|
600
|
+
this.agentStatusTimer = setInterval(() => this.readAgentStatus(), AGENT_STATUS_POLL_MS);
|
|
601
|
+
if (typeof this.agentStatusTimer.unref === 'function') this.agentStatusTimer.unref();
|
|
602
|
+
this.readAgentStatus();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
stopAgentStatus() {
|
|
606
|
+
if (this.agentStatusTimer) {
|
|
607
|
+
clearInterval(this.agentStatusTimer);
|
|
608
|
+
this.agentStatusTimer = null;
|
|
609
|
+
}
|
|
610
|
+
this.lastAgentSummary = null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Read the summary and forward it if it changed.
|
|
615
|
+
*
|
|
616
|
+
* A slow answer must not stack up behind the next tick, and a failed one
|
|
617
|
+
* leaves the last summary standing rather than blanking the browser's chip:
|
|
618
|
+
* Herdr's server not running is an ordinary state, not news.
|
|
619
|
+
*/
|
|
620
|
+
async readAgentStatus() {
|
|
621
|
+
if (this.agentStatusPending || !this.agentStatusTimer) return;
|
|
622
|
+
this.agentStatusPending = true;
|
|
623
|
+
let snapshot;
|
|
624
|
+
try {
|
|
625
|
+
const result = await this.requestHerdr(this.socketPath, 'session.snapshot', {});
|
|
626
|
+
snapshot = result?.snapshot || result;
|
|
627
|
+
} catch {
|
|
628
|
+
return;
|
|
629
|
+
} finally {
|
|
630
|
+
this.agentStatusPending = false;
|
|
631
|
+
}
|
|
632
|
+
// Stopped while the answer was in flight; the browser it was for is gone.
|
|
633
|
+
if (!this.agentStatusTimer) return;
|
|
634
|
+
const summary = summarizeAgents(snapshot);
|
|
635
|
+
if (sameSummary(this.lastAgentSummary, summary)) return;
|
|
636
|
+
this.lastAgentSummary = summary;
|
|
637
|
+
sendJson(this.ws, { type: 'agent_status', ...summary });
|
|
477
638
|
}
|
|
478
639
|
|
|
479
640
|
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': '界面语言',
|