agentglow 0.2.0 → 0.2.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 +17 -0
- package/cli/agentglow.mjs +177 -0
- package/cli/lib/net.mjs +73 -0
- package/cli/lib/server.mjs +128 -0
- package/cli/lib/settings.mjs +192 -0
- package/cli/lib/uv.mjs +139 -0
- package/dist/chunks/{KitScene-BuFtYuhc.js → KitScene-C3SZiKL5.js} +1101 -1045
- package/dist/chunks/{index-DO9SAG-u.js → index--dwzdFRh.js} +1 -1
- package/dist/chunks/{index-DCmhxN8v.js → index-B2PGx_wt.js} +37 -37
- package/dist/chunks/{index-HyfZ0ePz.js → index-BZOG_CQS.js} +110 -110
- package/dist/chunks/{index-Bo5HRf-O.js → index-BfGluv59.js} +69 -69
- package/dist/chunks/{index-tonipLb2.js → index-Bm4te7Ug.js} +1 -1
- package/dist/chunks/{index-e6egU2Q6.js → index-Bqv_wy49.js} +1 -1
- package/dist/chunks/{index-ZT88hgW-.js → index-Bsmr7FwH.js} +1 -1
- package/dist/chunks/{index-BPyTLdiF.js → index-Bw31uayO.js} +33 -33
- package/dist/chunks/{index-Dn6oRasS.js → index-C4F16M9q.js} +117 -117
- package/dist/chunks/{index-jLXx_Wgk.js → index-CRRHsMqy.js} +15 -15
- package/dist/chunks/{index-Cyv7bII1.js → index-CS8eZTr_.js} +1 -1
- package/dist/chunks/{index-D733n3Hf.js → index-CdzGoWrn.js} +1 -1
- package/dist/chunks/{index-XMOz-HBf.js → index-CvDEulcq.js} +1 -1
- package/dist/chunks/{index-D0UvupqM.js → index-DXBfdHew.js} +1 -1
- package/dist/chunks/{index-C42Ihyo1.js → index-DdCs_ye9.js} +222 -222
- package/dist/chunks/{index-C5YqtHnC.js → index-_KrED-np.js} +1 -1
- package/dist/index.js +1 -1
- package/dist/types/world.d.ts +10 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -8,6 +8,23 @@ Python server, so it works with LangGraph, deepagents, LangChain and anything el
|
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
+
## Watch Claude Code (CLI)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx agentglow claude
|
|
15
|
+
```
|
|
16
|
+
Starts the AgentGlow server (fetching uv + Python on first run, so only Node 18+ is needed), opens the 3D view
|
|
17
|
+
and runs `claude` with AgentGlow hooks + traces. Your agents and subagents appear live as Claude works.
|
|
18
|
+
|
|
19
|
+
| Command | What it does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `npx agentglow claude [--port 8100] [--no-open] [-- <claude args>]` | start/reuse the server, open `/neural`, run Claude Code with AgentGlow attached |
|
|
22
|
+
| `npx agentglow claude --install` / `--uninstall` | add/remove the AgentGlow hooks + env in `~/.claude/settings.json` (backup first) |
|
|
23
|
+
| `npx agentglow serve` / `stop` / `open` `[--port 8100]` | run the server in the foreground / stop a background one / open the view |
|
|
24
|
+
|
|
25
|
+
`AGENTGLOW_URL` points everything at a remote server, `AGENTGLOW_API_KEY` sends an ingest key. Details:
|
|
26
|
+
[examples/claude-code](https://github.com/Nideesh1/agentglow/tree/main/examples/claude-code#cli).
|
|
27
|
+
|
|
11
28
|
## Install
|
|
12
29
|
|
|
13
30
|
```bash
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agentglow CLI: watch Claude Code in 3D with one command. Needs only Node >= 18 (uv + Python are fetched on demand).
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { probe } from "./lib/net.mjs";
|
|
9
|
+
import { baseUrl, claudeSettings, installSettingsFile, uninstallSettingsFile } from "./lib/settings.mjs";
|
|
10
|
+
import { localBase, readPid, serveForeground, startBackground, stopServer } from "./lib/server.mjs";
|
|
11
|
+
import { which } from "./lib/uv.mjs";
|
|
12
|
+
|
|
13
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const VERSION = (() => {
|
|
15
|
+
try { return JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version; } catch { return ""; }
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
const HELP = `agentglow ${VERSION}: live 3D views of agent systems
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
npx agentglow claude [--port 8100] [--no-open] [-- <claude args...>]
|
|
22
|
+
Start (or reuse) the AgentGlow server, open /neural and run Claude Code with AgentGlow hooks + traces.
|
|
23
|
+
npx agentglow claude --install [--port 8100]
|
|
24
|
+
Add the AgentGlow hooks + traces env to ~/.claude/settings.json (backup first). Plain \`claude\` then reports.
|
|
25
|
+
npx agentglow claude --uninstall
|
|
26
|
+
Remove exactly what --install added.
|
|
27
|
+
npx agentglow serve [--port 8100] Run the server in the foreground.
|
|
28
|
+
npx agentglow stop [--port 8100] Stop a server this CLI started in the background.
|
|
29
|
+
npx agentglow open [--port 8100] Open the 3D view in your browser.
|
|
30
|
+
|
|
31
|
+
Environment:
|
|
32
|
+
AGENTGLOW_URL use this (remote) server instead of starting one, e.g. https://glow.example.com
|
|
33
|
+
AGENTGLOW_API_KEY ingest key, sent as x-api-key by the hooks and the OTel traces
|
|
34
|
+
AGENTGLOW_CACHE_DIR where uv, the pidfile and server logs live
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function fail(msg, code = 1) {
|
|
38
|
+
console.error(`agentglow: ${msg}`);
|
|
39
|
+
process.exit(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const o = { cmd: argv[0], port: Number(process.env.AGENTGLOW_PORT) || 8100, open: true, install: false, uninstall: false, rest: [] };
|
|
44
|
+
const args = argv.slice(1);
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const a = args[i];
|
|
47
|
+
if (a === "--") { o.rest.push(...args.slice(i + 1)); break; }
|
|
48
|
+
else if (a === "--port") o.port = Number(args[++i]);
|
|
49
|
+
else if (a.startsWith("--port=")) o.port = Number(a.slice(7));
|
|
50
|
+
else if (a === "--no-open") o.open = false;
|
|
51
|
+
else if (a === "--install") o.install = true;
|
|
52
|
+
else if (a === "--uninstall") o.uninstall = true;
|
|
53
|
+
else if (a === "-h" || a === "--help") o.help = true;
|
|
54
|
+
else o.rest.push(a); // anything else goes to claude
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) fail("--port must be a number between 1 and 65535");
|
|
57
|
+
return o;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function openBrowser(url) {
|
|
61
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
|
|
62
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
63
|
+
: ["xdg-open", [url]];
|
|
64
|
+
try {
|
|
65
|
+
const c = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
|
|
66
|
+
c.on("error", () => console.error(`Open ${url} in your browser.`));
|
|
67
|
+
c.unref();
|
|
68
|
+
} catch { console.error(`Open ${url} in your browser.`); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const remoteUrl = () => (process.env.AGENTGLOW_URL || "").replace(/\/+$/, "") || null;
|
|
72
|
+
|
|
73
|
+
/** Make sure a server answers; returns { base (for hooks + browser), started }. */
|
|
74
|
+
async function ensureServer(port) {
|
|
75
|
+
const remote = remoteUrl();
|
|
76
|
+
if (remote) {
|
|
77
|
+
const st = await probe(remote, 4000);
|
|
78
|
+
if (st !== "agentglow") console.error(`agentglow: warning: ${remote}/live/health did not answer like AgentGlow; Claude Code will run anyway.`);
|
|
79
|
+
return { base: remote, started: false };
|
|
80
|
+
}
|
|
81
|
+
const st = await probe(localBase(port));
|
|
82
|
+
if (st === "agentglow") return { base: baseUrl({ port }), started: false };
|
|
83
|
+
if (st === "other") fail(`port ${port} is in use by something that is not AgentGlow. Pick another one: --port ${port + 1}`);
|
|
84
|
+
await startBackground({ port, version: VERSION });
|
|
85
|
+
return { base: baseUrl({ port }), started: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cmdClaude(o) {
|
|
89
|
+
if (o.install || o.uninstall) {
|
|
90
|
+
const file = path.join(os.homedir(), ".claude", "settings.json");
|
|
91
|
+
if (o.uninstall) {
|
|
92
|
+
const r = uninstallSettingsFile(file);
|
|
93
|
+
console.log(r.changed ? `Removed the AgentGlow hooks + env from ${file} (backup: ${r.backup})` : `No AgentGlow entries in ${file}.`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const base = remoteUrl() || baseUrl({ port: o.port });
|
|
97
|
+
const r = installSettingsFile(file, base);
|
|
98
|
+
if (!r.changed) console.log(`AgentGlow is already installed in ${file} (${base}).`);
|
|
99
|
+
else console.log(`Installed AgentGlow hooks + traces env into ${file}${r.backup ? ` (backup: ${r.backup})` : ""}.`);
|
|
100
|
+
if (r.skipped.length) console.log(`Left your own values for: ${r.skipped.join(", ")}`);
|
|
101
|
+
if (process.env.AGENTGLOW_API_KEY) console.log('For traces with an ingest key also export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=$AGENTGLOW_API_KEY".');
|
|
102
|
+
console.log(`Every \`claude\` session now reports to ${base}. Start the server with: npx agentglow serve${o.port !== 8100 ? ` --port ${o.port}` : ""}`);
|
|
103
|
+
console.log("Undo with: npx agentglow claude --uninstall");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const claude = which("claude");
|
|
108
|
+
if (!claude) fail("`claude` is not on your PATH. Install Claude Code first: npm i -g @anthropic-ai/claude-code");
|
|
109
|
+
|
|
110
|
+
const { base, started } = await ensureServer(o.port);
|
|
111
|
+
const view = `${base}/neural`;
|
|
112
|
+
console.error(`AgentGlow: ${view}${started ? " (server started in the background)" : ""}`);
|
|
113
|
+
if (o.open) openBrowser(view);
|
|
114
|
+
|
|
115
|
+
const tmp = path.join(os.tmpdir(), `agentglow-claude-${process.pid}.json`);
|
|
116
|
+
fs.writeFileSync(tmp, JSON.stringify(claudeSettings(base), null, 2));
|
|
117
|
+
const env = { ...process.env };
|
|
118
|
+
if (env.AGENTGLOW_API_KEY && !env.OTEL_EXPORTER_OTLP_HEADERS) env.OTEL_EXPORTER_OTLP_HEADERS = `x-api-key=${env.AGENTGLOW_API_KEY}`;
|
|
119
|
+
|
|
120
|
+
const code = await new Promise((resolve) => {
|
|
121
|
+
const win = process.platform === "win32";
|
|
122
|
+
const args = ["--settings", tmp, ...o.rest];
|
|
123
|
+
const child = win
|
|
124
|
+
? spawn([claude, ...args].map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" "), { stdio: "inherit", env, shell: true })
|
|
125
|
+
: spawn(claude, args, { stdio: "inherit", env });
|
|
126
|
+
// Claude Code handles Ctrl-C itself; don't let it kill us before we can clean up
|
|
127
|
+
const ignore = () => {};
|
|
128
|
+
process.on("SIGINT", ignore);
|
|
129
|
+
child.on("error", (e) => { console.error(`agentglow: could not start claude: ${e.message}`); resolve(1); });
|
|
130
|
+
child.on("exit", (c, sig) => {
|
|
131
|
+
process.off("SIGINT", ignore);
|
|
132
|
+
resolve(c ?? (sig === "SIGINT" ? 130 : sig === "SIGTERM" ? 143 : 1));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
|
|
136
|
+
if (!remoteUrl() && readPid(o.port)) {
|
|
137
|
+
console.error(`AgentGlow is still running at ${view}. Stop it with: npx agentglow stop${o.port !== 8100 ? ` --port ${o.port}` : ""}`);
|
|
138
|
+
}
|
|
139
|
+
return code;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main() {
|
|
143
|
+
const argv = process.argv.slice(2);
|
|
144
|
+
if (!argv.length || ["-h", "--help", "help"].includes(argv[0])) { console.log(HELP); return 0; }
|
|
145
|
+
if (["-v", "--version", "version"].includes(argv[0])) { console.log(VERSION); return 0; }
|
|
146
|
+
const o = parseArgs(argv);
|
|
147
|
+
if (o.help) { console.log(HELP); return 0; }
|
|
148
|
+
switch (o.cmd) {
|
|
149
|
+
case "claude":
|
|
150
|
+
return cmdClaude(o);
|
|
151
|
+
case "serve": {
|
|
152
|
+
const st = await probe(localBase(o.port));
|
|
153
|
+
if (st === "agentglow") { console.log(`AgentGlow is already running at ${baseUrl({ port: o.port })}/neural`); return 0; }
|
|
154
|
+
if (st === "other") fail(`port ${o.port} is in use by something that is not AgentGlow. Pick another one: --port ${o.port + 1}`);
|
|
155
|
+
return serveForeground({ port: o.port, version: VERSION });
|
|
156
|
+
}
|
|
157
|
+
case "stop": {
|
|
158
|
+
const r = await stopServer(o.port);
|
|
159
|
+
if (r === "stopped") console.log(`Stopped the AgentGlow server on port ${o.port}.`);
|
|
160
|
+
else if (r === "not-ours") console.log(`The AgentGlow server on port ${o.port} was not started by this CLI; stop it where it runs.`);
|
|
161
|
+
else console.log(`No AgentGlow server on port ${o.port}.`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
case "open": {
|
|
165
|
+
const url = `${remoteUrl() || baseUrl({ port: o.port })}/neural`;
|
|
166
|
+
openBrowser(url);
|
|
167
|
+
console.log(url);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
console.error(`agentglow: unknown command "${o.cmd}"\n`);
|
|
172
|
+
console.log(HELP);
|
|
173
|
+
return 2;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().then((code) => process.exit(code ?? 0), (e) => fail(e.message || String(e)));
|
package/cli/lib/net.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Tiny HTTP helpers on node:http/https (no runtime deps).
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
|
|
7
|
+
function get(url, { timeout = 30000 } = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const mod = url.startsWith("https:") ? https : http;
|
|
10
|
+
const req = mod.get(url, { headers: { "user-agent": "agentglow-cli" } }, resolve);
|
|
11
|
+
req.setTimeout(timeout, () => req.destroy(new Error(`timeout fetching ${url}`)));
|
|
12
|
+
req.on("error", reject);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** GET with redirects, streamed to `dest`. */
|
|
17
|
+
export async function download(url, dest, redirects = 8) {
|
|
18
|
+
const res = await get(url, { timeout: 120000 });
|
|
19
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
20
|
+
res.resume();
|
|
21
|
+
if (!redirects) throw new Error(`too many redirects for ${url}`);
|
|
22
|
+
return download(new URL(res.headers.location, url).toString(), dest, redirects - 1);
|
|
23
|
+
}
|
|
24
|
+
if (res.statusCode !== 200) {
|
|
25
|
+
res.resume();
|
|
26
|
+
throw new Error(`GET ${url} -> HTTP ${res.statusCode}`);
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
const out = fs.createWriteStream(dest);
|
|
30
|
+
res.pipe(out);
|
|
31
|
+
res.on("error", reject);
|
|
32
|
+
out.on("error", reject);
|
|
33
|
+
out.on("finish", resolve);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** GET a URL, return { status, body } or null on connection error / timeout. */
|
|
38
|
+
export async function fetchText(url, timeout = 2000) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await get(url, { timeout });
|
|
41
|
+
let body = "";
|
|
42
|
+
res.setEncoding("utf8");
|
|
43
|
+
for await (const chunk of res) body += chunk;
|
|
44
|
+
return { status: res.statusCode, body };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** "agentglow" when an AgentGlow server answers /live/health, "other" when something else holds it, else "none". */
|
|
51
|
+
export async function probe(base, timeout = 2000) {
|
|
52
|
+
const r = await fetchText(base + "/live/health", timeout);
|
|
53
|
+
if (r) {
|
|
54
|
+
try { if (r.status === 200 && JSON.parse(r.body).ok === true) return "agentglow"; } catch { /* not ours */ }
|
|
55
|
+
return "other";
|
|
56
|
+
}
|
|
57
|
+
const u = new URL(base);
|
|
58
|
+
if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(u.hostname)) return "none";
|
|
59
|
+
// something may hold the port on only one loopback family (e.g. a dev server on [::1] that `localhost` reaches)
|
|
60
|
+
const port = Number(u.port || (u.protocol === "https:" ? 443 : 80));
|
|
61
|
+
const open = await Promise.all([portOpen(port, "127.0.0.1"), portOpen(port, "::1")]);
|
|
62
|
+
return open.some(Boolean) ? "other" : "none";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function portOpen(port, host = "127.0.0.1", timeout = 1000) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const s = net.connect({ port, host });
|
|
68
|
+
const done = (v) => { s.destroy(); resolve(v); };
|
|
69
|
+
s.setTimeout(timeout, () => done(false));
|
|
70
|
+
s.on("connect", () => done(true));
|
|
71
|
+
s.on("error", () => done(false));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Start / stop the Python `agentglow serve` through uv.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { probe } from "./net.mjs";
|
|
6
|
+
import { cacheDir, findRunner } from "./uv.mjs";
|
|
7
|
+
|
|
8
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
|
+
|
|
10
|
+
export const localBase = (port) => `http://127.0.0.1:${port}`;
|
|
11
|
+
export const pidFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.pid`);
|
|
12
|
+
export const logFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.log`);
|
|
13
|
+
|
|
14
|
+
/** PyPI requirement for the server: same version as this npm package (or AGENTGLOW_PY_SPEC). */
|
|
15
|
+
export function pySpecs(version, env = process.env) {
|
|
16
|
+
if (env.AGENTGLOW_PY_SPEC) return [env.AGENTGLOW_PY_SPEC];
|
|
17
|
+
return /^\d+\.\d+\.\d+$/.test(version || "") ? [`agentglow==${version}`, "agentglow"] : ["agentglow"];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function serveArgv(runner, spec, port) {
|
|
21
|
+
return [...runner.args, "--from", spec, "agentglow", "serve", "--host", "127.0.0.1", "--port", String(port)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function alive(pid) {
|
|
25
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readPid(port) {
|
|
29
|
+
try {
|
|
30
|
+
const j = JSON.parse(fs.readFileSync(pidFile(port), "utf8"));
|
|
31
|
+
return j && alive(j.pid) ? j : null;
|
|
32
|
+
} catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tail(file, n = 15) {
|
|
36
|
+
try { return fs.readFileSync(file, "utf8").trimEnd().split("\n").slice(-n).join("\n"); } catch { return ""; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Start the server in the background and wait for /live/health. Returns { pid, started: true }. */
|
|
40
|
+
export async function startBackground({ port, version, log = console.error, timeoutMs = 120000 }) {
|
|
41
|
+
const dir = cacheDir();
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
const runner = await findRunner({ log });
|
|
44
|
+
log(`Starting AgentGlow server on port ${port} (via ${runner.source === "uvx" ? "uvx" : "uv"}). ` +
|
|
45
|
+
"First run downloads Python + agentglow, about 30-60s ...");
|
|
46
|
+
const logPath = logFile(port);
|
|
47
|
+
let lastErr = "";
|
|
48
|
+
for (const spec of pySpecs(version)) {
|
|
49
|
+
const fd = fs.openSync(logPath, "w");
|
|
50
|
+
const child = spawn(runner.cmd, serveArgv(runner, spec, port), {
|
|
51
|
+
detached: true, stdio: ["ignore", fd, fd], windowsHide: true,
|
|
52
|
+
env: { ...process.env, PYTHONUNBUFFERED: "1" },
|
|
53
|
+
});
|
|
54
|
+
fs.closeSync(fd);
|
|
55
|
+
let exited = null;
|
|
56
|
+
child.on("exit", (code) => { exited = code ?? -1; });
|
|
57
|
+
child.on("error", (e) => { exited = -1; lastErr = e.message; });
|
|
58
|
+
child.unref();
|
|
59
|
+
fs.writeFileSync(pidFile(port), JSON.stringify({ pid: child.pid, port, spec, startedAt: new Date().toISOString() }));
|
|
60
|
+
const t0 = Date.now();
|
|
61
|
+
let noted = false;
|
|
62
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
63
|
+
if ((await probe(localBase(port), 1500)) === "agentglow") return { pid: child.pid, started: true };
|
|
64
|
+
if (exited !== null) break;
|
|
65
|
+
if (!noted && Date.now() - t0 > 15000) { log(" still preparing the Python environment ..."); noted = true; }
|
|
66
|
+
await sleep(500);
|
|
67
|
+
}
|
|
68
|
+
if (exited === null) {
|
|
69
|
+
killTree(child.pid);
|
|
70
|
+
fs.rmSync(pidFile(port), { force: true });
|
|
71
|
+
throw new Error(`AgentGlow server did not become healthy within ${timeoutMs / 1000}s. Log: ${logPath}\n${tail(logPath)}`);
|
|
72
|
+
}
|
|
73
|
+
fs.rmSync(pidFile(port), { force: true });
|
|
74
|
+
lastErr = tail(logPath) || lastErr;
|
|
75
|
+
log(` ${spec} failed to start, ${spec === "agentglow" ? "giving up" : "retrying with the latest agentglow"} ...`);
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`AgentGlow server failed to start. Log: ${logPath}\n${lastErr}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function killTree(pid) {
|
|
81
|
+
if (process.platform === "win32") {
|
|
82
|
+
spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stop a server this CLI started. Returns "stopped" | "not-ours" | "none". */
|
|
89
|
+
export async function stopServer(port) {
|
|
90
|
+
const info = readPid(port);
|
|
91
|
+
if (!info) {
|
|
92
|
+
fs.rmSync(pidFile(port), { force: true });
|
|
93
|
+
return (await probe(localBase(port), 1500)) === "agentglow" ? "not-ours" : "none";
|
|
94
|
+
}
|
|
95
|
+
killTree(info.pid);
|
|
96
|
+
for (let i = 0; i < 20 && alive(info.pid); i++) await sleep(250);
|
|
97
|
+
if (alive(info.pid) && process.platform !== "win32") { try { process.kill(-info.pid, "SIGKILL"); } catch { /* gone */ } }
|
|
98
|
+
fs.rmSync(pidFile(port), { force: true });
|
|
99
|
+
return "stopped";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Run the server in the foreground (stdio inherited). Resolves with its exit code. */
|
|
103
|
+
export async function serveForeground({ port, version, log = console.error }) {
|
|
104
|
+
const runner = await findRunner({ log });
|
|
105
|
+
const specs = pySpecs(version);
|
|
106
|
+
for (const [i, spec] of specs.entries()) {
|
|
107
|
+
const t0 = Date.now();
|
|
108
|
+
const code = await new Promise((resolve) => {
|
|
109
|
+
const child = spawn(runner.cmd, serveArgv(runner, spec, port), { stdio: "inherit" });
|
|
110
|
+
const fwd = (sig) => () => child.kill(sig);
|
|
111
|
+
const onInt = fwd("SIGINT"), onTerm = fwd("SIGTERM");
|
|
112
|
+
process.on("SIGINT", onInt);
|
|
113
|
+
process.on("SIGTERM", onTerm);
|
|
114
|
+
child.on("exit", (c, sig) => {
|
|
115
|
+
process.off("SIGINT", onInt);
|
|
116
|
+
process.off("SIGTERM", onTerm);
|
|
117
|
+
resolve(c ?? (sig ? 130 : 1));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
// a pinned version missing on PyPI fails fast; fall back to latest once
|
|
121
|
+
if (code !== 0 && i < specs.length - 1 && Date.now() - t0 < 30000) {
|
|
122
|
+
log(`${spec} failed, retrying with the latest agentglow ...`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
return code;
|
|
126
|
+
}
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Claude Code settings for AgentGlow: HTTP hooks -> POST /v1/claude-code, OTel traces -> POST /v1/traces.
|
|
2
|
+
// Mirrors examples/claude-code/settings.json (a unit test keeps the two in sync).
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const HOOK_PATH = "/v1/claude-code";
|
|
7
|
+
export const TRACES_PATH = "/v1/traces";
|
|
8
|
+
|
|
9
|
+
// [event, needs "matcher": "*"] in the order of examples/claude-code/settings.json
|
|
10
|
+
export const HOOK_EVENTS = [
|
|
11
|
+
["SessionStart", false],
|
|
12
|
+
["UserPromptSubmit", false],
|
|
13
|
+
["PreToolUse", true],
|
|
14
|
+
["PostToolUse", true],
|
|
15
|
+
["PostToolUseFailure", true],
|
|
16
|
+
["SubagentStart", true],
|
|
17
|
+
["SubagentStop", true],
|
|
18
|
+
["Stop", false],
|
|
19
|
+
["SessionEnd", false],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Static env values; OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is added per base URL.
|
|
23
|
+
export const STATIC_ENV = {
|
|
24
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
25
|
+
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1",
|
|
26
|
+
OTEL_TRACES_EXPORTER: "otlp",
|
|
27
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
|
|
28
|
+
};
|
|
29
|
+
const ENDPOINT_KEY = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT";
|
|
30
|
+
const TAIL_ENV = { OTEL_TRACES_EXPORT_INTERVAL: "1000", OTEL_LOG_USER_PROMPTS: "0" };
|
|
31
|
+
|
|
32
|
+
export function baseUrl({ port = 8100, url } = {}) {
|
|
33
|
+
return String(url || `http://localhost:${port}`).replace(/\/+$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function envBlock(base) {
|
|
37
|
+
return { ...STATIC_ENV, [ENDPOINT_KEY]: base + TRACES_PATH, ...TAIL_ENV };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hookGroup(base, withMatcher) {
|
|
41
|
+
const hook = {
|
|
42
|
+
type: "http",
|
|
43
|
+
url: base + HOOK_PATH,
|
|
44
|
+
timeout: 2,
|
|
45
|
+
async: true,
|
|
46
|
+
headers: { "x-api-key": "$AGENTGLOW_API_KEY" },
|
|
47
|
+
allowedEnvVars: ["AGENTGLOW_API_KEY"],
|
|
48
|
+
};
|
|
49
|
+
return withMatcher ? { matcher: "*", hooks: [hook] } : { hooks: [hook] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The full settings object (what `claude --settings <file>` gets). */
|
|
53
|
+
export function claudeSettings(base) {
|
|
54
|
+
const hooks = {};
|
|
55
|
+
for (const [event, m] of HOOK_EVENTS) hooks[event] = [hookGroup(base, m)];
|
|
56
|
+
return { env: envBlock(base), hooks };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isOurHook(h) {
|
|
60
|
+
return !!h && h.type === "http" && typeof h.url === "string" && h.url.includes(HOOK_PATH);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Base URLs of our hooks found in a settings object (used to recognise our traces endpoint). */
|
|
64
|
+
export function ourBases(settings) {
|
|
65
|
+
const out = new Set();
|
|
66
|
+
for (const groups of Object.values(settings?.hooks || {})) {
|
|
67
|
+
if (!Array.isArray(groups)) continue;
|
|
68
|
+
for (const g of groups) for (const h of g?.hooks || []) {
|
|
69
|
+
if (isOurHook(h)) out.add(h.url.slice(0, h.url.indexOf(HOOK_PATH)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...out];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const clone = (o) => JSON.parse(JSON.stringify(o ?? {}));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Remove exactly our hook entries and our env keys. `state.addedEnv` (from the install sidecar) lists the env keys
|
|
79
|
+
* we added; without it, keys are removed only when their value is the one we would have set.
|
|
80
|
+
*/
|
|
81
|
+
export function unmergeSettings(settings, state = null) {
|
|
82
|
+
const s = clone(settings);
|
|
83
|
+
const bases = ourBases(s);
|
|
84
|
+
if (s.hooks && typeof s.hooks === "object") {
|
|
85
|
+
for (const [event, groups] of Object.entries(s.hooks)) {
|
|
86
|
+
if (!Array.isArray(groups)) continue;
|
|
87
|
+
const kept = [];
|
|
88
|
+
for (const g of groups) {
|
|
89
|
+
if (!g || !Array.isArray(g.hooks)) { kept.push(g); continue; }
|
|
90
|
+
const hooks = g.hooks.filter((h) => !isOurHook(h));
|
|
91
|
+
if (hooks.length === g.hooks.length) kept.push(g);
|
|
92
|
+
else if (hooks.length) kept.push({ ...g, hooks });
|
|
93
|
+
}
|
|
94
|
+
if (kept.length) s.hooks[event] = kept;
|
|
95
|
+
else delete s.hooks[event];
|
|
96
|
+
}
|
|
97
|
+
if (!Object.keys(s.hooks).length) delete s.hooks;
|
|
98
|
+
}
|
|
99
|
+
if (s.env && typeof s.env === "object") {
|
|
100
|
+
const ours = { ...STATIC_ENV, ...TAIL_ENV };
|
|
101
|
+
const endpoints = new Set(bases.map((b) => b + TRACES_PATH));
|
|
102
|
+
const candidates = state?.addedEnv ?? [...Object.keys(ours), ENDPOINT_KEY];
|
|
103
|
+
for (const k of candidates) {
|
|
104
|
+
if (!(k in s.env)) continue;
|
|
105
|
+
const v = s.env[k];
|
|
106
|
+
const mine = k === ENDPOINT_KEY ? (state?.addedEnv ? true : endpoints.has(v)) : (state?.addedEnv ? true : v === ours[k]);
|
|
107
|
+
if (mine) delete s.env[k];
|
|
108
|
+
}
|
|
109
|
+
if (!Object.keys(s.env).length) delete s.env;
|
|
110
|
+
}
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Add our hooks + env to `settings` (idempotent: our previous entries are replaced, foreign ones kept).
|
|
116
|
+
* Env keys the user already set to a different value are left alone and reported in `skipped`.
|
|
117
|
+
* Returns { settings, state: { addedEnv, base }, skipped }.
|
|
118
|
+
*/
|
|
119
|
+
export function mergeSettings(settings, base, state = null) {
|
|
120
|
+
const s = unmergeSettings(settings, state);
|
|
121
|
+
const ours = claudeSettings(base);
|
|
122
|
+
s.hooks = s.hooks && typeof s.hooks === "object" ? s.hooks : {};
|
|
123
|
+
for (const [event, groups] of Object.entries(ours.hooks)) {
|
|
124
|
+
s.hooks[event] = [...(Array.isArray(s.hooks[event]) ? s.hooks[event] : []), ...groups];
|
|
125
|
+
}
|
|
126
|
+
s.env = s.env && typeof s.env === "object" ? s.env : {};
|
|
127
|
+
const addedEnv = [];
|
|
128
|
+
const skipped = [];
|
|
129
|
+
for (const [k, v] of Object.entries(ours.env)) {
|
|
130
|
+
if (!(k in s.env)) { s.env[k] = v; addedEnv.push(k); }
|
|
131
|
+
else if (s.env[k] !== v) skipped.push(k);
|
|
132
|
+
}
|
|
133
|
+
if (!Object.keys(s.env).length) delete s.env;
|
|
134
|
+
return { settings: s, state: { base, addedEnv }, skipped };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- files ----------
|
|
138
|
+
|
|
139
|
+
export function statePath(settingsFile) {
|
|
140
|
+
return path.join(path.dirname(settingsFile), "agentglow-install.json");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readJson(file, fallback) {
|
|
144
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e.code === "ENOENT") return fallback;
|
|
147
|
+
throw new Error(`could not parse ${file}: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function backup(file, now) {
|
|
152
|
+
if (!fs.existsSync(file)) return null;
|
|
153
|
+
const ts = new Date(now).toISOString().replace(/[:.]/g, "-");
|
|
154
|
+
const dest = `${file}.agentglow-backup-${ts}`;
|
|
155
|
+
fs.copyFileSync(file, dest);
|
|
156
|
+
return dest;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
160
|
+
|
|
161
|
+
/** Merge into a settings file. Returns { changed, backup, skipped, file }. */
|
|
162
|
+
export function installSettingsFile(file, base, { now = Date.now() } = {}) {
|
|
163
|
+
const before = readJson(file, {});
|
|
164
|
+
const st = readJson(statePath(file), null);
|
|
165
|
+
const { settings, state, skipped } = mergeSettings(before, base, st);
|
|
166
|
+
if (same(before, settings) && st && same(st.addedEnv, state.addedEnv)) {
|
|
167
|
+
return { changed: false, backup: null, skipped, file };
|
|
168
|
+
}
|
|
169
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
170
|
+
const bak = same(before, settings) ? null : backup(file, now);
|
|
171
|
+
if (!same(before, settings)) fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
172
|
+
fs.writeFileSync(statePath(file), JSON.stringify(state, null, 2) + "\n");
|
|
173
|
+
return { changed: true, backup: bak, skipped, file };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Remove our entries from a settings file. Returns { changed, backup, file }. */
|
|
177
|
+
export function uninstallSettingsFile(file, { now = Date.now() } = {}) {
|
|
178
|
+
const before = readJson(file, null);
|
|
179
|
+
const st = readJson(statePath(file), null);
|
|
180
|
+
if (before === null) {
|
|
181
|
+
if (st) fs.rmSync(statePath(file), { force: true });
|
|
182
|
+
return { changed: false, backup: null, file };
|
|
183
|
+
}
|
|
184
|
+
const after = unmergeSettings(before, st);
|
|
185
|
+
let bak = null;
|
|
186
|
+
if (!same(before, after)) {
|
|
187
|
+
bak = backup(file, now);
|
|
188
|
+
fs.writeFileSync(file, JSON.stringify(after, null, 2) + "\n");
|
|
189
|
+
}
|
|
190
|
+
fs.rmSync(statePath(file), { force: true });
|
|
191
|
+
return { changed: !same(before, after), backup: bak, file };
|
|
192
|
+
}
|