@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 +412 -93
- package/dist/standalone.js +38 -0
- package/dist/standalone.js.map +1 -1
- package/package.json +3 -3
- package/plugin/cotal/__init__.py +28 -2
- package/plugin/cotal/_sidecar/standalone.cjs +23 -0
- package/plugin/cotal/tools.py +7 -3
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `<HERMES_HOME>/plugins/cotal`,
|
|
8
|
-
* `<HERMES_HOME>/.env
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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 [${
|
|
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 [${
|
|
64
|
-
const name = (await rl.question(` name [${
|
|
65
|
-
const server = (await rl.question(` 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 ||
|
|
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 ||
|
|
80
|
-
COTAL_NAME: flags.name || process.env.COTAL_NAME ||
|
|
81
|
-
COTAL_SERVERS: 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
|
-
//
|
|
86
|
-
|
|
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
|
|
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 =
|
|
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) =>
|
|
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(
|
|
163
|
+
if (drop.includes(envKey(lines[i])) && !lines[i].trimStart().startsWith("#")) lines[i] = `# ${lines[i]}`;
|
|
101
164
|
}
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
167
|
-
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
package/dist/standalone.js
CHANGED
|
@@ -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
|
package/dist/standalone.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
32
|
+
"@cotal-ai/core": "0.3.2"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"bin",
|
package/plugin/cotal/__init__.py
CHANGED
|
@@ -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
|
-
[
|
|
88
|
-
env=
|
|
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();
|
package/plugin/cotal/tools.py
CHANGED
|
@@ -29,9 +29,13 @@ def _spec(descriptor: dict) -> dict:
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def _handler(name: str) -> Callable[
|
|
33
|
-
"""Forward a tool call to the sidecar; the sidecar runs the shared spec and returns the text.
|
|
34
|
-
|
|
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
|