@cotal-ai/connector-hermes 0.3.0 → 0.3.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/bin/install.mjs CHANGED
@@ -1,22 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `npx @cotal-ai/connector-hermes install`
3
+ * `npx @cotal-ai/connector-hermes <install|uninstall>`
4
4
  *
5
- * Guided install of the Cotal plugin into an existing Hermes so its gateway can join a Cotal mesh.
6
- * Copies this package's `plugin/cotal` (incl. the bundled `_sidecar/standalone.cjs`) into
7
- * `<HERMES_HOME>/plugins/cotal`, enables it, optionally writes your mesh config to
8
- * `<HERMES_HOME>/.env`, and checks the mesh is reachable.
5
+ * Wires the Cotal plugin into an existing Hermes so its gateway can join a Cotal mesh, and
6
+ * reverses it cleanly. The plugin (incl. the bundled `_sidecar/standalone.cjs`) lands in
7
+ * `<HERMES_HOME>/plugins/cotal`, gets enabled, and your mesh config is written to
8
+ * `<HERMES_HOME>/.env`.
9
9
  *
10
- * Interactive when run in a TTY; non-interactive with `--yes` (or no TTY), taking config from
11
- * `--link/--space/--name/--server` flags or the matching `COTAL_*` env vars.
10
+ * It figures out WHERE Hermes lives on its own:
11
+ * - `hermes` on PATH → host mode (also covers running this INSIDE the container)
12
+ * - else a running Hermes container → docker mode (copy in + `docker exec … plugins enable`)
13
+ * - else `--target-home <path>` → files-only mode (place files, print the manual steps)
14
+ * Override the guess with `--docker <container>`, `--target-home <path>`, or `--profile <name>`.
12
15
  *
13
- * House rule: no fallbacks. If Hermes isn't installed we fail loudly and write NOTHING.
16
+ * Interactive in a TTY; non-interactive with `--yes` (or no TTY), taking mesh config from
17
+ * `--link/--space/--name/--server` flags or the matching `COTAL_*` env vars. A non-interactive
18
+ * run never mutates an auto-detected container — pass `--docker <name>` to be explicit.
19
+ *
20
+ * House rule: no fallbacks. If we can't find a place to install, we fail loudly and write nothing.
14
21
  */
15
22
  import { execFileSync } from "node:child_process";
16
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
+ import {
24
+ cpSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ mkdtempSync,
28
+ readFileSync,
29
+ rmSync,
30
+ writeFileSync,
31
+ } from "node:fs";
17
32
  import { createConnection } from "node:net";
18
- import { homedir } from "node:os";
19
- import { join, sep } from "node:path";
33
+ import { homedir, tmpdir } from "node:os";
34
+ import { dirname, join, sep } from "node:path";
20
35
  import { createInterface } from "node:readline/promises";
21
36
  import { fileURLToPath } from "node:url";
22
37
 
@@ -25,15 +40,22 @@ const PLUGIN_SRC = join(PKG_ROOT, "plugin", "cotal");
25
40
  const SIDECAR = join(PLUGIN_SRC, "_sidecar", "standalone.cjs");
26
41
 
27
42
  const DEFAULTS = { space: "demo", name: "my-hermes", server: "nats://127.0.0.1:4222" };
43
+ const DOCKER_SERVER = "nats://host.docker.internal:4222";
44
+ const CONTAINER_HOME = "/opt/data"; // HERMES_HOME inside the official image
45
+ const ENV_KEYS = ["COTAL_LINK", "COTAL_SPACE", "COTAL_NAME", "COTAL_SERVERS"];
28
46
 
29
47
  const die = (msg) => {
30
48
  process.stderr.write(`✗ ${msg}\n`);
31
49
  process.exit(1);
32
50
  };
33
51
  const info = (msg) => process.stdout.write(`${msg}\n`);
52
+ const warn = (msg) => process.stdout.write(`! ${msg}\n`);
34
53
 
35
54
  function usage() {
36
- info("usage: npx @cotal-ai/connector-hermes install [--yes] [--link <url> | --space <s> --name <n> --server <url>]");
55
+ info("usage: npx @cotal-ai/connector-hermes <install|uninstall> [options]");
56
+ info(" targeting: --docker <container> | --target-home <path> | --profile <name>");
57
+ info(" mesh: --link <url> | --space <s> --name <n> --server <url>");
58
+ info(" other: --yes (non-interactive) --keep-env (uninstall: leave COTAL_* in .env)");
37
59
  }
38
60
 
39
61
  // ---- arg parsing ------------------------------------------------------------
@@ -50,67 +72,135 @@ function parseFlags(argv) {
50
72
  return f;
51
73
  }
52
74
 
75
+ // ---- small prompts ----------------------------------------------------------
76
+ async function confirm(question, defaultYes) {
77
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
78
+ try {
79
+ const a = (await rl.question(question)).trim().toLowerCase();
80
+ if (!a) return defaultYes;
81
+ return a === "y" || a === "yes";
82
+ } finally {
83
+ rl.close();
84
+ }
85
+ }
86
+
87
+ async function pickContainer(containers) {
88
+ info("Multiple Hermes containers are running:");
89
+ containers.forEach((c, i) => info(` ${i + 1}) ${c.name} (${c.image})`));
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ try {
92
+ const idx = Number((await rl.question(`pick [1-${containers.length}]: `)).trim()) - 1;
93
+ if (!(idx >= 0 && idx < containers.length)) die("invalid selection");
94
+ return containers[idx].name;
95
+ } finally {
96
+ rl.close();
97
+ }
98
+ }
99
+
53
100
  // ---- mesh config gathering --------------------------------------------------
54
- async function promptConfig() {
101
+ async function promptConfig(defaults) {
55
102
  const rl = createInterface({ input: process.stdin, output: process.stdout });
56
103
  try {
57
104
  info("\nCotal mesh — paste a join link (cotal://token@host/space), or press Enter to set it manually:");
58
105
  const link = (await rl.question(" link: ")).trim();
59
106
  if (link) {
60
- const name = (await rl.question(` name [${DEFAULTS.name}]: `)).trim() || DEFAULTS.name;
107
+ const name = (await rl.question(` name [${defaults.name}]: `)).trim() || defaults.name;
61
108
  return { COTAL_LINK: link, COTAL_NAME: name };
62
109
  }
63
- const space = (await rl.question(` space [${DEFAULTS.space}]: `)).trim() || DEFAULTS.space;
64
- const name = (await rl.question(` name [${DEFAULTS.name}]: `)).trim() || DEFAULTS.name;
65
- const server = (await rl.question(` server [${DEFAULTS.server}]: `)).trim() || DEFAULTS.server;
110
+ const space = (await rl.question(` space [${defaults.space}]: `)).trim() || defaults.space;
111
+ const name = (await rl.question(` name [${defaults.name}]: `)).trim() || defaults.name;
112
+ const server = (await rl.question(` server [${defaults.server}]: `)).trim() || defaults.server;
66
113
  return { COTAL_SPACE: space, COTAL_NAME: name, COTAL_SERVERS: server };
67
114
  } finally {
68
115
  rl.close();
69
116
  }
70
117
  }
71
118
 
72
- function configFromFlags(flags) {
119
+ function configFromFlags(flags, defaults) {
73
120
  const link = flags.link || process.env.COTAL_LINK;
74
- if (link) return { COTAL_LINK: link, COTAL_NAME: flags.name || process.env.COTAL_NAME || DEFAULTS.name };
121
+ if (link) return { COTAL_LINK: link, COTAL_NAME: flags.name || process.env.COTAL_NAME || defaults.name };
75
122
  const space = flags.space || process.env.COTAL_SPACE;
76
123
  const server = flags.server || process.env.COTAL_SERVERS;
77
124
  if (!space && !server) return {}; // nothing supplied → skip .env, print manual next steps
78
125
  return {
79
- COTAL_SPACE: space || DEFAULTS.space,
80
- COTAL_NAME: flags.name || process.env.COTAL_NAME || DEFAULTS.name,
81
- COTAL_SERVERS: server || DEFAULTS.server,
126
+ COTAL_SPACE: space || defaults.space,
127
+ COTAL_NAME: flags.name || process.env.COTAL_NAME || defaults.name,
128
+ COTAL_SERVERS: server || defaults.server,
82
129
  };
83
130
  }
84
131
 
85
- // ---- ~/.hermes/.env editing (idempotent) ------------------------------------
86
- function writeEnv(file, set) {
132
+ // On macOS/Windows a container can't reach the host over 127.0.0.1 — rewrite to host.docker.internal.
133
+ // On Linux the right answer depends on the container's network mode, so we only warn.
134
+ function dockerizeServer(meshEnv) {
135
+ const isLoop = (s) => /(^|@|\/\/)(127\.0\.0\.1|localhost)(:|\/|$)/.test(s || "");
136
+ for (const k of ["COTAL_SERVERS", "COTAL_LINK"]) {
137
+ if (!meshEnv[k] || !isLoop(meshEnv[k])) continue;
138
+ if (process.platform === "linux") {
139
+ warn(`${k} points at loopback. From a Linux container, reach the host via \`--network host\` (keep 127.0.0.1) or add \`host.docker.internal:host-gateway\`. Leaving as-is.`);
140
+ } else {
141
+ const before = meshEnv[k];
142
+ meshEnv[k] = before.replace(/127\.0\.0\.1|localhost/g, "host.docker.internal");
143
+ warn(`Rewrote ${k}: ${before} → ${meshEnv[k]} (so the container can reach your host).`);
144
+ }
145
+ }
146
+ }
147
+
148
+ // ---- .env editing (idempotent, pure) ----------------------------------------
149
+ const envKey = (l) => l.replace(/^#\s*/, "").split("=")[0].trim();
150
+
151
+ function applyEnv(content, set) {
87
152
  // Link mode and manual mode are mutually exclusive; comment out the other mode's keys so a
88
- // re-run never leaves a stale COTAL_SPACE overriding a new COTAL_LINK (individual vars win in
89
- // configFromEnv).
153
+ // re-run never leaves a stale COTAL_SPACE overriding a new COTAL_LINK.
90
154
  const drop = set.COTAL_LINK ? ["COTAL_SPACE", "COTAL_SERVERS"] : ["COTAL_LINK"];
91
- const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
92
- const keyOf = (l) => l.replace(/^#\s*/, "").split("=")[0].trim();
155
+ const lines = content ? content.split("\n") : [];
93
156
  for (const [k, v] of Object.entries(set)) {
94
157
  const line = `${k}=${v}`;
95
- const i = lines.findIndex((l) => keyOf(l) === k);
158
+ const i = lines.findIndex((l) => envKey(l) === k);
96
159
  if (i >= 0) lines[i] = line;
97
160
  else lines.push(line);
98
161
  }
99
162
  for (let i = 0; i < lines.length; i++) {
100
- if (drop.includes(keyOf(lines[i])) && !lines[i].trimStart().startsWith("#")) lines[i] = `# ${lines[i]}`;
163
+ if (drop.includes(envKey(lines[i])) && !lines[i].trimStart().startsWith("#")) lines[i] = `# ${lines[i]}`;
101
164
  }
102
- writeFileSync(file, lines.join("\n").replace(/\n*$/, "\n"));
165
+ return lines.join("\n").replace(/\n*$/, "\n");
166
+ }
167
+
168
+ function stripEnv(content, keys) {
169
+ if (!content) return content;
170
+ return content
171
+ .split("\n")
172
+ .filter((l) => !keys.includes(envKey(l)))
173
+ .join("\n")
174
+ .replace(/\n*$/, "\n");
175
+ }
176
+
177
+ function writeEnvFile(file, set) {
178
+ mkdirSync(dirname(file), { recursive: true });
179
+ const cur = existsSync(file) ? readFileSync(file, "utf8") : "";
180
+ writeFileSync(file, applyEnv(cur, set));
181
+ }
182
+
183
+ function stripEnvFile(file, keys) {
184
+ if (!existsSync(file)) return false;
185
+ const cur = readFileSync(file, "utf8");
186
+ const next = stripEnv(cur, keys);
187
+ if (next === cur) return false;
188
+ writeFileSync(file, next);
189
+ return true;
103
190
  }
104
191
 
105
192
  // ---- mesh reachability ------------------------------------------------------
106
193
  function hostPort(server) {
107
194
  try {
108
195
  const u = new URL(server.replace(/^(cotals?|nats):\/\//, "http://"));
109
- return { host: u.hostname, port: Number(u.port || 4222) };
196
+ // host.docker.internal is the container's name for the host; probe it as loopback from here.
197
+ const host = u.hostname === "host.docker.internal" ? "127.0.0.1" : u.hostname;
198
+ return { host, port: Number(u.port || 4222) };
110
199
  } catch {
111
200
  return undefined;
112
201
  }
113
202
  }
203
+
114
204
  function reachable(server) {
115
205
  const hp = hostPort(server);
116
206
  if (!hp) return Promise.resolve(false);
@@ -131,14 +221,15 @@ function reachable(server) {
131
221
  });
132
222
  }
133
223
 
134
- // ---- main -------------------------------------------------------------------
135
- const args = process.argv.slice(2);
136
- if (args[0] !== "install") {
137
- usage();
138
- process.exit(args[0] ? 1 : 0);
224
+ async function reachabilityInfo(meshEnv) {
225
+ const server = meshEnv.COTAL_SERVERS || meshEnv.COTAL_LINK;
226
+ if (!server) return;
227
+ const ok = await reachable(server);
228
+ const hp = hostPort(server);
229
+ info(ok ? "✓ Mesh reachable" : `! Mesh not reachable at ${hp?.host}:${hp?.port} — start it (e.g. \`nats-server -js\`) before \`hermes gateway run\``);
139
230
  }
140
- const flags = parseFlags(args.slice(1));
141
231
 
232
+ // ---- host / docker discovery ------------------------------------------------
142
233
  function hermesVersion() {
143
234
  try {
144
235
  return execFileSync("hermes", ["--version"], { encoding: "utf8" }).trim();
@@ -147,64 +238,292 @@ function hermesVersion() {
147
238
  }
148
239
  }
149
240
 
150
- const version = hermesVersion();
151
- if (!version) {
152
- die(
153
- "Hermes isn't installed (no `hermes` on PATH). Install it first:\n" +
154
- " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash\n" +
155
- " then re-run: npx @cotal-ai/connector-hermes install",
156
- );
241
+ function dockerAvailable() {
242
+ try {
243
+ execFileSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: ["ignore", "pipe", "ignore"] });
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ // Running containers whose image looks like Hermes AND that actually have a `hermes` binary.
251
+ function findHermesContainers() {
252
+ let out;
253
+ try {
254
+ out = execFileSync("docker", ["ps", "--format", "{{.ID}}\t{{.Image}}\t{{.Names}}"], { encoding: "utf8" });
255
+ } catch {
256
+ return [];
257
+ }
258
+ const rows = out
259
+ .split("\n")
260
+ .map((l) => l.trim())
261
+ .filter(Boolean)
262
+ .map((l) => {
263
+ const [id, image, name] = l.split("\t");
264
+ return { id, image, name };
265
+ })
266
+ .filter((r) => /hermes/i.test(r.image));
267
+ return rows.filter((r) => {
268
+ try {
269
+ execFileSync("docker", ["exec", r.name, "hermes", "--version"], { stdio: ["ignore", "pipe", "ignore"] });
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ });
275
+ }
276
+
277
+ // Host path bind-mounted at /opt/data (so files placed there appear in the container), or null
278
+ // for a volume / no mount (then we copy in with `docker cp`).
279
+ function bindSource(container) {
280
+ try {
281
+ const out = execFileSync("docker", ["inspect", container, "--format", "{{json .Mounts}}"], { encoding: "utf8" });
282
+ const m = JSON.parse(out).find((x) => x.Destination === CONTAINER_HOME);
283
+ return m && m.Type === "bind" ? m.Source : null;
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ // ---- target resolution ------------------------------------------------------
290
+ function resolveHome(flags) {
291
+ if (flags.profile) return join(homedir(), ".hermes", "profiles", flags.profile);
292
+ return process.env.HERMES_HOME?.trim() || join(homedir(), ".hermes");
157
293
  }
158
- if (!existsSync(SIDECAR)) {
159
- die(
160
- `bundled sidecar missing at ${SIDECAR}\n` +
161
- " (running from source? build it first: pnpm --filter @cotal-ai/connector-hermes build)",
162
- );
294
+
295
+ function resolveTarget(flags) {
296
+ if (flags["target-home"]) return { mode: "files", home: flags["target-home"] };
297
+ if (flags.docker) return { mode: "docker", container: flags.docker, explicit: true };
298
+ const version = hermesVersion();
299
+ if (version) return { mode: "host", home: resolveHome(flags), profile: flags.profile, version };
300
+ if (dockerAvailable()) {
301
+ const cs = findHermesContainers();
302
+ if (cs.length === 1) return { mode: "docker", container: cs[0].name, detected: cs[0] };
303
+ if (cs.length > 1) return { mode: "docker-multi", containers: cs };
304
+ }
305
+ return { mode: "none" };
163
306
  }
164
- info(`✓ Hermes detected — ${version.split("·")[0].trim()}`);
165
307
 
166
- const hermesHome = process.env.HERMES_HOME?.trim() || join(homedir(), ".hermes");
167
- const interactive = Boolean(process.stdin.isTTY) && !flags.yes;
168
- const meshEnv = interactive ? await promptConfig() : configFromFlags(flags);
308
+ const targetDesc = (t) =>
309
+ t.mode === "docker" ? `container '${t.container}'` : t.mode === "host" ? `${t.home}` : t.home;
169
310
 
170
- // Write mesh config to <home>/.env (if any was gathered).
171
- if (Object.keys(meshEnv).length) {
172
- const envFile = join(hermesHome, ".env");
173
- mkdirSync(hermesHome, { recursive: true });
174
- writeEnv(envFile, meshEnv);
175
- info(`✓ Wrote ${Object.keys(meshEnv).join(", ")} to ${envFile}`);
311
+ // Resolve auto-detected/ambiguous docker targets into a concrete one (or exit).
312
+ async function settleTarget(target, interactive, verb) {
313
+ if (target.mode === "none") {
314
+ die(
315
+ `No Hermes found to ${verb}.\n` +
316
+ " Host install? Put `hermes` on PATH first:\n" +
317
+ " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash\n" +
318
+ " • Running in Docker? pass --docker <container> (or run this inside it: `docker exec -it <c> npx @cotal-ai/connector-hermes " +
319
+ verb +
320
+ "`)\n" +
321
+ " • Just place/remove files for a runtime elsewhere? pass --target-home <path>",
322
+ );
323
+ }
324
+ if (target.mode === "docker-multi") {
325
+ if (!interactive) die("Multiple Hermes containers found — pass --docker <container>.");
326
+ return { mode: "docker", container: await pickContainer(target.containers) };
327
+ }
328
+ if (target.mode === "docker" && target.detected) {
329
+ // Auto-detected, not explicitly named: confirm in a TTY, never mutate silently otherwise.
330
+ if (!interactive)
331
+ die(`Detected container '${target.container}'. Re-run with --docker ${target.container} to confirm (a non-interactive run won't mutate an auto-detected container).`);
332
+ if (!(await confirm(`Found Hermes container '${target.container}' (${target.detected.image}) — ${verb} Cotal ${verb === "install" ? "into" : "from"} it? [Y/n] `, true)))
333
+ die("aborted");
334
+ }
335
+ return target;
336
+ }
176
337
 
177
- // Best-effort reachability check.
178
- const server = meshEnv.COTAL_SERVERS || meshEnv.COTAL_LINK;
179
- if (server) {
180
- const ok = await reachable(server);
181
- info(ok ? `✓ Mesh reachable` : `✗ Mesh not reachable at ${hostPort(server)?.host}:${hostPort(server)?.port} — start it (e.g. \`nats-server -js\`) before \`hermes gateway run\``);
182
- }
183
- }
184
-
185
- // Copy the plugin + enable it.
186
- const pluginsDir = join(hermesHome, "plugins");
187
- const dest = join(pluginsDir, "cotal");
188
- mkdirSync(pluginsDir, { recursive: true });
189
- rmSync(dest, { recursive: true, force: true });
190
- cpSync(PLUGIN_SRC, dest, {
191
- recursive: true,
192
- filter: (src) => !src.includes(`${sep}__pycache__`) && !src.endsWith(".pyc"),
193
- });
194
- try {
195
- execFileSync("hermes", ["plugins", "enable", "cotal"], { stdio: "inherit" });
196
- } catch {
197
- die(
198
- "copied the plugin but `hermes plugins enable cotal` failed.\n" +
199
- " Enable it manually: `hermes plugins enable cotal`.",
200
- );
201
- }
202
-
203
- info("\n✓ Cotal plugin installed + enabled.\n");
204
- if (Object.keys(meshEnv).length) {
205
- info("Run it on the mesh:");
206
- info(" hermes gateway run");
207
- } else {
208
- info("Next: set your mesh in ~/.hermes/.env (COTAL_LINK, or COTAL_SPACE/COTAL_NAME/COTAL_SERVERS),");
209
- info(" then: hermes gateway run");
338
+ // ---- plugin file placement --------------------------------------------------
339
+ function copyPluginTo(dest) {
340
+ rmSync(dest, { recursive: true, force: true });
341
+ mkdirSync(dirname(dest), { recursive: true });
342
+ cpSync(PLUGIN_SRC, dest, {
343
+ recursive: true,
344
+ filter: (src) => !src.includes(`${sep}__pycache__`) && !src.endsWith(".pyc"),
345
+ });
346
+ }
347
+
348
+ function dockerCopyPlugin(container) {
349
+ const stage = mkdtempSync(join(tmpdir(), "cotal-plugin-"));
350
+ try {
351
+ copyPluginTo(join(stage, "cotal"));
352
+ execFileSync("docker", ["exec", container, "mkdir", "-p", `${CONTAINER_HOME}/plugins`], { stdio: "inherit" });
353
+ execFileSync("docker", ["cp", join(stage, "cotal"), `${container}:${CONTAINER_HOME}/plugins/cotal`], { stdio: "inherit" });
354
+ } finally {
355
+ rmSync(stage, { recursive: true, force: true });
356
+ }
357
+ }
358
+
359
+ function dockerReadFile(container, path) {
360
+ try {
361
+ return execFileSync("docker", ["exec", container, "sh", "-c", `cat ${path} 2>/dev/null || true`], { encoding: "utf8" });
362
+ } catch {
363
+ return "";
364
+ }
365
+ }
366
+
367
+ function dockerWriteFile(container, path, content) {
368
+ execFileSync("docker", ["exec", "-i", container, "sh", "-c", `mkdir -p "$(dirname ${path})" && cat > ${path}`], { input: content });
369
+ }
370
+
371
+ // ---- running hermes (host or in-container) ----------------------------------
372
+ function runHermes(target, hermesArgs) {
373
+ if (target.mode === "host")
374
+ execFileSync("hermes", [...(target.profile ? ["-p", target.profile] : []), ...hermesArgs], { stdio: "inherit" });
375
+ else if (target.mode === "docker")
376
+ execFileSync("docker", ["exec", target.container, "hermes", ...hermesArgs], { stdio: "inherit" });
377
+ else throw new Error("runHermes is not available in files-only mode");
210
378
  }
379
+
380
+ function printRestart(target) {
381
+ if (target.mode === "docker") {
382
+ info("Restart the gateway:");
383
+ info(` docker restart ${target.container}`);
384
+ } else if (target.mode === "host") {
385
+ info("Restart the gateway to load it:");
386
+ info(" hermes gateway restart # supervised service (launchd/systemd)");
387
+ info(" # or restart your foreground `hermes gateway run`");
388
+ } else {
389
+ info("Where Hermes runs, enable + restart it:");
390
+ info(" hermes plugins enable cotal && hermes gateway restart");
391
+ }
392
+ }
393
+
394
+ // ---- install ----------------------------------------------------------------
395
+ async function install(flags) {
396
+ if (!existsSync(SIDECAR))
397
+ die(
398
+ `bundled sidecar missing at ${SIDECAR}\n` +
399
+ " (running from source? build it first: pnpm --filter @cotal-ai/connector-hermes build)",
400
+ );
401
+
402
+ const interactive = Boolean(process.stdin.isTTY) && !flags.yes;
403
+ let target = await settleTarget(resolveTarget(flags), interactive, "install");
404
+
405
+ if (target.mode === "host") info(`✓ Hermes detected — ${target.version.split("·")[0].trim()}`);
406
+ else if (target.mode === "docker") info(`✓ Targeting Hermes container '${target.container}'`);
407
+ else info(`• Files-only install into ${target.home} (no hermes binary will be invoked)`);
408
+
409
+ // Gather mesh config (docker defaults the server to host.docker.internal).
410
+ const defaults = target.mode === "docker" ? { ...DEFAULTS, server: DOCKER_SERVER } : DEFAULTS;
411
+ const meshEnv = interactive ? await promptConfig(defaults) : configFromFlags(flags, defaults);
412
+ if (target.mode === "docker") dockerizeServer(meshEnv);
413
+ const hasEnv = Object.keys(meshEnv).length > 0;
414
+
415
+ // Place .env + plugin files, per mode.
416
+ if (target.mode === "docker") {
417
+ const bind = bindSource(target.container);
418
+ if (bind) {
419
+ if (hasEnv) writeEnvFile(join(bind, ".env"), meshEnv);
420
+ copyPluginTo(join(bind, "plugins", "cotal"));
421
+ } else {
422
+ if (hasEnv) dockerWriteFile(target.container, `${CONTAINER_HOME}/.env`, applyEnv(dockerReadFile(target.container, `${CONTAINER_HOME}/.env`), meshEnv));
423
+ dockerCopyPlugin(target.container);
424
+ }
425
+ if (hasEnv) info(`✓ Wrote ${Object.keys(meshEnv).join(", ")} to ${target.container}:${CONTAINER_HOME}/.env`);
426
+ // The sidecar needs Node inside the container; the official image ships it, but verify.
427
+ try {
428
+ execFileSync("docker", ["exec", target.container, "node", "--version"], { stdio: ["ignore", "pipe", "ignore"] });
429
+ } catch {
430
+ warn(`No \`node\` in container '${target.container}' — the Cotal sidecar can't start. Use the official nousresearch/hermes-agent image (it bundles Node 22).`);
431
+ }
432
+ } else {
433
+ const envFile = join(target.home, ".env");
434
+ if (hasEnv) {
435
+ writeEnvFile(envFile, meshEnv);
436
+ info(`✓ Wrote ${Object.keys(meshEnv).join(", ")} to ${envFile}`);
437
+ }
438
+ copyPluginTo(join(target.home, "plugins", "cotal"));
439
+ }
440
+
441
+ if (hasEnv) await reachabilityInfo(meshEnv);
442
+
443
+ // Enable (host/docker only — files-only has no binary to call).
444
+ if (target.mode === "files") {
445
+ info("\n✓ Cotal plugin files placed.");
446
+ info("Can't enable without a hermes binary. Next, where Hermes runs:");
447
+ info(" hermes plugins enable cotal");
448
+ printRestart(target);
449
+ return;
450
+ }
451
+ try {
452
+ runHermes(target, ["plugins", "enable", "cotal"]);
453
+ } catch {
454
+ die("copied the plugin but `plugins enable cotal` failed.\n Enable it manually: `hermes plugins enable cotal`.");
455
+ }
456
+
457
+ info("\n✓ Cotal plugin installed + enabled.\n");
458
+ if (!hasEnv) info("Set your mesh in .env (COTAL_LINK, or COTAL_SPACE/COTAL_NAME/COTAL_SERVERS), then:");
459
+ printRestart(target);
460
+ }
461
+
462
+ // ---- uninstall --------------------------------------------------------------
463
+ async function uninstall(flags) {
464
+ const interactive = Boolean(process.stdin.isTTY) && !flags.yes;
465
+ const target = await settleTarget(resolveTarget(flags), interactive, "uninstall");
466
+
467
+ if (interactive && !(await confirm(`Remove Cotal from ${targetDesc(target)} and disable it? [y/N] `, false))) die("aborted");
468
+
469
+ // Disable (idempotent — cotal not being enabled is fine), then delete the files.
470
+ if (target.mode === "docker") {
471
+ try {
472
+ runHermes(target, ["plugins", "disable", "cotal"]);
473
+ } catch {
474
+ /* not enabled */
475
+ }
476
+ try {
477
+ execFileSync("docker", ["exec", target.container, "rm", "-rf", `${CONTAINER_HOME}/plugins/cotal`], { stdio: "inherit" });
478
+ } catch {
479
+ /* already gone */
480
+ }
481
+ info(`✓ Removed ${CONTAINER_HOME}/plugins/cotal in '${target.container}'`);
482
+ } else {
483
+ if (target.mode === "host") {
484
+ try {
485
+ runHermes(target, ["plugins", "disable", "cotal"]);
486
+ } catch {
487
+ /* not enabled */
488
+ }
489
+ }
490
+ const dir = join(target.home, "plugins", "cotal");
491
+ const had = existsSync(dir);
492
+ rmSync(dir, { recursive: true, force: true });
493
+ info(had ? `✓ Removed ${dir}` : `• No plugin dir at ${dir}`);
494
+ if (target.mode === "files") info("Where Hermes runs, also disable it: hermes plugins disable cotal");
495
+ }
496
+
497
+ // Clean only the COTAL_* keys we manage, leaving the rest of .env untouched.
498
+ if (!flags["keep-env"]) {
499
+ if (target.mode === "docker") {
500
+ const bind = bindSource(target.container);
501
+ if (bind) {
502
+ if (stripEnvFile(join(bind, ".env"), ENV_KEYS)) info(`✓ Cleaned COTAL_* from ${join(bind, ".env")}`);
503
+ } else {
504
+ const cur = dockerReadFile(target.container, `${CONTAINER_HOME}/.env`);
505
+ const next = stripEnv(cur, ENV_KEYS);
506
+ if (cur && next !== cur) {
507
+ dockerWriteFile(target.container, `${CONTAINER_HOME}/.env`, next);
508
+ info(`✓ Cleaned COTAL_* from ${target.container}:${CONTAINER_HOME}/.env`);
509
+ }
510
+ }
511
+ } else if (stripEnvFile(join(target.home, ".env"), ENV_KEYS)) {
512
+ info(`✓ Cleaned COTAL_* from ${join(target.home, ".env")}`);
513
+ }
514
+ }
515
+
516
+ info("\n✓ Cotal uninstalled.\n");
517
+ printRestart(target);
518
+ }
519
+
520
+ // ---- main -------------------------------------------------------------------
521
+ const args = process.argv.slice(2);
522
+ const cmd = args[0];
523
+ if (cmd !== "install" && cmd !== "uninstall") {
524
+ usage();
525
+ process.exit(cmd ? 1 : 0);
526
+ }
527
+ const flags = parseFlags(args.slice(1));
528
+ if (cmd === "install") await install(flags);
529
+ else await uninstall(flags);
@@ -15,10 +15,13 @@ function log(msg) {
15
15
  const sidecar = startSidecar();
16
16
  log(`mesh sidecar up for ${sidecar.config.name} in space ${sidecar.config.space}`);
17
17
  let stopping = false;
18
+ let parentWatch;
18
19
  const stop = async (code) => {
19
20
  if (stopping)
20
21
  return;
21
22
  stopping = true;
23
+ if (parentWatch)
24
+ clearInterval(parentWatch);
22
25
  try {
23
26
  await sidecar.stop();
24
27
  }
@@ -28,4 +31,39 @@ const stop = async (code) => {
28
31
  };
29
32
  process.on("SIGINT", () => void stop(0));
30
33
  process.on("SIGTERM", () => void stop(0));
34
+ // A sidecar must never outlive the gateway that spawned it: an orphan keeps publishing presence as
35
+ // a phantom `${name}` peer AND keeps pulling the shared mesh inbox, so a peer that resolves the
36
+ // phantom DMs a black hole that never replies. The gateway doesn't always signal us on
37
+ // restart/crash.
38
+ //
39
+ // We watch the EXACT pid of the launching gateway (COTAL_PARENT_PID), probed with a signal-0
40
+ // liveness check. This is robust where ppid is not: the official container image boots the gateway
41
+ // twice (a transient CMD `gateway run` hands off to the supervised service), and a sidecar can be
42
+ // reparented to an unrelated process before we ever read ppid — so "ppid changed" never fires.
43
+ // Watching the explicit launcher pid follows the right process regardless of reparenting; the ppid
44
+ // check stays as a cheap backstop for any launcher that didn't set COTAL_PARENT_PID.
45
+ const launcherPid = Number(process.env.COTAL_PARENT_PID) || undefined;
46
+ const initialPpid = process.ppid;
47
+ const launcherGone = () => {
48
+ if (launcherPid === undefined)
49
+ return false;
50
+ try {
51
+ process.kill(launcherPid, 0);
52
+ return false;
53
+ }
54
+ catch {
55
+ return true;
56
+ }
57
+ };
58
+ parentWatch = setInterval(() => {
59
+ if (launcherGone()) {
60
+ log(`launching gateway (pid ${launcherPid}) is gone — stopping sidecar to avoid an orphan`);
61
+ void stop(0);
62
+ }
63
+ else if (launcherPid === undefined && process.ppid !== initialPpid) {
64
+ log(`parent gateway (${initialPpid}) is gone — stopping sidecar to avoid an orphan`);
65
+ void stop(0);
66
+ }
67
+ }, 1000);
68
+ parentWatch.unref();
31
69
  //# sourceMappingURL=standalone.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"standalone.js","sourceRoot":"","sources":["../src/standalone.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,SAAS,GAAG,CAAC,GAAW;IACtB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,6BAA6B,GAAG,IAAI,CAAC,CAAC;AAC7D,CAAC;AAED,MAAM,OAAO,GAAG,YAAY,EAAE,CAAC;AAC/B,GAAG,CAAC,uBAAuB,OAAO,CAAC,MAAM,CAAC,IAAI,aAAa,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;AAEnF,IAAI,QAAQ,GAAG,KAAK,CAAC;AACrB,MAAM,IAAI,GAAG,KAAK,EAAE,IAAY,EAAiB,EAAE;IACjD,IAAI,QAAQ;QAAE,OAAO;IACrB,QAAQ,GAAG,IAAI,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,IAAI,EAAE,CAAC;IACvB,CAAC;YAAS,CAAC;QACT,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC;AACH,CAAC,CAAC;AAEF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACzC,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"standalone.js","sourceRoot":"","sources":["../src/standalone.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,SAAS,GAAG,CAAC,GAAW;IACtB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,6BAA6B,GAAG,IAAI,CAAC,CAAC;AAC7D,CAAC;AAED,MAAM,OAAO,GAAG,YAAY,EAAE,CAAC;AAC/B,GAAG,CAAC,uBAAuB,OAAO,CAAC,MAAM,CAAC,IAAI,aAAa,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;AAEnF,IAAI,QAAQ,GAAG,KAAK,CAAC;AACrB,IAAI,WAAuD,CAAC;AAC5D,MAAM,IAAI,GAAG,KAAK,EAAE,IAAY,EAAiB,EAAE;IACjD,IAAI,QAAQ;QAAE,OAAO;IACrB,QAAQ,GAAG,IAAI,CAAC;IAChB,IAAI,WAAW;QAAE,aAAa,CAAC,WAAW,CAAC,CAAC;IAC5C,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,IAAI,EAAE,CAAC;IACvB,CAAC;YAAS,CAAC;QACT,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC;AACH,CAAC,CAAC;AAEF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACzC,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AAE1C,mGAAmG;AACnG,gGAAgG;AAChG,uFAAuF;AACvF,iBAAiB;AACjB,EAAE;AACF,6FAA6F;AAC7F,mGAAmG;AACnG,kGAAkG;AAClG,+FAA+F;AAC/F,mGAAmG;AACnG,qFAAqF;AACrF,MAAM,WAAW,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,SAAS,CAAC;AACtE,MAAM,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;AACjC,MAAM,YAAY,GAAG,GAAG,EAAE;IACxB,IAAI,WAAW,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC5C,IAAI,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;QAC7B,OAAO,KAAK,CAAC;IACf,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC,CAAC;AACF,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE;IAC7B,IAAI,YAAY,EAAE,EAAE,CAAC;QACnB,GAAG,CAAC,0BAA0B,WAAW,iDAAiD,CAAC,CAAC;QAC5F,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC;IACf,CAAC;SAAM,IAAI,WAAW,KAAK,SAAS,IAAI,OAAO,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;QACrE,GAAG,CAAC,mBAAmB,WAAW,iDAAiD,CAAC,CAAC;QACrF,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC;IACf,CAAC;AACH,CAAC,EAAE,IAAI,CAAC,CAAC;AACT,WAAW,CAAC,KAAK,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cotal-ai/connector-hermes",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,14 +22,14 @@
22
22
  "dependencies": {
23
23
  "tsx": "^4.22.4",
24
24
  "zod": "^4.4.3",
25
- "@cotal-ai/connector-core": "0.3.0"
25
+ "@cotal-ai/connector-core": "0.3.2"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@cotal-ai/core": ">=0.1.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "esbuild": "^0.28.0",
32
- "@cotal-ai/core": "0.3.0"
32
+ "@cotal-ai/core": "0.3.2"
33
33
  },
34
34
  "files": [
35
35
  "bin",
@@ -16,6 +16,7 @@ in the environment. Two run modes:
16
16
  from __future__ import annotations
17
17
 
18
18
  import os
19
+ import shutil
19
20
  import subprocess
20
21
  import tempfile
21
22
  import time
@@ -69,6 +70,24 @@ def _resolve_sidecar_js() -> Path:
69
70
  return candidate
70
71
 
71
72
 
73
+ def _resolve_node() -> str:
74
+ """Locate a Node runtime for the sidecar. Prefer `node` on PATH (the gateway's PATH includes
75
+ Hermes' bundled Node and the container has its own); else fall back to Hermes' bundled
76
+ `<HERMES_HOME>/node/bin/node` (the host installer puts it there). Throws if neither exists —
77
+ a bare `pip install hermes-agent` without `install.sh --postinstall` may have no Node."""
78
+ on_path = shutil.which("node")
79
+ if on_path:
80
+ return on_path
81
+ home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
82
+ bundled = Path(home) / "node" / "bin" / "node"
83
+ if bundled.is_file():
84
+ return str(bundled)
85
+ raise RuntimeError(
86
+ f"Node.js not found for the Cotal sidecar: no `node` on PATH and none at {bundled}. "
87
+ "Install Node (Hermes' installer bundles it: `install.sh --ensure node`)."
88
+ )
89
+
90
+
72
91
  def _bootstrap_standalone_sidecar() -> None:
73
92
  """Standalone mode: spawn the bundled Node sidecar and wait until it has published the bridge
74
93
  socket + tools file. Sets the three path env vars first so the sidecar and the rest of this
@@ -83,9 +102,16 @@ def _bootstrap_standalone_sidecar() -> None:
83
102
  os.environ.setdefault("COTAL_TOOLS_FILE", str(run_dir / "cotal-tools.json"))
84
103
 
85
104
  sidecar = _resolve_sidecar_js()
105
+ node = _resolve_node()
106
+ env = os.environ.copy()
107
+ # Tie the sidecar's life to THIS process (the gateway that loaded the plugin). The official
108
+ # container image boots the gateway twice — a transient CMD `gateway run` spawns a sidecar then
109
+ # hands off to the supervised service — so a sidecar must follow its own launcher, not a ppid
110
+ # that can be reparented before the sidecar ever reads it. The sidecar watches this exact pid.
111
+ env["COTAL_PARENT_PID"] = str(os.getpid())
86
112
  subprocess.Popen( # noqa: S603 — trusted bundled asset
87
- ["node", str(sidecar)],
88
- env=os.environ.copy(),
113
+ [node, str(sidecar)],
114
+ env=env,
89
115
  stdout=subprocess.DEVNULL,
90
116
  stderr=subprocess.DEVNULL,
91
117
  )
@@ -33205,9 +33205,11 @@ function log2(msg) {
33205
33205
  var sidecar = startSidecar();
33206
33206
  log2(`mesh sidecar up for ${sidecar.config.name} in space ${sidecar.config.space}`);
33207
33207
  var stopping = false;
33208
+ var parentWatch;
33208
33209
  var stop = async (code) => {
33209
33210
  if (stopping) return;
33210
33211
  stopping = true;
33212
+ if (parentWatch) clearInterval(parentWatch);
33211
33213
  try {
33212
33214
  await sidecar.stop();
33213
33215
  } finally {
@@ -33216,3 +33218,24 @@ var stop = async (code) => {
33216
33218
  };
33217
33219
  process.on("SIGINT", () => void stop(0));
33218
33220
  process.on("SIGTERM", () => void stop(0));
33221
+ var launcherPid = Number(process.env.COTAL_PARENT_PID) || void 0;
33222
+ var initialPpid = process.ppid;
33223
+ var launcherGone = () => {
33224
+ if (launcherPid === void 0) return false;
33225
+ try {
33226
+ process.kill(launcherPid, 0);
33227
+ return false;
33228
+ } catch {
33229
+ return true;
33230
+ }
33231
+ };
33232
+ parentWatch = setInterval(() => {
33233
+ if (launcherGone()) {
33234
+ log2(`launching gateway (pid ${launcherPid}) is gone \u2014 stopping sidecar to avoid an orphan`);
33235
+ void stop(0);
33236
+ } else if (launcherPid === void 0 && process.ppid !== initialPpid) {
33237
+ log2(`parent gateway (${initialPpid}) is gone \u2014 stopping sidecar to avoid an orphan`);
33238
+ void stop(0);
33239
+ }
33240
+ }, 1e3);
33241
+ parentWatch.unref();
@@ -29,9 +29,13 @@ def _spec(descriptor: dict) -> dict:
29
29
  }
30
30
 
31
31
 
32
- def _handler(name: str) -> Callable[[dict], str]:
33
- """Forward a tool call to the sidecar; the sidecar runs the shared spec and returns the text."""
34
- def run(args: dict) -> str:
32
+ def _handler(name: str) -> Callable[..., str]:
33
+ """Forward a tool call to the sidecar; the sidecar runs the shared spec and returns the text.
34
+
35
+ Hermes' tool registry invokes handlers as ``handler(args, **kwargs)``, passing call context
36
+ (``task_id`` and friends). We act only on ``args`` and accept-and-ignore the rest, so the
37
+ signature can't reject a kwarg the host adds."""
38
+ def run(args: dict, **_ctx: Any) -> str:
35
39
  try:
36
40
  return get_client().call_tool(name, args or {})
37
41
  except Exception as e: # surfaced back to the model as the tool result