@agentworkspaceos/hermes 0.1.1 → 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 CHANGED
@@ -17,6 +17,23 @@ The connector sends safe runtime metadata and heartbeat status to AgentOS. Secre
17
17
 
18
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
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
+ ```
36
+
20
37
  ## Options
21
38
 
22
39
  ```txt
@@ -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 `@agentos` npm scope before publishing.
67
+ You must own or have publish access to the `@agentworkspaceos` npm scope before publishing.
@@ -1,15 +1,16 @@
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.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
10
  const DEFAULT_HERMES_DISCOVERY_PORTS = [8642, 8643, 8644, 8645];
11
11
  const DEFAULT_MODE = "plugin";
12
12
  const DEFAULT_HERMES_COMMAND = "hermes";
13
+ const DEFAULT_SERVICE_LABEL = "com.agentos.hermes";
13
14
  const HERMES_COMMAND_TIMEOUT_MS = 2500;
14
15
  const HERMES_HEALTH_TIMEOUT_MS = 1000;
15
16
  const HERMES_DISCOVERY_TIMEOUT_MS = 350;
@@ -28,6 +29,11 @@ async function main(argv) {
28
29
  return;
29
30
  }
30
31
 
32
+ if (command === "service") {
33
+ await service(args);
34
+ return;
35
+ }
36
+
31
37
  if (command !== "connect") {
32
38
  throw new Error(`Unknown command "${command}". Run agentos-hermes --help.`);
33
39
  }
@@ -43,13 +49,7 @@ async function connect(args) {
43
49
  const pairingCode = options.pair;
44
50
  const hermesCommand = options.hermesCommand ?? process.env.HERMES_CLI_COMMAND ?? DEFAULT_HERMES_COMMAND;
45
51
 
46
- if (!pairingCode) {
47
- throw new Error("Missing --pair <code>.");
48
- }
49
-
50
- if (!["plugin", "sidecar", "direct-url"].includes(mode)) {
51
- throw new Error('--mode must be "plugin", "sidecar", or "direct-url".');
52
- }
52
+ validateConnectInput({ mode, pairingCode });
53
53
 
54
54
  const hermesConnection = await resolveHermesConnection(options);
55
55
  const hermesUrl = hermesConnection.hermesUrl;
@@ -111,6 +111,87 @@ async function connect(args) {
111
111
  await runHeartbeatLoop(sendHeartbeat, intervalMs);
112
112
  }
113
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
+
114
195
  function parseConnectArgs(args) {
115
196
  const options = {};
116
197
 
@@ -157,6 +238,96 @@ function parseConnectArgs(args) {
157
238
  return options;
158
239
  }
159
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
+
160
331
  function requireValue(name, value) {
161
332
  if (!value || value.startsWith("--")) {
162
333
  throw new Error(`Missing value for ${name}.`);
@@ -165,6 +336,160 @@ function requireValue(name, value) {
165
336
  return value;
166
337
  }
167
338
 
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("&", "&amp;")
463
+ .replaceAll("<", "&lt;")
464
+ .replaceAll(">", "&gt;")
465
+ .replaceAll("\"", "&quot;")
466
+ .replaceAll("'", "&apos;");
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
+
168
493
  async function resolveHermesConnection(options) {
169
494
  const configuredHermesUrl = options.hermesUrl ?? process.env.HERMES_BASE_URL;
170
495
 
@@ -1262,6 +1587,7 @@ Usage:
1262
1587
 
1263
1588
  Commands:
1264
1589
  connect Pair the local Hermes agent with AgentOS
1590
+ service Install or remove a background connector service
1265
1591
  `);
1266
1592
  }
1267
1593
 
@@ -1282,6 +1608,50 @@ Options:
1282
1608
  `);
1283
1609
  }
1284
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
+
1285
1655
  main(process.argv.slice(2)).catch((error) => {
1286
1656
  console.error(error instanceof Error ? error.message : error);
1287
1657
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentworkspaceos/hermes",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "AgentOS connector CLI for pairing a local Hermes runtime with an AgentOS workspace.",
5
5
  "type": "module",
6
6
  "bin": {