agentglow 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,16 +11,20 @@ Python server, so it works with LangGraph, deepagents, LangChain and anything el
11
11
  ## Watch Claude Code (CLI)
12
12
 
13
13
  ```bash
14
- npx agentglow claude
14
+ npx agentglow setup # once
15
+ claude # then just use Claude Code as usual
15
16
  ```
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.
17
+ `setup` adds AgentGlow hooks + traces to `~/.claude/settings.json` (backup first) plus a hook that auto-starts the
18
+ AgentGlow server with every `claude` session, then opens the 3D view. Only Node 18+ is needed (uv + Python are fetched
19
+ on first run). Your agents and subagents appear live at http://localhost:8100/neural as Claude works.
18
20
 
19
21
  | Command | What it does |
20
22
  |---|---|
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 |
23
+ | `npx agentglow setup [--port 8100]` | install once (backup first), start the server, open `/neural` |
24
+ | `npx agentglow status` / `open` / `stop` | check install + server / open the view / stop the background server |
25
+ | `npx agentglow remove` | uninstall everything `setup` added and stop the server |
26
+ | `npx agentglow start [--background]` | run the server yourself (`serve` is an alias) |
27
+ | `npx agentglow claude [-- <claude args>]` | try it without installing: one session with temporary settings |
24
28
 
25
29
  `AGENTGLOW_URL` points everything at a remote server, `AGENTGLOW_API_KEY` sends an ingest key. Details:
26
30
  [examples/claude-code](https://github.com/Nideesh1/agentglow/tree/main/examples/claude-code#cli).
package/cli/agentglow.mjs CHANGED
@@ -1,59 +1,71 @@
1
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).
2
+ // agentglow CLI: set up once, then every `claude` session shows up in 3D. Needs only Node >= 18 (uv + Python are fetched on demand).
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { spawn } from "node:child_process";
7
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";
8
+ import { fetchText, probe } from "./lib/net.mjs";
9
+ import {
10
+ baseUrl, claudeSettings, installSettingsFile, isOurHook, readState, startHookCommand, uninstallSettingsFile,
11
+ } from "./lib/settings.mjs";
12
+ import {
13
+ clearLock, installCliCopy, localBase, readLock, readPid, removeCliCopies, serveForeground, spawnStarter,
14
+ startBackground, stopServer,
15
+ } from "./lib/server.mjs";
11
16
  import { which } from "./lib/uv.mjs";
12
17
 
13
- const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const SELF = fileURLToPath(import.meta.url);
19
+ const here = path.dirname(SELF);
14
20
  const VERSION = (() => {
15
21
  try { return JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version; } catch { return ""; }
16
22
  })();
17
23
 
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
24
+ const HELP = `agentglow ${VERSION}: watch Claude Code agents in 3D
25
+
26
+ npx agentglow setup [--port 8100] (recommended) set up once: hooks in ~/.claude/settings.json,
27
+ server auto-starts with every \`claude\` session
28
+ npx agentglow status server health, port, pid, whether hooks are installed
29
+ npx agentglow open open the 3D view (http://localhost:8100/neural)
30
+ npx agentglow stop stop the background server
31
+ npx agentglow remove undo setup: remove the hooks and stop the server
32
+ npx agentglow claude [-- <args>] try it without installing: one claude session with AgentGlow
33
+
34
+ More: npx agentglow start [--background] [--port N] (serve = start in the foreground)
35
+ Env: AGENTGLOW_URL (remote server), AGENTGLOW_API_KEY (ingest key), AGENTGLOW_CACHE_DIR
35
36
  `;
36
37
 
38
+ const DEFAULT_PORT = 8100;
39
+
37
40
  function fail(msg, code = 1) {
38
41
  console.error(`agentglow: ${msg}`);
39
42
  process.exit(code);
40
43
  }
41
44
 
42
45
  function parseArgs(argv) {
43
- const o = { cmd: argv[0], port: Number(process.env.AGENTGLOW_PORT) || 8100, open: true, install: false, uninstall: false, rest: [] };
46
+ const envPort = Number(process.env.AGENTGLOW_PORT);
47
+ const o = {
48
+ cmd: argv[0], port: envPort || DEFAULT_PORT, portSet: !!envPort, open: true, install: false, uninstall: false,
49
+ background: false, quiet: false, rest: [],
50
+ };
44
51
  const args = argv.slice(1);
45
52
  for (let i = 0; i < args.length; i++) {
46
53
  const a = args[i];
47
54
  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));
55
+ else if (a === "--port") { o.port = Number(args[++i]); o.portSet = true; }
56
+ else if (a.startsWith("--port=")) { o.port = Number(a.slice(7)); o.portSet = true; }
50
57
  else if (a === "--no-open") o.open = false;
51
58
  else if (a === "--install") o.install = true;
52
59
  else if (a === "--uninstall") o.uninstall = true;
60
+ else if (a === "--background" || a === "-b") o.background = true;
61
+ else if (a === "--quiet" || a === "-q") o.quiet = true;
53
62
  else if (a === "-h" || a === "--help") o.help = true;
54
63
  else o.rest.push(a); // anything else goes to claude
55
64
  }
56
- if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) fail("--port must be a number between 1 and 65535");
65
+ if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) {
66
+ if (o.quiet) process.exit(0);
67
+ fail("--port must be a number between 1 and 65535");
68
+ }
57
69
  return o;
58
70
  }
59
71
 
@@ -69,8 +81,37 @@ function openBrowser(url) {
69
81
  }
70
82
 
71
83
  const remoteUrl = () => (process.env.AGENTGLOW_URL || "").replace(/\/+$/, "") || null;
84
+ const settingsFile = () => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "settings.json");
85
+ const portFlag = (port) => (port !== DEFAULT_PORT ? ` --port ${port}` : "");
86
+
87
+ /** --port when given, else the port `setup` used, else 8100. */
88
+ function effectivePort(o) {
89
+ if (o.portSet) return o.port;
90
+ try { return readState(settingsFile())?.port || o.port; } catch { return o.port; }
91
+ }
92
+
93
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
72
94
 
73
- /** Make sure a server answers; returns { base (for hooks + browser), started }. */
95
+ /** Start the local server in the background (or reuse it) and wait until healthy. */
96
+ async function startLocal(port, { ignoreLock = false } = {}) {
97
+ const pending = () => readPid(port) || (!ignoreLock && readLock(port));
98
+ const st = await probe(localBase(port));
99
+ if (st === "agentglow") return { started: false };
100
+ if (st === "other") throw new Error(`port ${port} is in use by something that is not AgentGlow. Pick another one: --port ${port + 1}`);
101
+ if (pending()) {
102
+ // another `start` (e.g. the SessionStart hook) is already bringing it up
103
+ console.error(`AgentGlow is starting on port ${port} ...`);
104
+ for (let t0 = Date.now(); Date.now() - t0 < 120000; await sleep(500)) {
105
+ if ((await probe(localBase(port), 1500)) === "agentglow") return { started: true };
106
+ if (!pending()) break;
107
+ }
108
+ if ((await probe(localBase(port), 1500)) === "agentglow") return { started: true };
109
+ }
110
+ const r = await startBackground({ port, version: VERSION });
111
+ return { started: true, pid: r.pid };
112
+ }
113
+
114
+ /** Make sure a server answers; returns { base (for hooks + browser), started }. Used by `claude` try mode. */
74
115
  async function ensureServer(port) {
75
116
  const remote = remoteUrl();
76
117
  if (remote) {
@@ -78,30 +119,155 @@ async function ensureServer(port) {
78
119
  if (st !== "agentglow") console.error(`agentglow: warning: ${remote}/live/health did not answer like AgentGlow; Claude Code will run anyway.`);
79
120
  return { base: remote, started: false };
80
121
  }
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 };
122
+ try {
123
+ const { started } = await startLocal(port);
124
+ return { base: baseUrl({ port }), started };
125
+ } catch (e) { fail(e.message); }
86
126
  }
87
127
 
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;
128
+ // ---------- setup / remove ----------
129
+
130
+ function nodeForHook() {
131
+ // a PATH node survives Homebrew / nvm patch upgrades better than the versioned execPath
132
+ return which("node") || process.execPath;
133
+ }
134
+
135
+ async function cmdSetup(o) {
136
+ const file = settingsFile();
137
+ const remote = remoteUrl();
138
+ const port = o.port;
139
+ const base = remote || baseUrl({ port });
140
+ let startCommand = null;
141
+ if (!remote) {
142
+ const script = installCliCopy({ srcCliDir: here, version: VERSION });
143
+ startCommand = startHookCommand({
144
+ port, version: VERSION, node: nodeForHook(), script, cacheDir: process.env.AGENTGLOW_CACHE_DIR,
145
+ });
146
+ }
147
+ const r = installSettingsFile(file, base, { startCommand, extra: remote ? {} : { port } });
148
+ if (!r.changed) console.log(`AgentGlow hooks are already in ${file} (${base}).`);
149
+ else console.log(`Added AgentGlow hooks + traces env to ${file}${r.backup ? ` (backup: ${r.backup})` : ""}.`);
150
+ if (r.skipped.length) console.log(`Left your own values for: ${r.skipped.join(", ")}`);
151
+ 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".');
152
+
153
+ const view = `${base}/neural`;
154
+ if (remote) {
155
+ if ((await probe(remote, 4000)) !== "agentglow") console.error(`agentglow: warning: ${remote}/live/health did not answer like AgentGlow.`);
156
+ } else {
157
+ try {
158
+ const s = await startLocal(port);
159
+ console.log(s.started ? `Started the AgentGlow server on port ${port}.` : `AgentGlow server already running on port ${port}.`);
160
+ } catch (e) {
161
+ console.error(`agentglow: ${e.message}`);
162
+ console.error(`The hooks are installed; start the server with: npx agentglow start --background${portFlag(port)}`);
163
+ return 1;
95
164
  }
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");
165
+ }
166
+ if (o.open) openBrowser(view);
167
+ console.log(`\nDone. Just run \`claude\` as usual. View: ${view} Undo: npx agentglow remove`);
168
+ return 0;
169
+ }
170
+
171
+ async function cmdRemove(o) {
172
+ const file = settingsFile();
173
+ const r = uninstallSettingsFile(file);
174
+ const st = r.state;
175
+ if (r.changed) {
176
+ console.log(`Removed the AgentGlow hooks + env from ${file}.`);
177
+ console.log(` Settings as they were just now: ${r.backup}`);
178
+ if (st?.backup) console.log(` Settings from before setup: ${st.backup} (restore: cp "${st.backup}" "${file}")`);
179
+ } else console.log(`No AgentGlow entries in ${file}.`);
180
+ const port = o.portSet ? o.port : st?.port || o.port;
181
+ if (!remoteUrl() && (st?.startHook || !st)) {
182
+ const s = await stopServer(port);
183
+ if (s === "stopped") console.log(`Stopped the AgentGlow server on port ${port}.`);
184
+ else if (s === "not-ours") console.log(`An AgentGlow server on port ${port} was not started by this CLI; left it running.`);
185
+ }
186
+ try { removeCliCopies(); } catch { /* ignore */ }
187
+ return 0;
188
+ }
189
+
190
+ // ---------- start / stop / status ----------
191
+
192
+ /** The SessionStart hook: no output (stdout would go into Claude's context), never fails, returns fast. */
193
+ async function cmdStartQuiet(o) {
194
+ setTimeout(() => process.exit(0), 3000).unref();
195
+ try {
196
+ if (remoteUrl()) return 0;
197
+ if ((await probe(localBase(o.port), 800)) !== "none") return 0; // running, or the port is taken
198
+ spawnStarter({ port: o.port, script: SELF });
199
+ } catch { /* never block Claude */ }
200
+ return 0;
201
+ }
202
+
203
+ async function cmdStart(o) {
204
+ if (o.quiet) return cmdStartQuiet(o);
205
+ const port = effectivePort(o);
206
+ if (!o.background) {
207
+ const st = await probe(localBase(port));
208
+ if (st === "agentglow") { console.log(`AgentGlow is already running at ${baseUrl({ port })}/neural`); return 0; }
209
+ if (st === "other") fail(`port ${port} is in use by something that is not AgentGlow. Pick another one: --port ${port + 1}`);
210
+ return serveForeground({ port, version: VERSION });
211
+ }
212
+ const starter = process.env.AGENTGLOW_STARTER === "1";
213
+ try {
214
+ const s = await startLocal(port, { ignoreLock: starter });
215
+ const pid = readPid(port)?.pid;
216
+ console.log(`AgentGlow ${s.started ? "started" : "is already running"} at ${baseUrl({ port })}/neural${pid ? ` (pid ${pid})` : ""}.`);
217
+ if (!starter) console.log(`Stop it with: npx agentglow stop${portFlag(port)}`);
104
218
  return 0;
219
+ } catch (e) {
220
+ console.error(`agentglow: ${e.message}`);
221
+ return 1;
222
+ } finally {
223
+ if (starter) clearLock(port);
224
+ }
225
+ }
226
+
227
+ async function cmdStatus(o) {
228
+ const file = settingsFile();
229
+ const port = effectivePort(o);
230
+ const remote = remoteUrl();
231
+ let state = null;
232
+ let installed = false;
233
+ let startHook = false;
234
+ try {
235
+ state = readState(file);
236
+ const s = JSON.parse(fs.readFileSync(file, "utf8"));
237
+ for (const groups of Object.values(s.hooks || {})) {
238
+ for (const g of Array.isArray(groups) ? groups : []) {
239
+ for (const h of g?.hooks || []) {
240
+ if (!isOurHook(h)) continue;
241
+ if (h.type === "command") startHook = true;
242
+ else installed = true;
243
+ }
244
+ }
245
+ }
246
+ } catch { /* no settings file */ }
247
+ const base = remote || state?.base || baseUrl({ port });
248
+ const h = await fetchText(base + "/live/health", 2000);
249
+ let health = null;
250
+ try { health = h && h.status === 200 ? JSON.parse(h.body) : null; } catch { /* not ours */ }
251
+ const pid = remote ? null : readPid(port)?.pid;
252
+ const starting = !remote && !health && readLock(port);
253
+ console.log(`agentglow CLI ${VERSION}`);
254
+ console.log(`server: ${health?.ok ? `healthy at ${base} (server ${health.version || "?"})` : starting ? `starting on port ${port}` : `not running at ${base}`}`);
255
+ if (!remote) console.log(`port: ${port}${pid ? ` pid ${pid} (started by this CLI)` : ""}`);
256
+ console.log(`hooks: ${installed ? `installed in ${file}${startHook ? " (server auto-starts with claude)" : ""}` : "not installed (run: npx agentglow setup)"}`);
257
+ if (health?.ok) console.log(`view: ${base}/neural`);
258
+ return 0;
259
+ }
260
+
261
+ // ---------- try mode ----------
262
+
263
+ async function cmdClaude(o) {
264
+ if (o.install) {
265
+ console.error("agentglow: `claude --install` is deprecated; use `npx agentglow setup`.");
266
+ return cmdSetup(o);
267
+ }
268
+ if (o.uninstall) {
269
+ console.error("agentglow: `claude --uninstall` is deprecated; use `npx agentglow remove`.");
270
+ return cmdRemove(o);
105
271
  }
106
272
 
107
273
  const claude = which("claude");
@@ -134,7 +300,7 @@ async function cmdClaude(o) {
134
300
  });
135
301
  try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
136
302
  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}` : ""}`);
303
+ console.error(`AgentGlow is still running at ${view}. Stop it with: npx agentglow stop${portFlag(o.port)}`);
138
304
  }
139
305
  return code;
140
306
  }
@@ -144,25 +310,31 @@ async function main() {
144
310
  if (!argv.length || ["-h", "--help", "help"].includes(argv[0])) { console.log(HELP); return 0; }
145
311
  if (["-v", "--version", "version"].includes(argv[0])) { console.log(VERSION); return 0; }
146
312
  const o = parseArgs(argv);
147
- if (o.help) { console.log(HELP); return 0; }
313
+ if (o.help && !o.quiet) { console.log(HELP); return 0; }
148
314
  switch (o.cmd) {
315
+ case "setup":
316
+ return cmdSetup(o);
317
+ case "remove":
318
+ case "uninstall":
319
+ return cmdRemove(o);
149
320
  case "claude":
150
321
  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
- }
322
+ case "start":
323
+ return cmdStart(o);
324
+ case "serve":
325
+ return cmdStart({ ...o, background: false, quiet: false });
326
+ case "status":
327
+ return cmdStatus(o);
157
328
  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}.`);
329
+ const port = effectivePort(o);
330
+ const r = await stopServer(port);
331
+ if (r === "stopped") console.log(`Stopped the AgentGlow server on port ${port}.`);
332
+ else if (r === "not-ours") console.log(`The AgentGlow server on port ${port} was not started by this CLI; stop it where it runs.`);
333
+ else console.log(`No AgentGlow server on port ${port}.`);
162
334
  return 0;
163
335
  }
164
336
  case "open": {
165
- const url = `${remoteUrl() || baseUrl({ port: o.port })}/neural`;
337
+ const url = `${remoteUrl() || baseUrl({ port: effectivePort(o) })}/neural`;
166
338
  openBrowser(url);
167
339
  console.log(url);
168
340
  return 0;
@@ -174,4 +346,7 @@ async function main() {
174
346
  }
175
347
  }
176
348
 
177
- main().then((code) => process.exit(code ?? 0), (e) => fail(e.message || String(e)));
349
+ main().then((code) => process.exit(code ?? 0), (e) => {
350
+ if (process.argv.includes("--quiet") || process.argv.includes("-q")) process.exit(0);
351
+ fail(e.message || String(e));
352
+ });
@@ -10,6 +10,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
10
  export const localBase = (port) => `http://127.0.0.1:${port}`;
11
11
  export const pidFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.pid`);
12
12
  export const logFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.log`);
13
+ export const lockFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.starting`);
14
+ const LOCK_TTL_MS = 180000;
13
15
 
14
16
  /** PyPI requirement for the server: same version as this npm package (or AGENTGLOW_PY_SPEC). */
15
17
  export function pySpecs(version, env = process.env) {
@@ -21,7 +23,7 @@ export function serveArgv(runner, spec, port) {
21
23
  return [...runner.args, "--from", spec, "agentglow", "serve", "--host", "127.0.0.1", "--port", String(port)];
22
24
  }
23
25
 
24
- function alive(pid) {
26
+ export function alive(pid) {
25
27
  try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
26
28
  }
27
29
 
@@ -87,10 +89,13 @@ export function killTree(pid) {
87
89
 
88
90
  /** Stop a server this CLI started. Returns "stopped" | "not-ours" | "none". */
89
91
  export async function stopServer(port) {
92
+ const starter = readLock(port);
93
+ if (starter) { killTree(starter.pid); clearLock(port); }
90
94
  const info = readPid(port);
91
95
  if (!info) {
92
96
  fs.rmSync(pidFile(port), { force: true });
93
- return (await probe(localBase(port), 1500)) === "agentglow" ? "not-ours" : "none";
97
+ if ((await probe(localBase(port), 1500)) === "agentglow") return "not-ours";
98
+ return starter ? "stopped" : "none";
94
99
  }
95
100
  killTree(info.pid);
96
101
  for (let i = 0; i < 20 && alive(info.pid); i++) await sleep(250);
@@ -99,6 +104,62 @@ export async function stopServer(port) {
99
104
  return "stopped";
100
105
  }
101
106
 
107
+ /** A detached starter (see spawnStarter) that is still preparing the server, or null. */
108
+ export function readLock(port) {
109
+ try {
110
+ const j = JSON.parse(fs.readFileSync(lockFile(port), "utf8"));
111
+ return j && alive(j.pid) && Date.now() - j.at < LOCK_TTL_MS ? j : null;
112
+ } catch { return null; }
113
+ }
114
+
115
+ /**
116
+ * Fire-and-forget start for the SessionStart hook: spawn `node <cli> start --background --port <p>` detached
117
+ * (it waits for health, falls back to the latest PyPI version and logs to the server log) and return at once.
118
+ * A lockfile keeps two sessions starting at the same moment from racing. Returns the starter pid or null.
119
+ */
120
+ export function spawnStarter({ port, script, node = process.execPath }) {
121
+ if (readPid(port) || readLock(port)) return null;
122
+ const dir = cacheDir();
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ const fd = fs.openSync(logFile(port), "a");
125
+ const child = spawn(node, [script, "start", "--background", "--port", String(port)], {
126
+ detached: true, stdio: ["ignore", fd, fd], windowsHide: true,
127
+ env: { ...process.env, AGENTGLOW_STARTER: "1" },
128
+ });
129
+ fs.closeSync(fd);
130
+ child.on("error", () => {});
131
+ child.unref();
132
+ fs.writeFileSync(lockFile(port), JSON.stringify({ pid: child.pid, at: Date.now() }));
133
+ return child.pid;
134
+ }
135
+
136
+ export function clearLock(port) {
137
+ fs.rmSync(lockFile(port), { force: true });
138
+ }
139
+
140
+ /**
141
+ * Copy this CLI (plain .mjs files, Node builtins only) to <cache>/cli/<version>/ so the SessionStart hook can run it
142
+ * with plain `node` instead of resolving npx every session. Returns the copied entry script.
143
+ */
144
+ export function installCliCopy({ srcCliDir, version }) {
145
+ const root = path.join(cacheDir(), "cli", version || "dev");
146
+ const tmp = `${root}.tmp-${process.pid}`;
147
+ fs.rmSync(tmp, { recursive: true, force: true });
148
+ fs.mkdirSync(path.join(tmp, "cli", "lib"), { recursive: true });
149
+ fs.copyFileSync(path.join(srcCliDir, "agentglow.mjs"), path.join(tmp, "cli", "agentglow.mjs"));
150
+ for (const f of fs.readdirSync(path.join(srcCliDir, "lib"))) {
151
+ if (f.endsWith(".mjs")) fs.copyFileSync(path.join(srcCliDir, "lib", f), path.join(tmp, "cli", "lib", f));
152
+ }
153
+ fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify({ name: "agentglow", version, type: "module" }) + "\n");
154
+ fs.rmSync(root, { recursive: true, force: true });
155
+ fs.renameSync(tmp, root);
156
+ return path.join(root, "cli", "agentglow.mjs");
157
+ }
158
+
159
+ export function removeCliCopies() {
160
+ fs.rmSync(path.join(cacheDir(), "cli"), { recursive: true, force: true });
161
+ }
162
+
102
163
  /** Run the server in the foreground (stdio inherited). Resolves with its exit code. */
103
164
  export async function serveForeground({ port, version, log = console.error }) {
104
165
  const runner = await findRunner({ log });
@@ -56,8 +56,35 @@ export function claudeSettings(base) {
56
56
  return { env: envBlock(base), hooks };
57
57
  }
58
58
 
59
+ // Marker in the SessionStart command hook that starts the server; recognises it on remove / re-setup.
60
+ export const START_MARK = "start --background --quiet";
61
+
62
+ const isOurHttpHook = (h) => !!h && h.type === "http" && typeof h.url === "string" && h.url.includes(HOOK_PATH);
63
+ const isOurStartHook = (h) =>
64
+ !!h && h.type === "command" && typeof h.command === "string" && h.command.includes("agentglow") && h.command.includes(START_MARK);
65
+
59
66
  export function isOurHook(h) {
60
- return !!h && h.type === "http" && typeof h.url === "string" && h.url.includes(HOOK_PATH);
67
+ return isOurHttpHook(h) || isOurStartHook(h);
68
+ }
69
+
70
+ const shq = (s) => `"${String(s).replace(/(["\\$`])/g, "\\$1")}"`;
71
+
72
+ /**
73
+ * Shell command for the SessionStart hook that makes sure the local server runs. Prints nothing (SessionStart stdout
74
+ * would land in Claude's context) and always exits 0. Calls the CLI copy in the cache directly (~30 ms) and falls
75
+ * back to the pinned npm version through npx (~0.5 s when cached). Windows without Git Bash runs it in PowerShell,
76
+ * so there it is npx only.
77
+ */
78
+ export function startHookCommand({ port, version, node, script, cacheDir: dir, platform = process.platform }) {
79
+ const args = `${START_MARK} --port ${port}`;
80
+ const npx = `npx -y agentglow${version ? `@${version}` : ""} ${args}`;
81
+ if (platform === "win32" || !node || !script) return npx;
82
+ const env = dir ? `AGENTGLOW_CACHE_DIR=${shq(dir)} ` : "";
83
+ return `${env}${shq(node)} ${shq(script)} ${args} || ${env}${npx}`;
84
+ }
85
+
86
+ export function startHookGroup(command) {
87
+ return { hooks: [{ type: "command", command, timeout: 10 }] };
61
88
  }
62
89
 
63
90
  /** Base URLs of our hooks found in a settings object (used to recognise our traces endpoint). */
@@ -66,7 +93,7 @@ export function ourBases(settings) {
66
93
  for (const groups of Object.values(settings?.hooks || {})) {
67
94
  if (!Array.isArray(groups)) continue;
68
95
  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)));
96
+ if (isOurHttpHook(h)) out.add(h.url.slice(0, h.url.indexOf(HOOK_PATH)));
70
97
  }
71
98
  }
72
99
  return [...out];
@@ -114,11 +141,13 @@ export function unmergeSettings(settings, state = null) {
114
141
  /**
115
142
  * Add our hooks + env to `settings` (idempotent: our previous entries are replaced, foreign ones kept).
116
143
  * Env keys the user already set to a different value are left alone and reported in `skipped`.
117
- * Returns { settings, state: { addedEnv, base }, skipped }.
144
+ * `startCommand` adds the SessionStart command hook that starts the local server (see startHookCommand).
145
+ * Returns { settings, state: { addedEnv, base, startHook }, skipped }.
118
146
  */
119
- export function mergeSettings(settings, base, state = null) {
147
+ export function mergeSettings(settings, base, state = null, { startCommand = null } = {}) {
120
148
  const s = unmergeSettings(settings, state);
121
149
  const ours = claudeSettings(base);
150
+ if (startCommand) ours.hooks.SessionStart.unshift(startHookGroup(startCommand));
122
151
  s.hooks = s.hooks && typeof s.hooks === "object" ? s.hooks : {};
123
152
  for (const [event, groups] of Object.entries(ours.hooks)) {
124
153
  s.hooks[event] = [...(Array.isArray(s.hooks[event]) ? s.hooks[event] : []), ...groups];
@@ -131,7 +160,7 @@ export function mergeSettings(settings, base, state = null) {
131
160
  else if (s.env[k] !== v) skipped.push(k);
132
161
  }
133
162
  if (!Object.keys(s.env).length) delete s.env;
134
- return { settings: s, state: { base, addedEnv }, skipped };
163
+ return { settings: s, state: { base, addedEnv, startHook: !!startCommand }, skipped };
135
164
  }
136
165
 
137
166
  // ---------- files ----------
@@ -157,29 +186,40 @@ function backup(file, now) {
157
186
  }
158
187
 
159
188
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
189
+ const sorted = (o) => Object.fromEntries(Object.entries(o || {}).sort(([a], [b]) => a.localeCompare(b)));
160
190
 
161
191
  /** Merge into a settings file. Returns { changed, backup, skipped, file }. */
162
- export function installSettingsFile(file, base, { now = Date.now() } = {}) {
192
+ export function installSettingsFile(file, base, { now = Date.now(), startCommand = null, extra = {} } = {}) {
163
193
  const before = readJson(file, {});
164
194
  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)) {
195
+ const merged = mergeSettings(before, base, st, { startCommand });
196
+ const { settings, skipped } = merged;
197
+ const state = { ...merged.state, ...extra };
198
+ // keep the first backup we made, so `remove` can point at the pre-AgentGlow settings
199
+ if (st?.backup && !state.backup) state.backup = st.backup;
200
+ if (same(before, settings) && st && same(sorted({ ...st, backup: state.backup }), sorted(state))) {
167
201
  return { changed: false, backup: null, skipped, file };
168
202
  }
169
203
  fs.mkdirSync(path.dirname(file), { recursive: true });
170
204
  const bak = same(before, settings) ? null : backup(file, now);
205
+ if (bak && !state.backup) state.backup = bak;
171
206
  if (!same(before, settings)) fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
172
207
  fs.writeFileSync(statePath(file), JSON.stringify(state, null, 2) + "\n");
173
208
  return { changed: true, backup: bak, skipped, file };
174
209
  }
175
210
 
176
- /** Remove our entries from a settings file. Returns { changed, backup, file }. */
211
+ /** Read the install sidecar (null when AgentGlow is not installed in `file`). */
212
+ export function readState(file) {
213
+ return readJson(statePath(file), null);
214
+ }
215
+
216
+ /** Remove our entries from a settings file. Returns { changed, backup, file, state }. */
177
217
  export function uninstallSettingsFile(file, { now = Date.now() } = {}) {
178
218
  const before = readJson(file, null);
179
219
  const st = readJson(statePath(file), null);
180
220
  if (before === null) {
181
221
  if (st) fs.rmSync(statePath(file), { force: true });
182
- return { changed: false, backup: null, file };
222
+ return { changed: false, backup: null, file, state: st };
183
223
  }
184
224
  const after = unmergeSettings(before, st);
185
225
  let bak = null;
@@ -188,5 +228,5 @@ export function uninstallSettingsFile(file, { now = Date.now() } = {}) {
188
228
  fs.writeFileSync(file, JSON.stringify(after, null, 2) + "\n");
189
229
  }
190
230
  fs.rmSync(statePath(file), { force: true });
191
- return { changed: !same(before, after), backup: bak, file };
231
+ return { changed: !same(before, after), backup: bak, file, state: st };
192
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentglow",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Live 3D views of agent systems (LangGraph, deepagents, any OpenTelemetry-instrumented agents) as a React component.",
5
5
  "keywords": [
6
6
  "claude-code",