@agentworkspaceos/hermes 0.1.0 → 0.1.2
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/README.md +29 -7
- package/bin/agentos-hermes.js +488 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,20 +10,37 @@ Run this command from the machine where Hermes is installed:
|
|
|
10
10
|
npx --yes @agentworkspaceos/hermes@latest connect \
|
|
11
11
|
--mode plugin \
|
|
12
12
|
--pair AGOS-XXXX-XXXX \
|
|
13
|
-
--agentos-url
|
|
14
|
-
--hermes-url http://127.0.0.1:8642 \
|
|
15
|
-
--dashboard-url http://127.0.0.1:9119
|
|
13
|
+
--agentos-url http://127.0.0.1:3002
|
|
16
14
|
```
|
|
17
15
|
|
|
18
|
-
The connector sends safe runtime metadata and heartbeat status to AgentOS. Secrets stay on the local machine.
|
|
16
|
+
The connector sends safe runtime metadata and heartbeat status to AgentOS. Secrets stay on the local machine. If the dashboard is opened through a browser-only or Cloudflare-protected URL, keep `--agentos-url` pointed at the direct AgentOS API base URL.
|
|
17
|
+
|
|
18
|
+
When `--hermes-url` is omitted, the CLI probes common local Hermes gateway ports (`8642` through `8645`) and falls back to `http://127.0.0.1:8642`. Use `--hermes-url` when Hermes is running on an unusual local port or a reachable remote gateway. `--dashboard-url` is optional and can be omitted when the Hermes dashboard is not running.
|
|
19
|
+
|
|
20
|
+
## Background Service
|
|
21
|
+
|
|
22
|
+
On macOS, install the connector as a LaunchAgent so it keeps running after the terminal closes and restarts if it exits:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx --yes @agentworkspaceos/hermes@latest service install \
|
|
26
|
+
--mode plugin \
|
|
27
|
+
--pair AGOS-XXXX-XXXX \
|
|
28
|
+
--agentos-url https://agentos-local.rewardsbunny.com
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Logs are written to `~/.agentos/hermes/logs/connector.log`. Remove the service with:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx --yes @agentworkspaceos/hermes@latest service uninstall
|
|
35
|
+
```
|
|
19
36
|
|
|
20
37
|
## Options
|
|
21
38
|
|
|
22
39
|
```txt
|
|
23
40
|
agentos-hermes connect --pair <code> [options]
|
|
24
41
|
|
|
25
|
-
--agentos-url <url> AgentOS
|
|
26
|
-
--hermes-url <url> Local Hermes gateway URL
|
|
42
|
+
--agentos-url <url> AgentOS API base URL
|
|
43
|
+
--hermes-url <url> Local Hermes gateway URL; omitted means auto-detect local Hermes
|
|
27
44
|
--dashboard-url <url> Optional Hermes dashboard URL
|
|
28
45
|
--mode <mode> plugin, sidecar, or direct-url
|
|
29
46
|
--config-dir <path> Local config directory
|
|
@@ -33,6 +50,11 @@ agentos-hermes connect --pair <code> [options]
|
|
|
33
50
|
--once Send one heartbeat and exit
|
|
34
51
|
```
|
|
35
52
|
|
|
53
|
+
```txt
|
|
54
|
+
agentos-hermes service install --pair <code> --agentos-url <url> [options]
|
|
55
|
+
agentos-hermes service uninstall [options]
|
|
56
|
+
```
|
|
57
|
+
|
|
36
58
|
## Publish
|
|
37
59
|
|
|
38
60
|
The package is configured for public scoped publishing:
|
|
@@ -42,4 +64,4 @@ npm login
|
|
|
42
64
|
npm publish --access public
|
|
43
65
|
```
|
|
44
66
|
|
|
45
|
-
You must own or have publish access to the `@
|
|
67
|
+
You must own or have publish access to the `@agentworkspaceos` npm scope before publishing.
|
package/bin/agentos-hermes.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir, hostname } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
const VERSION = "0.1.
|
|
7
|
+
const VERSION = "0.1.2";
|
|
8
8
|
const DEFAULT_AGENTOS_URL = "http://localhost:3000";
|
|
9
9
|
const DEFAULT_HERMES_URL = "http://127.0.0.1:8642";
|
|
10
|
+
const DEFAULT_HERMES_DISCOVERY_PORTS = [8642, 8643, 8644, 8645];
|
|
10
11
|
const DEFAULT_MODE = "plugin";
|
|
11
12
|
const DEFAULT_HERMES_COMMAND = "hermes";
|
|
13
|
+
const DEFAULT_SERVICE_LABEL = "com.agentos.hermes";
|
|
12
14
|
const HERMES_COMMAND_TIMEOUT_MS = 2500;
|
|
15
|
+
const HERMES_HEALTH_TIMEOUT_MS = 1000;
|
|
16
|
+
const HERMES_DISCOVERY_TIMEOUT_MS = 350;
|
|
13
17
|
const PARENT_AGENT_NAME = "Hermes Main Orchestrator";
|
|
14
18
|
|
|
15
19
|
async function main(argv) {
|
|
@@ -25,6 +29,11 @@ async function main(argv) {
|
|
|
25
29
|
return;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
if (command === "service") {
|
|
33
|
+
await service(args);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
if (command !== "connect") {
|
|
29
38
|
throw new Error(`Unknown command "${command}". Run agentos-hermes --help.`);
|
|
30
39
|
}
|
|
@@ -35,21 +44,16 @@ async function main(argv) {
|
|
|
35
44
|
async function connect(args) {
|
|
36
45
|
const options = parseConnectArgs(args);
|
|
37
46
|
const agentosUrl = normalizeBaseUrl(options.agentosUrl ?? process.env.AGENTOS_URL ?? DEFAULT_AGENTOS_URL);
|
|
38
|
-
const hermesUrl = normalizeBaseUrl(options.hermesUrl ?? process.env.HERMES_BASE_URL ?? DEFAULT_HERMES_URL);
|
|
39
47
|
const dashboardUrl = options.dashboardUrl ? normalizeBaseUrl(options.dashboardUrl) : undefined;
|
|
40
48
|
const mode = options.mode ?? DEFAULT_MODE;
|
|
41
49
|
const pairingCode = options.pair;
|
|
42
50
|
const hermesCommand = options.hermesCommand ?? process.env.HERMES_CLI_COMMAND ?? DEFAULT_HERMES_COMMAND;
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
throw new Error("Missing --pair <code>.");
|
|
46
|
-
}
|
|
52
|
+
validateConnectInput({ mode, pairingCode });
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const hermesHealth = await detectHermes(hermesUrl);
|
|
54
|
+
const hermesConnection = await resolveHermesConnection(options);
|
|
55
|
+
const hermesUrl = hermesConnection.hermesUrl;
|
|
56
|
+
const hermesHealth = hermesConnection.hermesHealth;
|
|
53
57
|
const metadata = options.skipInventory
|
|
54
58
|
? createFallbackMetadata(hermesHealth)
|
|
55
59
|
: await collectHermesInventory({ hermesCommand, hermesHealth });
|
|
@@ -83,6 +87,7 @@ async function connect(args) {
|
|
|
83
87
|
console.log(`Pairing code: ${session.pairingCode}`);
|
|
84
88
|
console.log(`Status: ${session.status}`);
|
|
85
89
|
console.log(`Mode: ${mode}`);
|
|
90
|
+
console.log(`Hermes URL: ${hermesUrl}${hermesConnection.autodetected ? " (auto-detected)" : ""}`);
|
|
86
91
|
console.log(`Hermes detected: ${hermesHealth.detected ? "yes" : "no"}`);
|
|
87
92
|
console.log(
|
|
88
93
|
`Inventory: ${metadata.skills?.length ?? 0} skills, ${metadata.toolsets?.length ?? 0} enabled toolsets, ${
|
|
@@ -106,6 +111,87 @@ async function connect(args) {
|
|
|
106
111
|
await runHeartbeatLoop(sendHeartbeat, intervalMs);
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
async function service(args) {
|
|
115
|
+
const [subcommand, ...subcommandArgs] = args;
|
|
116
|
+
|
|
117
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
118
|
+
printServiceHelp();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (subcommand === "install") {
|
|
123
|
+
await installService(subcommandArgs);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (subcommand === "uninstall") {
|
|
128
|
+
await uninstallService(subcommandArgs);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new Error(`Unknown service command "${subcommand}". Run agentos-hermes service --help.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function installService(args) {
|
|
136
|
+
const { connectOptions, serviceOptions } = parseServiceInstallArgs(args);
|
|
137
|
+
const mode = connectOptions.mode ?? DEFAULT_MODE;
|
|
138
|
+
const pairingCode = connectOptions.pair;
|
|
139
|
+
|
|
140
|
+
validateConnectInput({ mode, pairingCode });
|
|
141
|
+
|
|
142
|
+
const serviceConfig = await createServiceConfig(connectOptions, serviceOptions);
|
|
143
|
+
|
|
144
|
+
if (process.platform !== "darwin" && !serviceOptions.noLoad) {
|
|
145
|
+
throw new Error("agentos-hermes service install currently supports macOS launchd. Use --no-load to only write the LaunchAgent plist.");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await mkdir(dirname(serviceConfig.plistPath), { recursive: true });
|
|
149
|
+
await mkdir(dirname(serviceConfig.stdoutPath), { recursive: true });
|
|
150
|
+
await writeFile(serviceConfig.plistPath, createLaunchAgentPlist(serviceConfig), { mode: 0o644 });
|
|
151
|
+
|
|
152
|
+
if (!serviceOptions.noLoad) {
|
|
153
|
+
await bootoutLaunchAgent(serviceConfig);
|
|
154
|
+
await bootstrapLaunchAgent(serviceConfig);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log("AgentOS Hermes background service installed");
|
|
158
|
+
console.log(`Label: ${serviceConfig.label}`);
|
|
159
|
+
console.log(`Plist: ${serviceConfig.plistPath}`);
|
|
160
|
+
console.log(`Logs: ${serviceConfig.stdoutPath}`);
|
|
161
|
+
|
|
162
|
+
if (serviceOptions.noLoad) {
|
|
163
|
+
console.log(`Load it with: launchctl bootstrap gui/$(id -u) ${serviceConfig.plistPath}`);
|
|
164
|
+
} else {
|
|
165
|
+
console.log("Status: loaded");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function uninstallService(args) {
|
|
170
|
+
const serviceOptions = parseServiceUninstallArgs(args);
|
|
171
|
+
const label = serviceOptions.label ?? DEFAULT_SERVICE_LABEL;
|
|
172
|
+
const launchAgentDir = serviceOptions.launchAgentDir ?? join(homedir(), "Library", "LaunchAgents");
|
|
173
|
+
const plistPath = join(launchAgentDir, `${label}.plist`);
|
|
174
|
+
const serviceConfig = { label, plistPath };
|
|
175
|
+
|
|
176
|
+
if (process.platform !== "darwin" && !serviceOptions.noLoad) {
|
|
177
|
+
throw new Error("agentos-hermes service uninstall currently supports macOS launchd. Use --no-load to only remove the LaunchAgent plist.");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!serviceOptions.noLoad) {
|
|
181
|
+
await bootoutLaunchAgent(serviceConfig);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await unlink(plistPath).catch((error) => {
|
|
185
|
+
if (error?.code !== "ENOENT") {
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
console.log("AgentOS Hermes background service uninstalled");
|
|
191
|
+
console.log(`Label: ${label}`);
|
|
192
|
+
console.log(`Plist: ${plistPath}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
109
195
|
function parseConnectArgs(args) {
|
|
110
196
|
const options = {};
|
|
111
197
|
|
|
@@ -152,6 +238,96 @@ function parseConnectArgs(args) {
|
|
|
152
238
|
return options;
|
|
153
239
|
}
|
|
154
240
|
|
|
241
|
+
function parseServiceInstallArgs(args) {
|
|
242
|
+
const connectOptions = {};
|
|
243
|
+
const serviceOptions = {};
|
|
244
|
+
|
|
245
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
246
|
+
const arg = args[index];
|
|
247
|
+
const next = args[index + 1];
|
|
248
|
+
|
|
249
|
+
if (arg === "--label") {
|
|
250
|
+
serviceOptions.label = requireValue(arg, next);
|
|
251
|
+
index += 1;
|
|
252
|
+
} else if (arg === "--launch-agent-dir") {
|
|
253
|
+
serviceOptions.launchAgentDir = requireValue(arg, next);
|
|
254
|
+
index += 1;
|
|
255
|
+
} else if (arg === "--npx-path") {
|
|
256
|
+
serviceOptions.npxPath = requireValue(arg, next);
|
|
257
|
+
index += 1;
|
|
258
|
+
} else if (arg === "--no-load") {
|
|
259
|
+
serviceOptions.noLoad = true;
|
|
260
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
261
|
+
printServiceInstallHelp();
|
|
262
|
+
process.exit(0);
|
|
263
|
+
} else if (isConnectOptionWithValue(arg)) {
|
|
264
|
+
connectOptions[connectOptionKey(arg)] = requireValue(arg, next);
|
|
265
|
+
index += 1;
|
|
266
|
+
} else if (arg === "--once") {
|
|
267
|
+
throw new Error("--once cannot be used with service install.");
|
|
268
|
+
} else if (arg === "--skip-inventory") {
|
|
269
|
+
connectOptions.skipInventory = true;
|
|
270
|
+
} else {
|
|
271
|
+
throw new Error(`Unknown option "${arg}".`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { connectOptions, serviceOptions };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function parseServiceUninstallArgs(args) {
|
|
279
|
+
const options = {};
|
|
280
|
+
|
|
281
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
282
|
+
const arg = args[index];
|
|
283
|
+
const next = args[index + 1];
|
|
284
|
+
|
|
285
|
+
if (arg === "--label") {
|
|
286
|
+
options.label = requireValue(arg, next);
|
|
287
|
+
index += 1;
|
|
288
|
+
} else if (arg === "--launch-agent-dir") {
|
|
289
|
+
options.launchAgentDir = requireValue(arg, next);
|
|
290
|
+
index += 1;
|
|
291
|
+
} else if (arg === "--no-load") {
|
|
292
|
+
options.noLoad = true;
|
|
293
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
294
|
+
printServiceUninstallHelp();
|
|
295
|
+
process.exit(0);
|
|
296
|
+
} else {
|
|
297
|
+
throw new Error(`Unknown option "${arg}".`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return options;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isConnectOptionWithValue(arg) {
|
|
305
|
+
return ["--pair", "--agentos-url", "--hermes-url", "--dashboard-url", "--mode", "--config-dir", "--hermes-command", "--interval-ms"].includes(arg);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function connectOptionKey(arg) {
|
|
309
|
+
return {
|
|
310
|
+
"--agentos-url": "agentosUrl",
|
|
311
|
+
"--config-dir": "configDir",
|
|
312
|
+
"--dashboard-url": "dashboardUrl",
|
|
313
|
+
"--hermes-command": "hermesCommand",
|
|
314
|
+
"--hermes-url": "hermesUrl",
|
|
315
|
+
"--interval-ms": "intervalMs",
|
|
316
|
+
"--mode": "mode",
|
|
317
|
+
"--pair": "pair",
|
|
318
|
+
}[arg];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateConnectInput({ mode, pairingCode }) {
|
|
322
|
+
if (!pairingCode) {
|
|
323
|
+
throw new Error("Missing --pair <code>.");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!["plugin", "sidecar", "direct-url"].includes(mode)) {
|
|
327
|
+
throw new Error('--mode must be "plugin", "sidecar", or "direct-url".');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
155
331
|
function requireValue(name, value) {
|
|
156
332
|
if (!value || value.startsWith("--")) {
|
|
157
333
|
throw new Error(`Missing value for ${name}.`);
|
|
@@ -160,13 +336,229 @@ function requireValue(name, value) {
|
|
|
160
336
|
return value;
|
|
161
337
|
}
|
|
162
338
|
|
|
163
|
-
async function
|
|
339
|
+
async function createServiceConfig(connectOptions, serviceOptions) {
|
|
340
|
+
const label = serviceOptions.label ?? DEFAULT_SERVICE_LABEL;
|
|
341
|
+
const launchAgentDir = serviceOptions.launchAgentDir ?? join(homedir(), "Library", "LaunchAgents");
|
|
342
|
+
const logDir = join(homedir(), ".agentos", "hermes", "logs");
|
|
343
|
+
const npxPath = serviceOptions.npxPath ?? (await findNpxExecutable());
|
|
344
|
+
const programArguments = [
|
|
345
|
+
npxPath,
|
|
346
|
+
"--yes",
|
|
347
|
+
"@agentworkspaceos/hermes@latest",
|
|
348
|
+
...buildServiceConnectArgs(connectOptions),
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
label,
|
|
353
|
+
pathEnvironment: process.env.PATH ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
354
|
+
plistPath: join(launchAgentDir, `${label}.plist`),
|
|
355
|
+
programArguments,
|
|
356
|
+
stderrPath: join(logDir, "connector.err.log"),
|
|
357
|
+
stdoutPath: join(logDir, "connector.log"),
|
|
358
|
+
workingDirectory: homedir(),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildServiceConnectArgs(options) {
|
|
363
|
+
const args = [
|
|
364
|
+
"connect",
|
|
365
|
+
"--mode",
|
|
366
|
+
options.mode ?? DEFAULT_MODE,
|
|
367
|
+
"--pair",
|
|
368
|
+
options.pair,
|
|
369
|
+
"--agentos-url",
|
|
370
|
+
normalizeBaseUrl(options.agentosUrl ?? process.env.AGENTOS_URL ?? DEFAULT_AGENTOS_URL),
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
if (options.hermesUrl) {
|
|
374
|
+
args.push("--hermes-url", normalizeBaseUrl(options.hermesUrl));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (options.dashboardUrl) {
|
|
378
|
+
args.push("--dashboard-url", normalizeBaseUrl(options.dashboardUrl));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (options.configDir) {
|
|
382
|
+
args.push("--config-dir", options.configDir);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (options.hermesCommand) {
|
|
386
|
+
args.push("--hermes-command", options.hermesCommand);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (options.intervalMs) {
|
|
390
|
+
args.push("--interval-ms", String(Number(options.intervalMs)));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (options.skipInventory) {
|
|
394
|
+
args.push("--skip-inventory");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return args;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function findNpxExecutable() {
|
|
401
|
+
const candidates = [
|
|
402
|
+
...splitPathEntries(process.env.PATH).map((entry) => join(entry, "npx")),
|
|
403
|
+
"/opt/homebrew/bin/npx",
|
|
404
|
+
"/usr/local/bin/npx",
|
|
405
|
+
"/usr/bin/npx",
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
409
|
+
try {
|
|
410
|
+
await access(candidate);
|
|
411
|
+
return candidate;
|
|
412
|
+
} catch {
|
|
413
|
+
// Keep looking.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return "npx";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function splitPathEntries(value) {
|
|
421
|
+
return value
|
|
422
|
+
? value
|
|
423
|
+
.split(":")
|
|
424
|
+
.map((entry) => entry.trim())
|
|
425
|
+
.filter(Boolean)
|
|
426
|
+
: [];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function createLaunchAgentPlist(config) {
|
|
430
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
431
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
432
|
+
<plist version="1.0">
|
|
433
|
+
<dict>
|
|
434
|
+
<key>Label</key>
|
|
435
|
+
<string>${escapePlistValue(config.label)}</string>
|
|
436
|
+
<key>ProgramArguments</key>
|
|
437
|
+
<array>
|
|
438
|
+
${config.programArguments.map((argument) => ` <string>${escapePlistValue(argument)}</string>`).join("\n")}
|
|
439
|
+
</array>
|
|
440
|
+
<key>RunAtLoad</key>
|
|
441
|
+
<true/>
|
|
442
|
+
<key>KeepAlive</key>
|
|
443
|
+
<true/>
|
|
444
|
+
<key>WorkingDirectory</key>
|
|
445
|
+
<string>${escapePlistValue(config.workingDirectory)}</string>
|
|
446
|
+
<key>StandardOutPath</key>
|
|
447
|
+
<string>${escapePlistValue(config.stdoutPath)}</string>
|
|
448
|
+
<key>StandardErrorPath</key>
|
|
449
|
+
<string>${escapePlistValue(config.stderrPath)}</string>
|
|
450
|
+
<key>EnvironmentVariables</key>
|
|
451
|
+
<dict>
|
|
452
|
+
<key>PATH</key>
|
|
453
|
+
<string>${escapePlistValue(config.pathEnvironment)}</string>
|
|
454
|
+
</dict>
|
|
455
|
+
</dict>
|
|
456
|
+
</plist>
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function escapePlistValue(value) {
|
|
461
|
+
return String(value)
|
|
462
|
+
.replaceAll("&", "&")
|
|
463
|
+
.replaceAll("<", "<")
|
|
464
|
+
.replaceAll(">", ">")
|
|
465
|
+
.replaceAll("\"", """)
|
|
466
|
+
.replaceAll("'", "'");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function bootstrapLaunchAgent(config) {
|
|
470
|
+
const target = launchctlTarget();
|
|
471
|
+
const result = await runCommand("launchctl", ["bootstrap", target, config.plistPath], { timeoutMs: 10000 });
|
|
472
|
+
|
|
473
|
+
if (!result.ok) {
|
|
474
|
+
throw new Error(`Could not load launchd service: ${result.stderr || result.stdout || "launchctl bootstrap failed"}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function bootoutLaunchAgent(config) {
|
|
479
|
+
const target = launchctlTarget();
|
|
480
|
+
await runCommand("launchctl", ["bootout", target, config.plistPath], { timeoutMs: 10000 });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function launchctlTarget() {
|
|
484
|
+
const uid = process.getuid?.();
|
|
485
|
+
|
|
486
|
+
if (typeof uid !== "number") {
|
|
487
|
+
throw new Error("Could not determine the current user id for launchctl.");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return `gui/${uid}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function resolveHermesConnection(options) {
|
|
494
|
+
const configuredHermesUrl = options.hermesUrl ?? process.env.HERMES_BASE_URL;
|
|
495
|
+
|
|
496
|
+
if (configuredHermesUrl) {
|
|
497
|
+
const hermesUrl = normalizeBaseUrl(configuredHermesUrl);
|
|
498
|
+
return {
|
|
499
|
+
autodetected: false,
|
|
500
|
+
hermesHealth: await detectHermes(hermesUrl),
|
|
501
|
+
hermesUrl,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const candidates = createHermesDiscoveryUrls();
|
|
506
|
+
const checks = await Promise.all(
|
|
507
|
+
candidates.map(async (hermesUrl) => ({
|
|
508
|
+
hermesHealth: await detectHermes(hermesUrl, { timeoutMs: HERMES_DISCOVERY_TIMEOUT_MS }),
|
|
509
|
+
hermesUrl,
|
|
510
|
+
})),
|
|
511
|
+
);
|
|
512
|
+
const detected = checks.find((check) => check.hermesHealth.detected);
|
|
513
|
+
|
|
514
|
+
if (detected) {
|
|
515
|
+
return {
|
|
516
|
+
autodetected: true,
|
|
517
|
+
...detected,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const fallbackUrl = normalizeBaseUrl(DEFAULT_HERMES_URL);
|
|
522
|
+
const fallback = checks.find((check) => check.hermesUrl === fallbackUrl);
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
autodetected: false,
|
|
526
|
+
hermesHealth: fallback?.hermesHealth ?? (await detectHermes(fallbackUrl)),
|
|
527
|
+
hermesUrl: fallbackUrl,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function createHermesDiscoveryUrls() {
|
|
532
|
+
const configuredUrls = splitEnvList(process.env.HERMES_DISCOVERY_URLS).map((url) => normalizeBaseUrl(url));
|
|
533
|
+
const configuredPorts = splitEnvList(process.env.HERMES_DISCOVERY_PORTS)
|
|
534
|
+
.map((port) => Number(port))
|
|
535
|
+
.filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
|
|
536
|
+
const ports = configuredPorts.length ? configuredPorts : DEFAULT_HERMES_DISCOVERY_PORTS;
|
|
537
|
+
const localUrls = ports.flatMap((port) => [`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
538
|
+
|
|
539
|
+
return [...new Set([...configuredUrls, ...localUrls].map((url) => normalizeBaseUrl(url)))];
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function splitEnvList(value) {
|
|
543
|
+
return value
|
|
544
|
+
? value
|
|
545
|
+
.split(",")
|
|
546
|
+
.map((item) => item.trim())
|
|
547
|
+
.filter(Boolean)
|
|
548
|
+
: [];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function detectHermes(hermesUrl, options = {}) {
|
|
552
|
+
const controller = new AbortController();
|
|
553
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? HERMES_HEALTH_TIMEOUT_MS);
|
|
554
|
+
|
|
164
555
|
try {
|
|
165
556
|
const response = await fetch(`${hermesUrl}/health`, {
|
|
166
557
|
headers: {
|
|
167
558
|
accept: "application/json",
|
|
168
559
|
},
|
|
169
560
|
method: "GET",
|
|
561
|
+
signal: controller.signal,
|
|
170
562
|
});
|
|
171
563
|
const text = await response.text();
|
|
172
564
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -182,6 +574,8 @@ async function detectHermes(hermesUrl) {
|
|
|
182
574
|
platform: null,
|
|
183
575
|
status: "unavailable",
|
|
184
576
|
};
|
|
577
|
+
} finally {
|
|
578
|
+
clearTimeout(timeout);
|
|
185
579
|
}
|
|
186
580
|
}
|
|
187
581
|
|
|
@@ -1001,8 +1395,7 @@ async function postHeartbeat(agentosUrl, pairingCode, payload) {
|
|
|
1001
1395
|
method: "POST",
|
|
1002
1396
|
},
|
|
1003
1397
|
);
|
|
1004
|
-
const
|
|
1005
|
-
const body = text ? JSON.parse(text) : {};
|
|
1398
|
+
const body = await parseJsonResponse(response, "AgentOS heartbeat endpoint");
|
|
1006
1399
|
|
|
1007
1400
|
if (!response.ok) {
|
|
1008
1401
|
const message = typeof body.error === "string" ? body.error : `AgentOS returned ${response.status}.`;
|
|
@@ -1012,6 +1405,39 @@ async function postHeartbeat(agentosUrl, pairingCode, payload) {
|
|
|
1012
1405
|
return body;
|
|
1013
1406
|
}
|
|
1014
1407
|
|
|
1408
|
+
async function parseJsonResponse(response, context) {
|
|
1409
|
+
const text = await response.text();
|
|
1410
|
+
|
|
1411
|
+
if (!text) {
|
|
1412
|
+
return {};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
return JSON.parse(text);
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
const contentType = response.headers.get("content-type") ?? "unknown content type";
|
|
1419
|
+
const status = [response.status, response.statusText].filter(Boolean).join(" ");
|
|
1420
|
+
|
|
1421
|
+
if (isHtmlResponse(text, contentType)) {
|
|
1422
|
+
const isCloudflareChallenge = response.headers.get("cf-mitigated") === "challenge" || text.includes("challenge-platform");
|
|
1423
|
+
const hint = isCloudflareChallenge
|
|
1424
|
+
? " Cloudflare is challenging the CLI request; use a direct local API URL for --agentos-url, such as http://127.0.0.1:3002, or exempt /api/hermes/* from browser challenges."
|
|
1425
|
+
: " Check that --agentos-url points to the AgentOS API server, not a browser-only app shell or proxy fallback. For local AgentOS, use http://127.0.0.1:3002.";
|
|
1426
|
+
|
|
1427
|
+
throw new Error(context + " returned HTML instead of JSON (" + (status || contentType) + ")." + hint);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1431
|
+
throw new Error(context + " returned invalid JSON (" + (status || contentType) + "): " + message);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function isHtmlResponse(text, contentType) {
|
|
1436
|
+
const trimmed = text.trimStart().toLowerCase();
|
|
1437
|
+
|
|
1438
|
+
return contentType.toLowerCase().includes("text/html") || trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1015
1441
|
async function runHeartbeatLoop(sendHeartbeat, intervalMs) {
|
|
1016
1442
|
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
|
1017
1443
|
throw new Error("--interval-ms must be at least 1000.");
|
|
@@ -1161,6 +1587,7 @@ Usage:
|
|
|
1161
1587
|
|
|
1162
1588
|
Commands:
|
|
1163
1589
|
connect Pair the local Hermes agent with AgentOS
|
|
1590
|
+
service Install or remove a background connector service
|
|
1164
1591
|
`);
|
|
1165
1592
|
}
|
|
1166
1593
|
|
|
@@ -1169,9 +1596,9 @@ function printConnectHelp() {
|
|
|
1169
1596
|
agentos-hermes connect --pair <code> [options]
|
|
1170
1597
|
|
|
1171
1598
|
Options:
|
|
1172
|
-
--agentos-url <url> AgentOS
|
|
1173
|
-
--hermes-url <url> Local Hermes gateway URL. Defaults to ${DEFAULT_HERMES_URL}
|
|
1174
|
-
--dashboard-url <url> Optional Hermes dashboard URL
|
|
1599
|
+
--agentos-url <url> AgentOS API base URL. Defaults to ${DEFAULT_AGENTOS_URL}
|
|
1600
|
+
--hermes-url <url> Local Hermes gateway URL. Defaults to auto-detection, then ${DEFAULT_HERMES_URL}
|
|
1601
|
+
--dashboard-url <url> Optional Hermes dashboard URL. Omit it when the dashboard is not running
|
|
1175
1602
|
--mode <mode> plugin, sidecar, or direct-url. Defaults to ${DEFAULT_MODE}
|
|
1176
1603
|
--config-dir <path> Local config directory. Defaults to ~/.agentos/hermes
|
|
1177
1604
|
--hermes-command <cmd> Hermes CLI executable. Defaults to ${DEFAULT_HERMES_COMMAND}
|
|
@@ -1181,6 +1608,50 @@ Options:
|
|
|
1181
1608
|
`);
|
|
1182
1609
|
}
|
|
1183
1610
|
|
|
1611
|
+
function printServiceHelp() {
|
|
1612
|
+
console.log(`Usage:
|
|
1613
|
+
agentos-hermes service install --pair <code> --agentos-url <url> [options]
|
|
1614
|
+
agentos-hermes service uninstall [options]
|
|
1615
|
+
|
|
1616
|
+
Commands:
|
|
1617
|
+
install Install a macOS launchd service that keeps the connector running
|
|
1618
|
+
uninstall Remove the macOS launchd service
|
|
1619
|
+
`);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function printServiceInstallHelp() {
|
|
1623
|
+
console.log(`Usage:
|
|
1624
|
+
agentos-hermes service install --pair <code> --agentos-url <url> [options]
|
|
1625
|
+
|
|
1626
|
+
Connector options:
|
|
1627
|
+
--agentos-url <url> AgentOS API base URL. Defaults to ${DEFAULT_AGENTOS_URL}
|
|
1628
|
+
--hermes-url <url> Local Hermes gateway URL. Omitted means auto-detect
|
|
1629
|
+
--dashboard-url <url> Optional Hermes dashboard URL
|
|
1630
|
+
--mode <mode> plugin, sidecar, or direct-url. Defaults to ${DEFAULT_MODE}
|
|
1631
|
+
--config-dir <path> Local config directory. Defaults to ~/.agentos/hermes
|
|
1632
|
+
--hermes-command <cmd> Hermes CLI executable. Defaults to ${DEFAULT_HERMES_COMMAND}
|
|
1633
|
+
--interval-ms <ms> Heartbeat interval. Defaults to 15000
|
|
1634
|
+
--skip-inventory Send basic heartbeat metadata only
|
|
1635
|
+
|
|
1636
|
+
Service options:
|
|
1637
|
+
--label <label> LaunchAgent label. Defaults to ${DEFAULT_SERVICE_LABEL}
|
|
1638
|
+
--npx-path <path> Absolute path to npx for launchd
|
|
1639
|
+
--launch-agent-dir <path> Directory for the plist. Defaults to ~/Library/LaunchAgents
|
|
1640
|
+
--no-load Write the plist but do not load it
|
|
1641
|
+
`);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function printServiceUninstallHelp() {
|
|
1645
|
+
console.log(`Usage:
|
|
1646
|
+
agentos-hermes service uninstall [options]
|
|
1647
|
+
|
|
1648
|
+
Options:
|
|
1649
|
+
--label <label> LaunchAgent label. Defaults to ${DEFAULT_SERVICE_LABEL}
|
|
1650
|
+
--launch-agent-dir <path> Directory containing the plist. Defaults to ~/Library/LaunchAgents
|
|
1651
|
+
--no-load Remove the plist but do not call launchctl
|
|
1652
|
+
`);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1184
1655
|
main(process.argv.slice(2)).catch((error) => {
|
|
1185
1656
|
console.error(error instanceof Error ? error.message : error);
|
|
1186
1657
|
process.exitCode = 1;
|