herdr-remote 0.2.17 → 0.2.19

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 (92) hide show
  1. package/bin/herdr-remote.js +3 -221
  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 +10 -9
  70. package/dist/tui.mjs +0 -4613
  71. package/src/agent-status.js +0 -139
  72. package/src/config.js +0 -454
  73. package/src/herdr-api.js +0 -244
  74. package/src/herdr-command.js +0 -231
  75. package/src/herdr-plugin.js +0 -130
  76. package/src/herdr-server.js +0 -201
  77. package/src/host-connector.js +0 -927
  78. package/src/i18n/en.js +0 -240
  79. package/src/i18n/index.js +0 -56
  80. package/src/i18n/zh.js +0 -239
  81. package/src/keepalive.js +0 -460
  82. package/src/lifecycle.js +0 -62
  83. package/src/net-interfaces.js +0 -76
  84. package/src/pasted-files.js +0 -176
  85. package/src/pty-session.js +0 -92
  86. package/src/service.js +0 -516
  87. package/src/settings-model.js +0 -323
  88. package/src/socket-discovery.js +0 -60
  89. package/src/state.js +0 -47
  90. package/src/supervisor.js +0 -250
  91. package/src/terminal-palette.js +0 -325
  92. package/src/updater.js +0 -394
package/dist/cli.js ADDED
@@ -0,0 +1,255 @@
1
+ // herdr-remote command line: argument parsing and the subcommands.
2
+ //
3
+ // With no arguments (and a real terminal) this opens the configuration TUI;
4
+ // the subcommands exist for scripting and for the actions declared in
5
+ // herdr-plugin.toml, which Herdr runs without a terminal attached.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { LANGUAGES, loadConfig, migrateLegacyConfig } from './config.js';
9
+ import { PACKAGE_ROOT, configPath, stateDir } from './paths.js';
10
+ import { extractPairingCode } from './relay-client.js';
11
+ import { resolvePublicUrl } from './relay-urls.js';
12
+ import { createTranslator, detectLocale } from './i18n/index.js';
13
+ import { preferredLanAddress } from './net-interfaces.js';
14
+ const VERSION = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
15
+ const USAGE = `herdr-remote ${VERSION} — browser access to Herdr workspaces
16
+
17
+ Usage: herdr-remote [command] [options]
18
+
19
+ Commands:
20
+ (none) Open the configuration TUI
21
+ start Start relay and host connector
22
+ stop Stop services
23
+ restart Restart services
24
+ status [--json] Show service status
25
+ pair [--json] Create pairing code
26
+ url Print browser access URL
27
+ run [--daemon] Run services in foreground (keep-alive)
28
+ keepalive <action> install | uninstall | restart | status
29
+ plugin <action> link | unlink | status (Herdr plugin)
30
+
31
+ Options:
32
+ --lang <zh|en> Interface language
33
+ --json JSON output
34
+ -h, --help Show help
35
+ -v, --version Show version
36
+
37
+ Self-hosting: docs/self-hosted-relay.md
38
+ `;
39
+ function parseArgs(argv) {
40
+ const positional = [];
41
+ const flags = {};
42
+ for (let index = 0; index < argv.length; index += 1) {
43
+ const arg = argv[index];
44
+ if (arg === '--json') {
45
+ flags.json = true;
46
+ continue;
47
+ }
48
+ if (arg === '--daemon') {
49
+ flags.daemon = true;
50
+ continue;
51
+ }
52
+ if (arg === '--help' || arg === '-h') {
53
+ flags.help = true;
54
+ continue;
55
+ }
56
+ if (arg === '--version' || arg === '-v') {
57
+ flags.version = true;
58
+ continue;
59
+ }
60
+ if (arg === '--lang') {
61
+ flags.lang = argv[index + 1];
62
+ index += 1;
63
+ continue;
64
+ }
65
+ if (arg.startsWith('--lang=')) {
66
+ flags.lang = arg.slice('--lang='.length);
67
+ continue;
68
+ }
69
+ if (arg.startsWith('-')) {
70
+ flags.unknown = arg;
71
+ continue;
72
+ }
73
+ positional.push(arg);
74
+ }
75
+ return { positional, flags };
76
+ }
77
+ function printJson(value) {
78
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
79
+ }
80
+ function describeStatus(status, t) {
81
+ const lines = [];
82
+ lines.push(`${t('overview.mode')}: ${t(`mode.${status.mode}`)}`);
83
+ if (status.relay.local) {
84
+ lines.push(`${t('overview.relay')}: ${status.relay.alive ? t('common.running') : t('common.stopped')} (${status.relay.bind}:${status.relay.port})`);
85
+ }
86
+ else {
87
+ lines.push(`${t('overview.relay')}: ${status.relay.remoteUrl}`);
88
+ }
89
+ lines.push(`${t('overview.host')}: ${status.host.alive ? t('common.running') : t('common.stopped')}`);
90
+ lines.push(`${t('overview.socket')}: ${status.host.socketPath}${status.host.socketExists ? '' : ` (${t('overview.socketMissing')})`}`);
91
+ lines.push(`${t('overview.webUrl')}: ${status.publicUrl}`);
92
+ if (status.keepalive) {
93
+ const state = status.keepalive.active
94
+ ? t('common.running')
95
+ : status.keepalive.installed
96
+ ? t('common.installed')
97
+ : t('common.notInstalled');
98
+ lines.push(`${t('overview.keepalive')}: ${status.keepalive.manager} — ${state}`);
99
+ }
100
+ if (status.relay.health && status.relay.health.ok === false) {
101
+ lines.push(`${t('overview.relay')}: ${t('overview.unreachable', { message: status.relay.health.message ?? '' })}`);
102
+ }
103
+ return lines.join('\n');
104
+ }
105
+ async function runTui(options) {
106
+ const { startTui } = await import('./tui/index.js');
107
+ await startTui(options);
108
+ }
109
+ async function main(argv = process.argv.slice(2)) {
110
+ const { positional, flags } = parseArgs(argv);
111
+ const command = positional[0] || null;
112
+ // Answered before anything reads the config or queries the terminal.
113
+ if (flags.help) {
114
+ process.stdout.write(USAGE);
115
+ return;
116
+ }
117
+ if (flags.version) {
118
+ process.stdout.write(`${VERSION}\n`);
119
+ return;
120
+ }
121
+ if (flags.unknown) {
122
+ process.stderr.write(`herdr-remote: unknown option "${flags.unknown}"\n\n${USAGE}`);
123
+ process.exitCode = 2;
124
+ return;
125
+ }
126
+ migrateLegacyConfig();
127
+ // Ask the terminal for its colors while it is still a plain terminal: the
128
+ // TUI takes the screen a few lines below, and the services this command
129
+ // starts usually run detached with nothing to ask. Everything downstream
130
+ // inherits the answer through the environment.
131
+ (await import('./terminal-palette.js')).captureTerminalPalette();
132
+ // The font, likewise, is read while this process still knows which terminal
133
+ // it runs in: its variables and its parent process are gone once a service
134
+ // manager or Herdr's detached server starts the connector.
135
+ (await import('./terminal-font/index.js')).captureTerminalFont();
136
+ const config = loadConfig();
137
+ const preference = flags.lang && LANGUAGES.includes(flags.lang)
138
+ ? flags.lang
139
+ : config.ui.language;
140
+ const t = createTranslator(detectLocale({ preference }));
141
+ // Loaded lazily so that the TUI does not pay for loading the
142
+ // service layer (which pulls in the relay package and node-pty).
143
+ const lifecycle = await import('./lifecycle.js');
144
+ const service = await import('./service.js');
145
+ switch (command) {
146
+ case null: {
147
+ if (process.stdin.isTTY && process.stdout.isTTY) {
148
+ await runTui({ language: flags.lang || null });
149
+ return;
150
+ }
151
+ // Herdr runs plugin panes and actions without a TTY; emit status instead
152
+ // of trying to paint an interface into a pipe.
153
+ printJson(await lifecycle.fullStatus(config));
154
+ return;
155
+ }
156
+ case 'start': {
157
+ const result = lifecycle.startAll(config);
158
+ if (flags.json)
159
+ printJson(result);
160
+ else
161
+ process.stdout.write(`${t('services.started')}\n${describeStatus(await lifecycle.fullStatus(config), t)}\n`);
162
+ return;
163
+ }
164
+ case 'stop': {
165
+ const result = lifecycle.stopAll(config);
166
+ if (flags.json)
167
+ printJson(result);
168
+ else
169
+ process.stdout.write(`${t('services.stopped')}\n`);
170
+ return;
171
+ }
172
+ case 'restart': {
173
+ const result = lifecycle.restartAll(config);
174
+ if (flags.json)
175
+ printJson(result);
176
+ else
177
+ process.stdout.write(`${t('services.restarted')}\n`);
178
+ return;
179
+ }
180
+ case 'status': {
181
+ const status = await lifecycle.fullStatus(config);
182
+ if (flags.json)
183
+ printJson(status);
184
+ else
185
+ process.stdout.write(`${describeStatus(status, t)}\n`);
186
+ return;
187
+ }
188
+ case 'pair': {
189
+ const pairing = await service.pair();
190
+ if (flags.json) {
191
+ printJson(pairing);
192
+ return;
193
+ }
194
+ const code = extractPairingCode(pairing);
195
+ const url = pairing.pairUrl ||
196
+ `${resolvePublicUrl(config, preferredLanAddress())}/?pairCode=${encodeURIComponent(code)}`;
197
+ const minutes = `${Math.round((config.auth.pairingTtlMs || 600000) / 60000)}m`;
198
+ process.stdout.write(`${t('pair.code')}: ${code}\n${t('pair.url')}: ${url}\n${t('pair.expires', { minutes, time: new Date(pairing.expiresAt).toLocaleTimeString() })}\n`);
199
+ return;
200
+ }
201
+ case 'url': {
202
+ process.stdout.write(`${resolvePublicUrl(config, preferredLanAddress())}\n`);
203
+ return;
204
+ }
205
+ case 'run': {
206
+ const { runForeground } = await import('./supervisor.js');
207
+ await runForeground({ logToFiles: Boolean(flags.daemon) });
208
+ return;
209
+ }
210
+ case 'keepalive': {
211
+ const keepalive = await import('./keepalive/index.js');
212
+ const action = positional[1] || 'status';
213
+ if (action === 'install')
214
+ printJson(keepalive.install(config));
215
+ else if (action === 'uninstall')
216
+ printJson(keepalive.uninstall(config));
217
+ else if (action === 'restart')
218
+ printJson(keepalive.restart(config));
219
+ else if (action === 'status')
220
+ printJson(keepalive.status(config));
221
+ else
222
+ throw new Error(`unknown keepalive action: ${action}`);
223
+ return;
224
+ }
225
+ case 'plugin': {
226
+ const plugin = await import('./herdr-plugin.js');
227
+ const action = positional[1] || 'status';
228
+ if (action === 'link')
229
+ printJson(plugin.register());
230
+ else if (action === 'unlink')
231
+ printJson(plugin.unregister());
232
+ else if (action === 'status')
233
+ printJson(plugin.registrationStatus());
234
+ else
235
+ throw new Error(`unknown plugin action: ${action}`);
236
+ return;
237
+ }
238
+ case 'config': {
239
+ // Handy when reporting a problem: where does this install read from?
240
+ printJson({ configPath: configPath(), stateDir: stateDir(), config });
241
+ return;
242
+ }
243
+ default:
244
+ process.stderr.write(`herdr-remote: unknown command "${command}"\n\n${USAGE}`);
245
+ process.exitCode = 2;
246
+ }
247
+ }
248
+ /** Runs the command line and reports a failure as the exit code. */
249
+ function run() {
250
+ main().catch((error) => {
251
+ process.stderr.write(`herdr-remote: ${error.message}\n`);
252
+ process.exitCode = 1;
253
+ });
254
+ }
255
+ export { run, parseArgs, describeStatus };
package/dist/config.js ADDED
@@ -0,0 +1,270 @@
1
+ // The configuration file: its shape, its defaults, and how a file on disk,
2
+ // older schemas and the environment become one validated Config.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { DEFAULTS as RELAY_DEFAULTS } from 'herdr-remote-relay/config';
7
+ import { configPath, legacyConfigPath } from './paths.js';
8
+ import { isLoopbackHost, isUnspecifiedAddress, isUnspecifiedHost, normalizeUrl, } from './relay-urls.js';
9
+ // Access modes
10
+ // local — a relay runs on this machine, reachable only from this machine.
11
+ // lan — a relay runs on this machine, bound to every interface so other
12
+ // devices on the LAN (or on a Tailscale/WireGuard overlay) reach it.
13
+ // remote — no local relay; the host connector dials an operator-run relay,
14
+ // which is the only way in from outside the local network.
15
+ const ACCESS_MODES = ['local', 'lan', 'remote'];
16
+ // The relay this project runs for people who do not want to host one. Offered
17
+ // as a one-keystroke choice during setup; it is never selected automatically,
18
+ // because routing a terminal through someone else's server has to be a
19
+ // deliberate decision. It is a plain public relay: no join password, and each
20
+ // workstation stays reachable only through its own host token.
21
+ const OFFICIAL_RELAY_URL = 'wss://herdr-remote.564616.xyz';
22
+ const LANGUAGES = ['auto', 'zh', 'en'];
23
+ const KEEPALIVE_MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
24
+ function isOneOf(list, value) {
25
+ return list.includes(value);
26
+ }
27
+ const DEFAULTS = {
28
+ ui: {
29
+ language: 'auto',
30
+ },
31
+ relay: {
32
+ mode: 'local',
33
+ port: 8787,
34
+ // Address advertised in pairing URLs when mode is "lan". Empty means "pick
35
+ // the first non-internal IPv4 automatically".
36
+ lanHost: '',
37
+ // Manual override for the URL browsers open. Empty means "derive it".
38
+ publicUrl: '',
39
+ // Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
40
+ remoteUrl: '',
41
+ // The CLI starts a relay of its own, so its limits are the relay
42
+ // package's: a smaller ceiling here would reject an upload the hosted
43
+ // relay accepts.
44
+ maxPayloadBytes: RELAY_DEFAULTS.relay.maxPayloadBytes,
45
+ maxClientsPerHost: RELAY_DEFAULTS.relay.maxClientsPerHost,
46
+ maxHosts: RELAY_DEFAULTS.relay.maxHosts,
47
+ maxPendingHandshakes: RELAY_DEFAULTS.relay.maxPendingHandshakes,
48
+ maxBufferedBytesPerClient: RELAY_DEFAULTS.relay.maxBufferedBytesPerClient,
49
+ hostReconnectGraceMs: RELAY_DEFAULTS.relay.hostReconnectGraceMs,
50
+ allowedOrigins: [],
51
+ },
52
+ herdr: {
53
+ socketPath: null,
54
+ args: [],
55
+ cwd: os.homedir(),
56
+ // Start Herdr's server when herdr-remote starts (at boot, under keep-alive).
57
+ // Off: Herdr starts when the user runs it, or confirms it from the WebUI.
58
+ autoStart: false,
59
+ },
60
+ auth: {
61
+ pairingTtlMs: RELAY_DEFAULTS.auth.pairingTtlMs,
62
+ deviceTtlMs: RELAY_DEFAULTS.auth.deviceTtlMs,
63
+ maxDevices: RELAY_DEFAULTS.auth.maxDevices,
64
+ },
65
+ cleanup: { ...RELAY_DEFAULTS.cleanup },
66
+ keepalive: {
67
+ manager: 'auto',
68
+ },
69
+ };
70
+ function clone(value) {
71
+ return JSON.parse(JSON.stringify(value));
72
+ }
73
+ function readJson(filePath) {
74
+ try {
75
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
76
+ }
77
+ catch (error) {
78
+ const { code, message } = error;
79
+ if (code !== 'ENOENT') {
80
+ process.stderr.write(`herdr-remote: ignoring invalid JSON at ${filePath}: ${message}\n`);
81
+ }
82
+ return {};
83
+ }
84
+ }
85
+ /**
86
+ * Copy a config written by an older, plugin-scoped install into the canonical
87
+ * location. Runs at most once: it never overwrites an existing config.
88
+ */
89
+ function migrateLegacyConfig() {
90
+ const target = configPath();
91
+ if (fs.existsSync(target))
92
+ return { migrated: false, reason: 'config already exists' };
93
+ const legacy = legacyConfigPath();
94
+ if (!legacy || !fs.existsSync(legacy))
95
+ return { migrated: false, reason: 'no legacy config' };
96
+ try {
97
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
98
+ fs.copyFileSync(legacy, target);
99
+ return { migrated: true, from: legacy, to: target };
100
+ }
101
+ catch (error) {
102
+ return { migrated: false, reason: error.message };
103
+ }
104
+ }
105
+ function mergeConfig(fileConfig) {
106
+ const config = clone(DEFAULTS);
107
+ const file = (fileConfig ?? {});
108
+ for (const section of Object.keys(config)) {
109
+ const values = file[section];
110
+ if (values && typeof values === 'object')
111
+ Object.assign(config[section], values);
112
+ }
113
+ return config;
114
+ }
115
+ /**
116
+ * Accept configs written before access modes existed. The 0.1 schema used
117
+ * `relay.local` (boolean), `relay.host` (bind address) and `relay.url`.
118
+ */
119
+ function normalizeLegacyFields(config, fileConfig) {
120
+ const legacy = fileConfig?.relay || {};
121
+ // The absence of an explicit `mode` — not an invalid one — is what marks a
122
+ // file as pre-0.2, since merging defaults always leaves a valid mode behind.
123
+ const hasExplicitMode = isOneOf(ACCESS_MODES, legacy.mode);
124
+ if (!hasExplicitMode) {
125
+ if (legacy.local === false && typeof legacy.url === 'string' && legacy.url) {
126
+ config.relay.mode = 'remote';
127
+ if (!config.relay.remoteUrl)
128
+ config.relay.remoteUrl = legacy.url;
129
+ }
130
+ else if (typeof legacy.host === 'string' && legacy.host && !isLoopbackHost(legacy.host)) {
131
+ config.relay.mode = 'lan';
132
+ }
133
+ else {
134
+ config.relay.mode = 'local';
135
+ }
136
+ }
137
+ if (config.relay.mode === 'lan' &&
138
+ !config.relay.lanHost &&
139
+ typeof legacy.host === 'string' &&
140
+ !isLoopbackHost(legacy.host) &&
141
+ legacy.host !== '0.0.0.0') {
142
+ config.relay.lanHost = legacy.host;
143
+ }
144
+ // Superseded keys copied in by the merge would otherwise reappear in the
145
+ // in-memory config and confuse anything reading it.
146
+ delete config.relay.local;
147
+ delete config.relay.host;
148
+ delete config.relay.url;
149
+ // `publicUrl` used to be mandatory and defaulted to the loopback URL; treat
150
+ // that default as "no override" so derivation can take over.
151
+ if (typeof config.relay.publicUrl === 'string' &&
152
+ /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/?$/.test(config.relay.publicUrl)) {
153
+ config.relay.publicUrl = '';
154
+ }
155
+ }
156
+ function parseInteger(value, fallback, min, max) {
157
+ const numeric = Number(value);
158
+ if (!Number.isInteger(numeric) || numeric < min || numeric > max)
159
+ return fallback;
160
+ return numeric;
161
+ }
162
+ function validate(config) {
163
+ if (!isOneOf(ACCESS_MODES, config.relay.mode))
164
+ config.relay.mode = DEFAULTS.relay.mode;
165
+ if (!isOneOf(LANGUAGES, config.ui.language))
166
+ config.ui.language = DEFAULTS.ui.language;
167
+ if (!isOneOf(KEEPALIVE_MANAGERS, config.keepalive.manager))
168
+ config.keepalive.manager = DEFAULTS.keepalive.manager;
169
+ config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
170
+ config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
171
+ config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
172
+ config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
173
+ config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
174
+ config.relay.maxBufferedBytesPerClient = parseInteger(config.relay.maxBufferedBytesPerClient, DEFAULTS.relay.maxBufferedBytesPerClient, 64 * 1024, 256 * 1024 * 1024);
175
+ config.relay.hostReconnectGraceMs = parseInteger(config.relay.hostReconnectGraceMs, DEFAULTS.relay.hostReconnectGraceMs, 1000, 24 * 60 * 60 * 1000);
176
+ config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
177
+ config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
178
+ config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
179
+ config.cleanup.intervalMs = parseInteger(config.cleanup.intervalMs, DEFAULTS.cleanup.intervalMs, 1000, 24 * 60 * 60 * 1000);
180
+ config.cleanup.heartbeatIntervalMs = parseInteger(config.cleanup.heartbeatIntervalMs, DEFAULTS.cleanup.heartbeatIntervalMs, 1000, 10 * 60 * 1000);
181
+ config.cleanup.staleAfterMs = parseInteger(config.cleanup.staleAfterMs, DEFAULTS.cleanup.staleAfterMs, config.cleanup.heartbeatIntervalMs * 2, 24 * 60 * 60 * 1000);
182
+ config.relay.publicUrl = normalizeUrl(config.relay.publicUrl);
183
+ config.relay.remoteUrl = normalizeUrl(config.relay.remoteUrl);
184
+ config.relay.lanHost =
185
+ typeof config.relay.lanHost === 'string' ? config.relay.lanHost.trim() : '';
186
+ // A LAN relay listens on every interface, but a loopback or wildcard value
187
+ // cannot be opened by another device. Clear stale values from older TUI
188
+ // versions so the advertised URL falls back to a real interface address.
189
+ if (config.relay.mode === 'lan' &&
190
+ (isLoopbackHost(config.relay.lanHost) || isUnspecifiedAddress(config.relay.lanHost))) {
191
+ config.relay.lanHost = '';
192
+ }
193
+ // 0.0.0.0 is a server-side bind wildcard, never a destination a browser can
194
+ // open. Treat it like an empty override so LAN mode derives the real address.
195
+ if (isUnspecifiedHost(config.relay.publicUrl))
196
+ config.relay.publicUrl = '';
197
+ if (!Array.isArray(config.relay.allowedOrigins))
198
+ config.relay.allowedOrigins = [];
199
+ if (!Array.isArray(config.herdr.args) ||
200
+ !config.herdr.args.every((arg) => typeof arg === 'string')) {
201
+ config.herdr.args = [];
202
+ }
203
+ const args = config.herdr.args;
204
+ // Herdr 0.9.0 removed --no-session; retaining it in saved configs causes
205
+ // session creation to fail. Filter it out if present.
206
+ if (args.includes('--no-session')) {
207
+ config.herdr.args = args.filter((arg) => arg !== '--no-session');
208
+ process.stderr.write('herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n');
209
+ }
210
+ if (typeof config.herdr.socketPath !== 'string' || config.herdr.socketPath.length === 0) {
211
+ config.herdr.socketPath = null;
212
+ }
213
+ if (typeof config.herdr.cwd !== 'string' || config.herdr.cwd.length === 0) {
214
+ config.herdr.cwd = os.homedir();
215
+ }
216
+ config.herdr.autoStart = config.herdr.autoStart === true;
217
+ // A "remote" config without a relay URL cannot reach anything; fall back to
218
+ // local rather than silently starting a host connector that dials nowhere.
219
+ if (config.relay.mode === 'remote' && !config.relay.remoteUrl) {
220
+ config.relay.mode = 'local';
221
+ }
222
+ // Every field `Config` promises has been normalised above.
223
+ return config;
224
+ }
225
+ function applyEnvironment(config) {
226
+ const env = process.env;
227
+ if (env.HERDR_REMOTE_LANG && isOneOf(LANGUAGES, env.HERDR_REMOTE_LANG))
228
+ config.ui.language = env.HERDR_REMOTE_LANG;
229
+ if (env.HERDR_REMOTE_MODE && isOneOf(ACCESS_MODES, env.HERDR_REMOTE_MODE))
230
+ config.relay.mode = env.HERDR_REMOTE_MODE;
231
+ if (env.RELAY_PORT)
232
+ config.relay.port = env.RELAY_PORT;
233
+ if (env.RELAY_PUBLIC_URL)
234
+ config.relay.publicUrl = env.RELAY_PUBLIC_URL;
235
+ if (env.RELAY_REMOTE_URL)
236
+ config.relay.remoteUrl = env.RELAY_REMOTE_URL;
237
+ if (env.RELAY_MAX_HOSTS)
238
+ config.relay.maxHosts = env.RELAY_MAX_HOSTS;
239
+ if (env.RELAY_MAX_PENDING_HANDSHAKES)
240
+ config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
241
+ if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT)
242
+ config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
243
+ if (env.RELAY_HOST_RECONNECT_GRACE_MS)
244
+ config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
245
+ if (env.HERDR_SOCKET_PATH)
246
+ config.herdr.socketPath = env.HERDR_SOCKET_PATH;
247
+ if (env.HERDR_CWD)
248
+ config.herdr.cwd = env.HERDR_CWD;
249
+ if (env.HERDR_ARGS_JSON) {
250
+ try {
251
+ const args = JSON.parse(env.HERDR_ARGS_JSON);
252
+ if (Array.isArray(args))
253
+ config.herdr.args = args;
254
+ }
255
+ catch (error) {
256
+ process.stderr.write(`herdr-remote: invalid HERDR_ARGS_JSON: ${error.message}\n`);
257
+ }
258
+ }
259
+ }
260
+ function loadConfig() {
261
+ const fileConfig = readJson(configPath());
262
+ const draft = mergeConfig(fileConfig);
263
+ normalizeLegacyFields(draft, fileConfig);
264
+ applyEnvironment(draft);
265
+ return validate(draft);
266
+ }
267
+ function configExists() {
268
+ return fs.existsSync(configPath());
269
+ }
270
+ export { DEFAULTS, ACCESS_MODES, OFFICIAL_RELAY_URL, LANGUAGES, KEEPALIVE_MANAGERS, configExists, migrateLegacyConfig, loadConfig, validate, };
@@ -0,0 +1,110 @@
1
+ // Watching what the agents on this workstation are doing, for the status bar
2
+ // of every window attached to it.
3
+ import { sameSummary, summarizeAgents, } from '../agent-status.js';
4
+ import { requestHerdr, subscribeHerdr, } from '../herdr-api.js';
5
+ /** How often the agent summary is re-read while a browser is watching. */
6
+ const AGENT_STATUS_POLL_MS = 5_000;
7
+ const AGENT_STATUS_EVENT_DEBOUNCE_MS = 150;
8
+ const AGENT_STATUS_SUBSCRIPTIONS = [
9
+ { type: 'pane.focused' },
10
+ { type: 'tab.focused' },
11
+ { type: 'workspace.focused' },
12
+ { type: 'pane.agent_detected' },
13
+ ];
14
+ /**
15
+ * A side channel, on its own socket, alive only while a browser is attached.
16
+ * Herdr's server can be restarted underneath a running herdr-remote — the
17
+ * PTYs survive it — so every failure here is a retry, never anything a
18
+ * terminal session notices.
19
+ *
20
+ * A long-lived subscription provides fast refreshes on pane and workspace
21
+ * focus changes; the five-second poll still repairs missed events and a
22
+ * restarted Herdr server. Both paths only read the side-channel snapshot.
23
+ */
24
+ export class AgentWatch {
25
+ socketPath;
26
+ request;
27
+ subscribe;
28
+ send;
29
+ pollTimer = null;
30
+ debounceTimer = null;
31
+ subscription = null;
32
+ pending = false;
33
+ lastSummary = null;
34
+ constructor(options, send) {
35
+ this.socketPath = options.socketPath;
36
+ this.request = options.requestHerdr || requestHerdr;
37
+ this.subscribe = options.subscribeHerdr || subscribeHerdr;
38
+ this.send = send;
39
+ }
40
+ start() {
41
+ if (this.pollTimer)
42
+ return;
43
+ this.pollTimer = setInterval(() => this.read(), AGENT_STATUS_POLL_MS);
44
+ this.pollTimer.unref?.();
45
+ try {
46
+ this.subscription = this.subscribe(this.socketPath, AGENT_STATUS_SUBSCRIPTIONS, () => this.scheduleRead());
47
+ }
48
+ catch {
49
+ // A missing or restarting Herdr server leaves the regular poll in place.
50
+ }
51
+ this.read();
52
+ }
53
+ stop() {
54
+ if (this.pollTimer) {
55
+ clearInterval(this.pollTimer);
56
+ this.pollTimer = null;
57
+ }
58
+ if (this.debounceTimer) {
59
+ clearTimeout(this.debounceTimer);
60
+ this.debounceTimer = null;
61
+ }
62
+ if (this.subscription) {
63
+ this.subscription.close();
64
+ this.subscription = null;
65
+ }
66
+ this.lastSummary = null;
67
+ }
68
+ scheduleRead() {
69
+ if (!this.pollTimer)
70
+ return;
71
+ if (this.debounceTimer)
72
+ clearTimeout(this.debounceTimer);
73
+ this.debounceTimer = setTimeout(() => {
74
+ this.debounceTimer = null;
75
+ this.read();
76
+ }, AGENT_STATUS_EVENT_DEBOUNCE_MS);
77
+ this.debounceTimer.unref?.();
78
+ }
79
+ /**
80
+ * Read the summary and forward it if it changed.
81
+ *
82
+ * A slow answer must not stack up behind the next tick, and a failed one
83
+ * leaves the last summary standing rather than blanking the browser's chip:
84
+ * Herdr's server not running is an ordinary state, not news.
85
+ */
86
+ async read() {
87
+ if (this.pending || !this.pollTimer)
88
+ return;
89
+ this.pending = true;
90
+ let snapshot;
91
+ try {
92
+ const result = await this.request(this.socketPath, 'session.snapshot', {});
93
+ snapshot = result?.snapshot || result;
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ finally {
99
+ this.pending = false;
100
+ }
101
+ // Stopped while the answer was in flight; the browser it was for is gone.
102
+ if (!this.pollTimer)
103
+ return;
104
+ const summary = summarizeAgents(snapshot);
105
+ if (sameSummary(this.lastSummary, summary))
106
+ return;
107
+ this.lastSummary = summary;
108
+ this.send({ type: 'agent_status', ...summary });
109
+ }
110
+ }