@winding-labs/glow 0.1.0 → 0.1.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/lib/agents.mjs CHANGED
@@ -3,6 +3,10 @@
3
3
  // session when a message arrives
4
4
  // Codex ~/.codex/hooks.json hooks.Stop: the hook before the turn ends (≤ 15 s; it takes about a second)
5
5
  // Every other key in those files is kept as it was. A file that isn't valid JSON is never overwritten.
6
+ //
7
+ // And Glow's plugin in each (/glow:status, /glow:ignore, /glow:unignore), from the marketplace in ~/.glow/marketplace,
8
+ // added and removed with the agent's own CLI (`claude plugin …`, `codex plugin …`) so it writes its settings its way.
9
+ import { execFileSync } from 'node:child_process';
6
10
  import fs from 'node:fs';
7
11
  import os from 'node:os';
8
12
  import path from 'node:path';
@@ -70,6 +74,14 @@ function stripOurs(groups) {
70
74
  .filter((g) => !(g && typeof g === 'object' && Array.isArray(g.hooks) && g.hooks.length === 0));
71
75
  }
72
76
 
77
+ const hasHookIn = (file) => {
78
+ try {
79
+ return hasHook(readJson(file));
80
+ } catch {
81
+ return false;
82
+ }
83
+ };
84
+
73
85
  export const hasHook = (settings) => Array.isArray(settings?.hooks?.Stop) && settings.hooks.Stop.some((g) => Array.isArray(g?.hooks) && g.hooks.some(isOurs));
74
86
 
75
87
  /** The file's JSON ({} when missing). Throws when it exists but isn't a JSON object, so it's never clobbered. */
@@ -83,8 +95,9 @@ export function readJson(file) {
83
95
  }
84
96
 
85
97
  /**
86
- * Write next to the original with its mode kept; the first time, the original is copied to <file>.before-glow. A
87
- * symlinked settings file (dotfiles) is written through to its target, so the link stays a link.
98
+ * Write next to the original with its mode kept. The original is copied to <file>.before-glow once, and only while it
99
+ * has no Glow hook (so the backup is always the file as it was before Glow). A symlinked settings file (dotfiles) is
100
+ * written through to its target, so the link stays a link.
88
101
  */
89
102
  export function writeJson(file, value) {
90
103
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -93,7 +106,7 @@ export function writeJson(file, value) {
93
106
  if (fs.existsSync(target)) {
94
107
  mode = fs.statSync(target).mode & 0o777;
95
108
  const backup = `${file}.before-glow`;
96
- if (!fs.existsSync(backup)) fs.copyFileSync(target, backup);
109
+ if (!fs.existsSync(backup) && !hasHookIn(target)) fs.copyFileSync(target, backup);
97
110
  }
98
111
  writeFileAtomic(target, `${JSON.stringify(value, null, 2)}\n`, mode);
99
112
  }
@@ -120,3 +133,41 @@ export const installed = (id) => {
120
133
  return false;
121
134
  }
122
135
  };
136
+
137
+ // ---------- the /glow plugin ----------
138
+
139
+ export const marketplacePath = () => path.join(dir(), 'marketplace');
140
+ const PLUGIN = 'glow@glow';
141
+ const pluginSteps = (id) => ({
142
+ add: [['plugin', 'marketplace', 'add', marketplacePath()], id === 'codex' ? ['plugin', 'add', PLUGIN] : ['plugin', 'install', PLUGIN]],
143
+ remove: [[...(id === 'codex' ? ['plugin', 'remove'] : ['plugin', 'uninstall']), PLUGIN], ['plugin', 'marketplace', 'remove', 'glow']],
144
+ });
145
+
146
+ const agentCli = (id, args) =>
147
+ execFileSync(AGENTS[id].bin, args, { encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 });
148
+
149
+ /** Adds (or, run again, refreshes) the plugin in this agent. Throws with the agent's own message when it can't. */
150
+ export function addPlugin(id) {
151
+ for (const args of pluginSteps(id).add) {
152
+ try {
153
+ agentCli(id, args);
154
+ } catch (e) {
155
+ const why = e?.code === 'ENOENT' ? `${AGENTS[id].bin} isn’t on your PATH` : String(e?.stderr || e?.stdout || e?.message || '').trim().split('\n').pop();
156
+ throw new Error(why || 'it failed');
157
+ }
158
+ }
159
+ }
160
+
161
+ /** Removes the plugin and its marketplace from this agent; true when the plugin was there. Never throws. */
162
+ export function removePlugin(id) {
163
+ let removed = false;
164
+ for (const [i, args] of pluginSteps(id).remove.entries()) {
165
+ try {
166
+ agentCli(id, args);
167
+ if (i === 0) removed = true;
168
+ } catch {
169
+ // not there, or the agent isn't installed
170
+ }
171
+ }
172
+ return removed;
173
+ }
package/lib/core.mjs CHANGED
@@ -1,4 +1,5 @@
1
- // Shared by the installer (bin/glow.mjs) and the hook (~/.glow/hook.mjs). Plain Node 18+, no dependencies.
1
+ // Shared by the CLI (bin/glow.mjs), the hook (lib/hook.mjs) and the runner (lib/runner.mjs), all copied to ~/.glow.
2
+ // Plain Node 18+, no dependencies.
2
3
  //
3
4
  // Safety (docs/security/review-2026-11-02-agent-reports.md):
4
5
  // - The only host this ever talks to is Glow's API: https://app.meetglow.ai (or staging.meetglow.ai), read from
@@ -6,7 +7,8 @@
6
7
  // variable (the hook runs with an empty environment but HOME and PATH). A local API (http://localhost) only when
7
8
  // installed with --dev. Redirects are refused, so the key never follows one.
8
9
  // - The key (glowc_…) can only report sessions, read the linked repo names, and fetch messages for this computer.
9
- // - Nothing is sent from a repo that isn't linked to a product in Glow; the reply is masked for keys before it leaves.
10
+ // - Nothing is sent from a repo that isn't linked to a product in Glow, or that's ignored on this computer (`glow
11
+ // ignore`: checked before any request); the reply is masked for keys before it leaves.
10
12
  // - git runs without a shell, with hooks, fsmonitor and signature checks off and none of the caller's GIT_* variables,
11
13
  // so a repo (or its .git/config) can't make it run anything.
12
14
  import { execFileSync } from 'node:child_process';
@@ -15,7 +17,7 @@ import fs from 'node:fs';
15
17
  import os from 'node:os';
16
18
  import path from 'node:path';
17
19
 
18
- export const VERSION = '0.1.0';
20
+ export const VERSION = '0.1.5';
19
21
  export const DEFAULT_API = 'https://app.meetglow.ai';
20
22
  export const dir = () => path.join(os.homedir(), '.glow');
21
23
  export const configPath = () => path.join(dir(), 'config.json');
@@ -54,6 +56,9 @@ export function readConfig() {
54
56
  }
55
57
 
56
58
  /** Atomic, owner-only. */
59
+ /** The repos ignored on this computer (`glow ignore`): nothing is sent from them, linked or not. */
60
+ export const ignoredOf = (c) => (Array.isArray(c?.ignored) ? c.ignored.filter((r) => typeof r === 'string' && REPO_RE.test(r)) : []);
61
+
57
62
  export function writeConfig(c) {
58
63
  fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
59
64
  fs.chmodSync(dir(), 0o700);
@@ -185,6 +190,67 @@ export function mask(text) {
185
190
  return s.replace(LABELLED, '$1[masked]').replace(BEARER, '$1[masked]').replace(URL_CREDS, '$1[masked]@');
186
191
  }
187
192
 
193
+ // ---------- the request a reply answers ----------
194
+
195
+ const PROMPT_MAX = 4_000;
196
+ const TAIL_BYTES = 2 * 1024 * 1024;
197
+
198
+ /**
199
+ * The person's latest request in the agent's own transcript, masked, ≤ 4,000 characters; '' when there is none. Only a
200
+ * .jsonl under ~/.claude/projects (Claude Code) or ~/.codex/sessions (Codex) is read, and only its last 2 MB.
201
+ */
202
+ export function promptIn(file, home = os.homedir()) {
203
+ if (typeof file !== 'string' || !path.isAbsolute(file) || !file.endsWith('.jsonl')) return '';
204
+ const real = (p) => {
205
+ try {
206
+ return fs.realpathSync(p);
207
+ } catch {
208
+ return '';
209
+ }
210
+ };
211
+ const at = real(file);
212
+ const roots = [path.join(home, '.claude', 'projects'), path.join(home, '.codex', 'sessions')].map(real).filter(Boolean);
213
+ if (!at || !roots.some((r) => at.startsWith(r + path.sep))) return '';
214
+ let text = '';
215
+ try {
216
+ const fd = fs.openSync(at, 'r');
217
+ try {
218
+ const size = fs.fstatSync(fd).size;
219
+ const n = Math.min(size, TAIL_BYTES);
220
+ const buf = Buffer.alloc(n);
221
+ fs.readSync(fd, buf, 0, n, size - n);
222
+ text = buf.toString('utf8');
223
+ } finally {
224
+ fs.closeSync(fd);
225
+ }
226
+ } catch {
227
+ return '';
228
+ }
229
+ const lines = text.split('\n');
230
+ for (let k = lines.length - 1; k >= 0; k--) {
231
+ if (!lines[k].includes('"user')) continue;
232
+ let e;
233
+ try {
234
+ e = JSON.parse(lines[k]);
235
+ } catch {
236
+ continue;
237
+ }
238
+ const p = userText(e);
239
+ if (p) return mask(p).slice(0, PROMPT_MAX);
240
+ }
241
+ return '';
242
+ }
243
+
244
+ /** A typed request: Codex's user_message event, or a Claude Code user turn that isn't a tool result, meta or a command. */
245
+ function userText(e) {
246
+ if (e?.type === 'event_msg' && e.payload?.type === 'user_message') return typeof e.payload.message === 'string' ? e.payload.message.trim() : '';
247
+ if (e?.type !== 'user' || e.isMeta || e.isSidechain || e.message?.role !== 'user') return '';
248
+ const c = e.message.content;
249
+ const t = typeof c === 'string' ? c : Array.isArray(c) && !c.some((x) => x?.type === 'tool_result') ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
250
+ const s = String(t ?? '').trim();
251
+ return /^<(command-|local-command|system-reminder|task-notification|bash-)/.test(s) ? '' : s;
252
+ }
253
+
188
254
  // ---------- this computer ----------
189
255
 
190
256
  /** "Ilya’s MacBook Pro": the computer's own name (macOS), else the host name. Letters, digits, spaces, ’'._- only. */
@@ -202,3 +268,99 @@ export function computerName() {
202
268
  }
203
269
 
204
270
  export const sha = (s) => createHash('sha256').update(s).digest('hex');
271
+
272
+ /**
273
+ * What else this computer can reach, for the green tags beside it in Glow: GitHub (gh signed in), Chrome, Docker, Xcode.
274
+ * File checks only (no process is started), so the hook can run it on every report. Never a token, an account or a path:
275
+ * only which of these four are there.
276
+ */
277
+ export function tools(home = os.homedir(), env = process.env, platform = process.platform) {
278
+ const dirs = String(env.PATH ?? '').split(path.delimiter).filter(Boolean);
279
+ const has = (p) => {
280
+ try {
281
+ return fs.existsSync(p);
282
+ } catch {
283
+ return false;
284
+ }
285
+ };
286
+ const onPath = (bin) => dirs.some((d) => has(path.join(d, bin)));
287
+ const ghConfig = env.GH_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'gh');
288
+ const out = [];
289
+ if (onPath('gh') && has(path.join(ghConfig, 'hosts.yml'))) out.push('github');
290
+ if (platform === 'darwin' ? has('/Applications/Google Chrome.app') || has(path.join(home, 'Applications', 'Google Chrome.app')) : onPath('google-chrome') || onPath('chrome') || onPath('chromium')) out.push('chrome');
291
+ if (onPath('docker')) out.push('docker');
292
+ if (platform === 'darwin' && has('/Applications/Xcode.app')) out.push('xcode');
293
+ return out;
294
+ }
295
+
296
+ // ---------- sessions Glow starts here (`glow run`) ----------
297
+
298
+ /** The file that marks a session the runner started (by its Claude Code session id): the runner sends its reply to
299
+ * Glow itself, so the Stop hook leaves it alone. In ~/.glow/sessions, tidied after a day like the hook's locks. */
300
+ export const runMarker = (session) => path.join(dir(), 'sessions', `${sha(`run:${session}`).slice(0, 24)}.run`);
301
+
302
+ const clip = (s, n) => {
303
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
304
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
305
+ };
306
+
307
+ /**
308
+ * A command as a session's status shows it: the program and its subcommand only ("$ git status", "$ psql …"), never its
309
+ * arguments, options or environment, which is where passwords go. Leading `VAR=…`, `export`, `cd` and wrappers like
310
+ * `sudo` are skipped. (The same as @glow/core commandHead, which Glow applies again.)
311
+ */
312
+ export function commandHead(command) {
313
+ const parts = String(command ?? '').split(/&&|\|\||;|\||\n/).map((x) => x.trim().split(/\s+/).filter(Boolean));
314
+ const SKIP = new Set(['sudo', 'env', 'time', 'nohup', 'command', 'exec', 'nice']);
315
+ const SETUP = new Set(['export', 'cd', 'set', 'unset', 'source', '.', 'pushd', 'popd']);
316
+ let fallback = '';
317
+ for (const words of parts) {
318
+ let k = 0;
319
+ while (k < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k]) || SKIP.has(words[k]) || words[k].startsWith('-'))) k++; // VAR=x, sudo -E, env, …
320
+ const prog = (words[k] ?? '').split('/').pop().replace(/[^\w.+-]/g, '').slice(0, 40);
321
+ if (!prog) continue;
322
+ // A subcommand is a plain lower-case word (`git status`, `npm run`): never what echo or printf print, nor a word
323
+ // with digits or capitals (a password piped to `sudo -S` looks like one).
324
+ const sub = words[k + 1] && !['echo', 'printf'].includes(prog) && /^[a-z][a-z_-]{0,30}$/.test(words[k + 1]) ? words[k + 1] : '';
325
+ const more = words.length > k + (sub ? 2 : 1) || parts.length > 1;
326
+ const head = `$ ${prog}${sub ? ` ${sub}` : ''}${more ? ' …' : ''}`;
327
+ if (!SETUP.has(prog)) return head;
328
+ fallback ||= head;
329
+ }
330
+ return fallback || '$ (a command)';
331
+ }
332
+
333
+ /**
334
+ * What a session is doing, from the tool it just called, for its row in Glow: "$ bun test …", "Edit src/app.ts",
335
+ * "Grep useSession". Short, one line, paths inside the repo relative and the home folder as ~, keys masked before
336
+ * anything is cut. A command by its program only; never what's written or fetched; an MCP tool by its name only.
337
+ */
338
+ export function activityOf(name, input, cwd = '') {
339
+ const i = input && typeof input === 'object' ? input : {};
340
+ const home = os.homedir();
341
+ const where = (p) => {
342
+ const s = String(p ?? '');
343
+ if (cwd && s.startsWith(`${cwd}/`)) return s.slice(cwd.length + 1);
344
+ return home && s.startsWith(`${home}/`) ? `~/${s.slice(home.length + 1)}` : s;
345
+ };
346
+ // Masked whole, then cut: a key cut in half can't be told from text any more.
347
+ const safe = (v, n) => clip(mask(String(v ?? '').replace(/\s+/g, ' ')), n);
348
+ const tool = String(name ?? '').replace(/[^\w.:-]/g, '').slice(0, 60) || 'A tool';
349
+ let line;
350
+ if (tool === 'Bash') line = commandHead(i.command);
351
+ else if (['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) line = `${tool} ${safe(where(i.file_path ?? i.notebook_path), 140)}`;
352
+ else if (tool === 'Glob' || tool === 'Grep') line = `${tool} ${safe(i.pattern, 100)}`;
353
+ else if (tool === 'WebFetch') {
354
+ let host = '';
355
+ try {
356
+ host = new URL(String(i.url)).host;
357
+ } catch {
358
+ host = '';
359
+ }
360
+ line = `WebFetch ${host}`;
361
+ } else if (tool === 'WebSearch') line = `WebSearch ${safe(i.query, 100)}`;
362
+ else if (tool === 'Task' || tool === 'Agent') line = `Agent: ${safe(i.description, 100)}`;
363
+ else if (tool === 'TodoWrite') line = 'Updating its to-do list';
364
+ else line = tool;
365
+ return printable(line, 160).trim();
366
+ }
package/lib/hook.mjs CHANGED
@@ -7,11 +7,13 @@
7
7
  // {"decision":"block","reason":…}, which Codex takes as the next prompt.
8
8
  //
9
9
  // `npx @winding-labs/glow messages off` stops both: Glow then holds no message for this computer's sessions.
10
+ // A repo ignored here (`glow ignore`, /glow:ignore) sends nothing at all: the hook stops before any request. A session
11
+ // Glow started here (`glow run`, lib/runner.mjs) is the runner's to report.
10
12
  //
11
13
  // Quiet by design: whatever goes wrong, it exits 0 and the agent carries on. Nothing is written to a log.
12
14
  import fs from 'node:fs';
13
15
  import path from 'node:path';
14
- import { api, dir, gitContext, mask, prIn, printable, readConfig, repoOf, SESSION_RE, sha, writeConfig } from './core.mjs';
16
+ import { api, dir, gitContext, ignoredOf, mask, prIn, printable, promptIn, readConfig, repoOf, runMarker, SESSION_RE, sha, tools, writeConfig } from './core.mjs';
15
17
 
16
18
  const LISTEN_MS = 25 * 60_000;
17
19
  // GLOW_POLL_MS only speeds up the tests (timing, never where anything is sent).
@@ -34,9 +36,11 @@ async function main() {
34
36
  if (!cfg || cfg.off) return 0;
35
37
  const session = typeof input.session_id === 'string' ? input.session_id : '';
36
38
  if (!SESSION_RE.test(session)) return 0;
39
+ // A session Glow started here (`glow run`): the runner sends its reply itself.
40
+ if (fs.existsSync(runMarker(session))) return 0;
37
41
  const cwd = typeof input.cwd === 'string' && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
38
42
  const repo = repoOf(cwd);
39
- if (!repo || !(await linked(cfg, repo))) return 0;
43
+ if (!repo || ignoredOf(cfg).includes(repo) || !(await linked(cfg, repo))) return 0;
40
44
 
41
45
  const reply = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : '';
42
46
  const inbox = cfg.messages !== false;
@@ -44,15 +48,17 @@ async function main() {
44
48
  if (reply.trim()) {
45
49
  const ctx = gitContext(cwd);
46
50
  const pr = prIn(reply, repo);
51
+ // The request this reply answers (masked), so Glow can name the recipe you ran; `recipes off` leaves it out.
52
+ const prompt = cfg.recipes === false ? '' : promptIn(input.transcript_path);
47
53
  const res = await api(cfg, 'POST', '/api/v1/agent/reports', {
48
- agent, session_id: session, repo, ...(ctx.branch ? { branch: ctx.branch } : {}), message: mask(reply).slice(0, MESSAGE_MAX), commits: ctx.commits, ...(pr ? { pr_url: pr } : {}), inbox,
54
+ agent, session_id: session, repo, ...(ctx.branch ? { branch: ctx.branch } : {}), message: mask(reply).slice(0, MESSAGE_MAX), ...(prompt ? { prompt } : {}), commits: ctx.commits, ...(pr ? { pr_url: pr } : {}), inbox, tools: tools(),
49
55
  }, agent === 'codex' ? 6_000 : 10_000);
50
56
  if (res.status === 401 || res.status === 403) return 0;
51
57
  if (res.json?.skipped === 'repo_not_linked' && Array.isArray(res.json.repos)) saveRepos(cfg, res.json.repos);
52
58
  messages = inbox && Array.isArray(res.json?.messages) ? res.json.messages : [];
53
59
  }
54
60
  if (messages.length) return deliver(messages);
55
- if (agent === 'claude_code' && inbox) return listen(cfg, session);
61
+ if (agent === 'claude_code' && inbox) return listen(cfg, session, repo);
56
62
  return 0;
57
63
  }
58
64
 
@@ -98,9 +104,10 @@ const timeOf = (iso) => {
98
104
 
99
105
  /**
100
106
  * Claude Code, idle: ask Glow for a message every 15 s for up to 25 minutes. One listener per session: a newer reply
101
- * writes its own token to the lock and this one stops. Stops too when Claude Code itself has gone.
107
+ * writes its own token to the lock and this one stops. Stops too when Claude Code itself has gone, or when sending,
108
+ * messages or this repo are turned off here.
102
109
  */
103
- async function listen(cfg, session) {
110
+ async function listen(cfg, session, repo) {
104
111
  const locks = path.join(dir(), 'sessions');
105
112
  fs.mkdirSync(locks, { recursive: true, mode: 0o700 });
106
113
  const lock = path.join(locks, `${sha(`${agent}:${session}`).slice(0, 24)}.lock`);
@@ -115,7 +122,7 @@ async function listen(cfg, session) {
115
122
  await new Promise((r) => setTimeout(r, POLL_MS));
116
123
  if (!mine(lock, token) || !alive(parent)) return 0;
117
124
  const now = readConfig();
118
- if (!now || now.off || now.messages === false) return 0;
125
+ if (!now || now.off || now.messages === false || ignoredOf(now).includes(repo)) return 0;
119
126
  const res = await api(now, 'GET', q, undefined, 10_000);
120
127
  if (res.status === 401 || res.status === 403 || res.status === 404) return 0;
121
128
  const messages = Array.isArray(res.json?.messages) ? res.json.messages : [];