agentglow 0.2.5 → 0.2.6

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/cli/agentglow.mjs CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  startBackground, stopServer,
15
15
  } from "./lib/server.mjs";
16
16
  import { which } from "./lib/uv.mjs";
17
+ import { hasAutostart, installAutostart, launchdLogPath, loginPath, removeAutostart, stopLoginItem } from "./lib/autostart.mjs";
17
18
 
18
19
  const SELF = fileURLToPath(import.meta.url);
19
20
  const here = path.dirname(SELF);
@@ -31,6 +32,7 @@ const HELP = `agentglow ${VERSION}: watch Claude Code agents in 3D
31
32
  npx agentglow remove undo setup: remove the hooks and stop the server
32
33
  npx agentglow claude [-- <args>] try it without installing: one claude session with AgentGlow
33
34
 
35
+
34
36
  More: npx agentglow start [--background] [--port N] (serve = start in the foreground)
35
37
  Env: AGENTGLOW_URL (remote server), AGENTGLOW_API_KEY (ingest key), AGENTGLOW_CACHE_DIR
36
38
  `;
@@ -57,6 +59,8 @@ function parseArgs(argv) {
57
59
  else if (a === "--no-open") o.open = false;
58
60
  else if (a === "--install") o.install = true;
59
61
  else if (a === "--uninstall") o.uninstall = true;
62
+ else if (a === "--remove") o.remove = true;
63
+ else if (a === "--no-autostart") o.autostart = false;
60
64
  else if (a === "--background" || a === "-b") o.background = true;
61
65
  else if (a === "--quiet" || a === "-q") o.quiet = true;
62
66
  else if (a === "-h" || a === "--help") o.help = true;
@@ -154,9 +158,19 @@ async function cmdSetup(o) {
154
158
  if (remote) {
155
159
  if ((await probe(remote, 4000)) !== "agentglow") console.error(`agentglow: warning: ${remote}/live/health did not answer like AgentGlow.`);
156
160
  } else {
161
+ // Default: the OS owns the server (login item: launchd / systemd --user / Task Scheduler), so it is already
162
+ // running for every claude session, survives reboots and restarts on crash. The SessionStart hook installed
163
+ // above stays as a cheap safety net. If the login item can't be installed, the hook alone covers it.
164
+ // Login item by default on macOS/Linux only; Windows keeps per-session auto-start via the SessionStart hook
165
+ // (explicit `npx agentglow autostart` still registers one there).
166
+ const wantLogin = o.autostart === true || (o.autostart !== false && process.platform !== "win32");
167
+ const login = wantLogin ? installLoginItem(port) : null;
157
168
  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}.`);
169
+ if (login?.startsNow && (await waitHealthy(port, 120000))) console.log(`AgentGlow server is running on port ${port} (managed by ${login.method}).`);
170
+ else {
171
+ const s2 = await startLocal(port);
172
+ console.log(s2.started ? `Started the AgentGlow server on port ${port}.` : `AgentGlow server already running on port ${port}.`);
173
+ }
160
174
  } catch (e) {
161
175
  console.error(`agentglow: ${e.message}`);
162
176
  console.error(`The hooks are installed; start the server with: npx agentglow start --background${portFlag(port)}`);
@@ -168,6 +182,50 @@ async function cmdSetup(o) {
168
182
  return 0;
169
183
  }
170
184
 
185
+ /** Register the server as a login item (launchd / systemd --user / Task Scheduler or Startup folder).
186
+ * Returns { method, path, startsNow } or null if it could not be installed (setup then relies on the hook). */
187
+ function installLoginItem(port) {
188
+ const script = installCliCopy({ srcCliDir: here, version: VERSION });
189
+ const node = nodeForHook() || process.execPath;
190
+ const command = startHookCommand({ port, version: VERSION, node, script, cacheDir: process.env.AGENTGLOW_CACHE_DIR });
191
+ // macOS/Linux: the supervisor runs the server in the foreground (argv, no shell); Windows keeps the shell command
192
+ const argv = [node, script, "start", "--port", String(port)];
193
+ const env = { PATH: loginPath({ node }), HOME: os.homedir() };
194
+ for (const k of ["AGENTGLOW_CACHE_DIR", "AGENTGLOW_API_KEY", "AGENTGLOW_INGEST_KEY", "AGENTGLOW_SECRET", "AGENTGLOW_PY_SPEC"]) {
195
+ if (process.env[k]) env[k] = process.env[k];
196
+ }
197
+ try {
198
+ const r = installAutostart({ command, argv, env });
199
+ const logs = process.platform === "darwin" ? launchdLogPath() : process.platform === "win32" ? null : "journalctl --user -u agentglow";
200
+ console.log(`Server starts at login via ${r.method}${logs ? ` (logs: ${logs})` : ""}.`);
201
+ return { ...r, startsNow: process.platform !== "win32" };
202
+ } catch (e) {
203
+ console.error(`agentglow: note: could not register a login item (${e.message}); the server will start with each claude session instead.`);
204
+ return null;
205
+ }
206
+ }
207
+
208
+ async function waitHealthy(port, ms) {
209
+ const until = Date.now() + ms;
210
+ let said = false;
211
+ while (Date.now() < until) {
212
+ if ((await probe(localBase(port), 1500)) === "agentglow") return true;
213
+ if (!said && Date.now() > until - ms + 4000) { console.log("First start downloads Python + the server (~30-60s) ..."); said = true; }
214
+ await new Promise((r) => setTimeout(r, 1000));
215
+ }
216
+ return false;
217
+ }
218
+
219
+ /** `autostart` = setup (which now includes the login item); `autostart --remove` = drop just the login item. */
220
+ async function cmdAutostart(o) {
221
+ if (o.remove) {
222
+ removeAutostart();
223
+ console.log(`Removed the AgentGlow login item. The hooks stay; the server now starts with each claude session.`);
224
+ return 0;
225
+ }
226
+ return cmdSetup({ ...o, autostart: true });
227
+ }
228
+
171
229
  async function cmdRemove(o) {
172
230
  const file = settingsFile();
173
231
  const r = uninstallSettingsFile(file);
@@ -178,11 +236,17 @@ async function cmdRemove(o) {
178
236
  if (st?.backup) console.log(` Settings from before setup: ${st.backup} (restore: cp "${st.backup}" "${file}")`);
179
237
  } else console.log(`No AgentGlow entries in ${file}.`);
180
238
  const port = o.portSet ? o.port : st?.port || o.port;
181
- if (!remoteUrl() && (st?.startHook || !st)) {
239
+ const login = hasAutostart();
240
+ if (!remoteUrl() && !login && (st?.startHook || !st)) {
182
241
  const s = await stopServer(port);
183
242
  if (s === "stopped") console.log(`Stopped the AgentGlow server on port ${port}.`);
184
243
  else if (s === "not-ours") console.log(`An AgentGlow server on port ${port} was not started by this CLI; left it running.`);
185
244
  }
245
+ if (login) { // `remove` undoes everything, incl. the login item (it points at the CLI copy removed below)
246
+ removeAutostart();
247
+ await stopServer(port); // in case a session-started server is also running
248
+ console.log(`Removed the AgentGlow login item and stopped its server.`);
249
+ }
186
250
  try { removeCliCopies(); } catch { /* ignore */ }
187
251
  return 0;
188
252
  }
@@ -254,6 +318,7 @@ async function cmdStatus(o) {
254
318
  console.log(`server: ${health?.ok ? `healthy at ${base} (server ${health.version || "?"})` : starting ? `starting on port ${port}` : `not running at ${base}`}`);
255
319
  if (!remote) console.log(`port: ${port}${pid ? ` pid ${pid} (started by this CLI)` : ""}`);
256
320
  console.log(`hooks: ${installed ? `installed in ${file}${startHook ? " (server auto-starts with claude)" : ""}` : "not installed (run: npx agentglow setup)"}`);
321
+ if (hasAutostart()) console.log(`login: server starts at login and restarts on crash`);
257
322
  if (health?.ok) console.log(`view: ${base}/neural`);
258
323
  return 0;
259
324
  }
@@ -314,6 +379,8 @@ async function main() {
314
379
  switch (o.cmd) {
315
380
  case "setup":
316
381
  return cmdSetup(o);
382
+ case "autostart":
383
+ return cmdAutostart(o);
317
384
  case "remove":
318
385
  case "uninstall":
319
386
  return cmdRemove(o);
@@ -328,6 +395,10 @@ async function main() {
328
395
  case "stop": {
329
396
  const port = effectivePort(o);
330
397
  const r = await stopServer(port);
398
+ if (r === "not-ours" && hasAutostart() && stopLoginItem()) {
399
+ console.log(`Stopped the AgentGlow server on port ${port} (it starts again at next login or with the next claude session).`);
400
+ return 0;
401
+ }
331
402
  if (r === "stopped") console.log(`Stopped the AgentGlow server on port ${port}.`);
332
403
  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
404
  else console.log(`No AgentGlow server on port ${port}.`);
@@ -0,0 +1,159 @@
1
+ // OS-native "start AgentGlow at login" (installed by `agentglow setup`): the server is always already running for
2
+ // every claude session, survives reboots and is restarted on crash. macOS/Linux run it in the foreground under
3
+ // launchd/systemd (argv); Windows uses Task Scheduler (or the Startup folder) with the hook's shell command.
4
+ import { execFileSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ export const TASK_NAME = "AgentGlow";
10
+ const LABEL = "com.agentglow.server";
11
+
12
+ export const plistPath = (home = os.homedir()) => path.join(home, "Library", "LaunchAgents", `${LABEL}.plist`);
13
+ export const systemdUnitPath = (home = os.homedir()) =>
14
+ path.join(home, ".config", "systemd", "user", "agentglow.service");
15
+ export const startupScriptPath = (home = os.homedir()) =>
16
+ path.join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "AgentGlow.vbs");
17
+
18
+ function winTaskCommand(command) {
19
+ // schtasks runs /tr through cmd.exe already; keep it a single quoted string, inner quotes escaped.
20
+ return `cmd /c "${command.replace(/"/g, '\\"')}"`;
21
+ }
22
+
23
+ // Runs hidden (no console flash) at login - no special privilege needed, just write access to the user's own
24
+ // Startup folder, which (unlike Task Scheduler) corporate Group Policy essentially never locks down. Wrapped in
25
+ // cmd /c like the Task Scheduler path (not run as a bare program) since `command` may use shell syntax (the "||
26
+ // fallback to npx" from startHookCommand), which WshShell.Run can't interpret on its own.
27
+ function startupVbs(command) {
28
+ const esc = winTaskCommand(command).replace(/"/g, '""');
29
+ return `Set WshShell = CreateObject("WScript.Shell")\r\nWshShell.Run "${esc}", 0, False\r\n`;
30
+ }
31
+
32
+ const xml = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
33
+
34
+ export const launchdLogPath = (home = os.homedir()) => path.join(home, "Library", "Logs", "agentglow.log");
35
+
36
+ /** PATH for login-time jobs: launchd/systemd start with a bare PATH, so add where uv/uvx/node usually live. */
37
+ export function loginPath({ home = os.homedir(), node = process.execPath, base = process.env.PATH || "" } = {}) {
38
+ const dirs = [path.dirname(node), path.join(home, ".local", "bin"), path.join(home, ".cargo", "bin"),
39
+ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", ...base.split(path.delimiter)];
40
+ return [...new Set(dirs.filter(Boolean))].join(path.delimiter);
41
+ }
42
+
43
+ // The server runs in the FOREGROUND under launchd/systemd (not `start --background`): a job whose process exits
44
+ // right away gets its spawned children reaped, and a supervised foreground process can be restarted on crash.
45
+ // `start` exits 0 when a server is already up on the port, so KeepAlive {SuccessfulExit: false} never loops.
46
+ export function launchdPlist({ argv, env = {}, home = os.homedir() }) {
47
+ const args = argv.map((a) => `<string>${xml(a)}</string>`).join("");
48
+ const envs = Object.entries(env).map(([k, v]) => `<key>${xml(k)}</key><string>${xml(v)}</string>`).join("");
49
+ const log = xml(launchdLogPath(home));
50
+ return `<?xml version="1.0" encoding="UTF-8"?>
51
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
52
+ <plist version="1.0"><dict>
53
+ <key>Label</key><string>${LABEL}</string>
54
+ <key>ProgramArguments</key><array>${args}</array>
55
+ <key>EnvironmentVariables</key><dict>${envs}</dict>
56
+ <key>RunAtLoad</key><true/>
57
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
58
+ <key>ThrottleInterval</key><integer>10</integer>
59
+ <key>ProcessType</key><string>Background</string>
60
+ <key>StandardOutPath</key><string>${log}</string>
61
+ <key>StandardErrorPath</key><string>${log}</string>
62
+ </dict></plist>
63
+ `;
64
+ }
65
+
66
+ export function systemdUnit({ argv, env = {} }) {
67
+ const q = (a) => (/[\s"\\]/.test(a) ? JSON.stringify(a) : a);
68
+ const envLines = Object.entries(env).map(([k, v]) => `Environment=${q(`${k}=${v}`)}`).join("\n");
69
+ return `[Unit]
70
+ Description=AgentGlow server
71
+
72
+ [Service]
73
+ ExecStart=${argv.map(q).join(" ")}
74
+ ${envLines}
75
+ Restart=on-failure
76
+ RestartSec=10
77
+
78
+ [Install]
79
+ WantedBy=default.target
80
+ `;
81
+ }
82
+
83
+ /** Install an OS-level "run this command at login" entry. Returns { method, path? }. Throws on failure. */
84
+ export function installAutostart({ command, argv, env = {}, platform = process.platform, home = os.homedir() } = {}) {
85
+ if (platform === "win32") {
86
+ try {
87
+ execFileSync("schtasks", ["/create", "/tn", TASK_NAME, "/tr", winTaskCommand(command), "/sc", "onlogon", "/rl", "limited", "/f"],
88
+ { stdio: "pipe" });
89
+ return { method: "Task Scheduler", name: TASK_NAME };
90
+ } catch (e) {
91
+ // Task Scheduler creation is commonly blocked by corporate Group Policy even for a user's own tasks;
92
+ // fall back to a Startup-folder launcher, which only needs normal write access to the user's own profile.
93
+ const file = startupScriptPath(home);
94
+ fs.mkdirSync(path.dirname(file), { recursive: true });
95
+ fs.writeFileSync(file, startupVbs(command));
96
+ return { method: "Startup folder (Task Scheduler was blocked)", path: file };
97
+ }
98
+ }
99
+ if (platform === "darwin") {
100
+ const file = plistPath(home);
101
+ fs.mkdirSync(path.dirname(file), { recursive: true });
102
+ fs.mkdirSync(path.dirname(launchdLogPath(home)), { recursive: true });
103
+ fs.writeFileSync(file, launchdPlist({ argv, env, home }));
104
+ try { execFileSync("launchctl", ["unload", file], { stdio: "pipe" }); } catch { /* wasn't loaded */ }
105
+ execFileSync("launchctl", ["load", "-w", file], { stdio: "pipe" }); // RunAtLoad: starts it right now too
106
+ return { method: "launchd", path: file };
107
+ }
108
+ // linux and other unix: systemd --user
109
+ const file = systemdUnitPath(home);
110
+ fs.mkdirSync(path.dirname(file), { recursive: true });
111
+ fs.writeFileSync(file, systemdUnit({ argv, env }));
112
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
113
+ execFileSync("systemctl", ["--user", "enable", "--now", "agentglow"], { stdio: "pipe" });
114
+ return { method: "systemd --user", path: file };
115
+ }
116
+
117
+ /** Undo installAutostart(). Best-effort: never throws, so `remove` always completes. */
118
+ export function removeAutostart({ platform = process.platform, home = os.homedir() } = {}) {
119
+ if (platform === "win32") {
120
+ try { execFileSync("schtasks", ["/delete", "/tn", TASK_NAME, "/f"], { stdio: "pipe" }); } catch { /* not installed */ }
121
+ try { fs.rmSync(startupScriptPath(home), { force: true }); } catch { /* ignore */ }
122
+ return;
123
+ }
124
+ if (platform === "darwin") {
125
+ const file = plistPath(home);
126
+ try { execFileSync("launchctl", ["unload", file], { stdio: "pipe" }); } catch { /* wasn't loaded */ }
127
+ try { fs.rmSync(file, { force: true }); } catch { /* ignore */ }
128
+ return;
129
+ }
130
+ try { execFileSync("systemctl", ["--user", "disable", "--now", "agentglow"], { stdio: "pipe" }); } catch { /* not installed */ }
131
+ try { fs.rmSync(systemdUnitPath(home), { force: true }); } catch { /* ignore */ }
132
+ }
133
+
134
+ /** Stop the login-item server for now (it starts again at next login / next claude session). Never throws. */
135
+ export function stopLoginItem({ platform = process.platform } = {}) {
136
+ try {
137
+ if (platform === "darwin") execFileSync("launchctl", ["stop", LABEL], { stdio: "pipe" });
138
+ else if (platform === "win32") execFileSync("schtasks", ["/end", "/tn", TASK_NAME], { stdio: "pipe" });
139
+ else execFileSync("systemctl", ["--user", "stop", "agentglow"], { stdio: "pipe" });
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /** Best-effort check: is the autostart entry currently installed? Never throws. */
147
+ export function hasAutostart({ platform = process.platform, home = os.homedir() } = {}) {
148
+ try {
149
+ if (platform === "win32") {
150
+ if (fs.existsSync(startupScriptPath(home))) return true;
151
+ execFileSync("schtasks", ["/query", "/tn", TASK_NAME], { stdio: "pipe" });
152
+ return true;
153
+ }
154
+ if (platform === "darwin") return fs.existsSync(plistPath(home));
155
+ return fs.existsSync(systemdUnitPath(home));
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
@@ -164,21 +164,25 @@ export function removeCliCopies() {
164
164
  export async function serveForeground({ port, version, log = console.error }) {
165
165
  const runner = await findRunner({ log });
166
166
  const specs = pySpecs(version);
167
+ let stopping = false;
167
168
  for (const [i, spec] of specs.entries()) {
168
169
  const t0 = Date.now();
169
170
  const code = await new Promise((resolve) => {
170
171
  const child = spawn(runner.cmd, serveArgv(runner, spec, port), { stdio: "inherit" });
171
- const fwd = (sig) => () => child.kill(sig);
172
+ // A requested stop (Ctrl-C, `launchctl stop`, `systemctl stop`) must exit 0, or a supervisor with
173
+ // "restart unless it exited successfully" (launchd KeepAlive/SuccessfulExit) would bring it right back.
174
+ const fwd = (sig) => () => { stopping = true; child.kill(sig); };
172
175
  const onInt = fwd("SIGINT"), onTerm = fwd("SIGTERM");
173
176
  process.on("SIGINT", onInt);
174
177
  process.on("SIGTERM", onTerm);
175
178
  child.on("exit", (c, sig) => {
176
179
  process.off("SIGINT", onInt);
177
180
  process.off("SIGTERM", onTerm);
178
- resolve(c ?? (sig ? 130 : 1));
181
+ resolve(stopping ? 0 : (c ?? (sig ? 130 : 1)));
179
182
  });
180
183
  });
181
184
  // a pinned version missing on PyPI fails fast; fall back to latest once
185
+ if (stopping) return 0;
182
186
  if (code !== 0 && i < specs.length - 1 && Date.now() - t0 < 30000) {
183
187
  log(`${spec} failed, retrying with the latest agentglow ...`);
184
188
  continue;