@winding-labs/glow 0.1.1 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/glow.mjs CHANGED
@@ -3,20 +3,32 @@
3
3
  // session in a repo linked to a product posts what it did to that product's channel, and Glow can message it back.
4
4
  //
5
5
  // npx @winding-labs/glow set up: find the agents, pair (approve in Glow, type the code it shows), add the hooks
6
- // npx @winding-labs/glow status what's set up, and the repos that report
6
+ // npx @winding-labs/glow status what's set up, what this folder sends, and the repos that report
7
+ // npx @winding-labs/glow ignore [repo] never send from this repo (or a folder / owner/name) on this computer
8
+ // npx @winding-labs/glow unignore [repo] undo that
7
9
  // npx @winding-labs/glow off | on stop / start sending from this computer
8
10
  // npx @winding-labs/glow messages off stop messages from Glow reaching this computer's agents (on: again)
9
- // npx @winding-labs/glow uninstall remove the hooks, revoke this computer's key, delete ~/.glow
10
- import { execFile } from 'node:child_process';
11
+ // npx @winding-labs/glow recipes off stop sending the request each reply answers (Glow learns recipes from it)
12
+ // npx @winding-labs/glow run [folder…] let Glow start Claude Code sessions here, in the linked repos under these
13
+ // folders (default: this one), while it runs (--background; run stop)
14
+ // npx @winding-labs/glow phone the Glow phone this computer trusts (what it signs runs here), and Glow's word
15
+ // npx @winding-labs/glow phone trust trust the phone Glow names now instead (after you made a new one yours)
16
+ // npx @winding-labs/glow uninstall remove the hooks and plugins, revoke this computer's key, delete ~/.glow
17
+ //
18
+ // Setup copies this package to ~/.glow (bin, lib, marketplace) with two shims: ~/.glow/hook, which the agents' Stop
19
+ // hooks run, and ~/.glow/glow, this CLI, which the /glow:status, /glow:ignore, /glow:unignore and /glow:run plugin
20
+ // commands run.
21
+ import { execFile, spawn } from 'node:child_process';
11
22
  import readline from 'node:readline';
12
23
  import fs from 'node:fs';
13
24
  import os from 'node:os';
14
25
  import path from 'node:path';
15
26
  import { fileURLToPath } from 'node:url';
16
- import { AGENTS, detect, install, installed, shimPath, shq, uninstall } from '../lib/agents.mjs';
17
- import { api, apiBase, apiOk, computerName, DEFAULT_API, dir, printable, readConfig, repoOf, VERSION, writeConfig } from '../lib/core.mjs';
27
+ import { addPlugin, AGENTS, detect, install, installed, removePlugin, shimPath, shq, uninstall } from '../lib/agents.mjs';
28
+ import { api, apiBase, apiOk, computerName, DEFAULT_API, dir, fingerprintText, ignoredOf, phoneOf, pinPhone, printable, readConfig, REPO_RE, repoOf, VERSION, writeConfig } from '../lib/core.mjs';
29
+ import { inRunnerSession, logPath, runnerState, SESSION_ENV, startRunner } from '../lib/runner.mjs';
18
30
 
19
- const LIB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib');
31
+ const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
20
32
  const tty = process.stdout.isTTY;
21
33
  const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
22
34
  const bold = (s) => c(1, s);
@@ -31,11 +43,25 @@ const fail = (s) => {
31
43
  const HELP = `Glow ${VERSION}: send what your coding agents do to your Glow channels.
32
44
 
33
45
  npx @winding-labs/glow set up this computer (finds Claude Code and Codex, pairs with Glow, adds hooks)
34
- npx @winding-labs/glow status what's set up and which repos report
46
+ npx @winding-labs/glow status what's set up, what this folder sends, which repos report
47
+ npx @winding-labs/glow ignore [repo] never send from this repo on this computer (or a folder, or owner/name)
48
+ npx @winding-labs/glow unignore [repo] let it send again
35
49
  npx @winding-labs/glow off | on stop or start sending from this computer
36
50
  npx @winding-labs/glow messages off stop messages from Glow reaching your agents here (messages on: again)
51
+ npx @winding-labs/glow recipes off don't send your requests, so Glow learns no recipes from them (recipes on: again)
52
+ npx @winding-labs/glow run [folder…] let Glow start Claude Code sessions here (Agents tab, your queue), in the linked
53
+ repos checked out under these folders (default: this one), while it runs. Each
54
+ runs like your own claude --dangerously-skip-permissions, with your Claude Code
55
+ model and effort, in a git worktree of its own
56
+ --background keep it running after this terminal closes (log: ~/.glow/runner.log)
57
+ --repo owner/name[=path] only this repo (repeat for more); =path picks its checkout
58
+ npx @winding-labs/glow run stop stop it, and the sessions it runs
59
+ npx @winding-labs/glow phone the Glow phone this computer trusts: only what it signs (after Face ID) runs here
60
+ npx @winding-labs/glow phone trust trust the phone Glow names now instead (once you've made a new phone yours)
37
61
  npx @winding-labs/glow uninstall remove the hooks and revoke this computer's key
38
62
 
63
+ In Claude Code: /glow:status, /glow:ignore, /glow:unignore, /glow:run (in Codex: $glow:status, $glow:ignore, …)
64
+
39
65
  Options for setup:
40
66
  --claude, --codex only these agents (default: every one found)
41
67
  --new pair again, as a new computer
@@ -44,11 +70,19 @@ Options for setup:
44
70
  `;
45
71
 
46
72
  function parse(argv) {
47
- const o = { cmd: 'install', agents: [], api: DEFAULT_API, dev: false, open: true, fresh: false, name: '', messages: null };
73
+ const o = { cmd: 'install', agents: [], api: DEFAULT_API, dev: false, open: true, fresh: false, name: '', messages: null, recipes: null, target: '', roots: [], background: false, runStop: false, allow: new Map() };
74
+ // One command per call: `glow ignore x run …` must not become `run` (a skill allowed to run `glow ignore` can't
75
+ // reach any other command).
76
+ let named = '', asked = '';
77
+ const command = (c) => {
78
+ if (named) fail(`One command at a time (${named}, then ${c}).`);
79
+ named = c;
80
+ o.cmd = c;
81
+ };
48
82
  for (let i = 0; i < argv.length; i++) {
49
83
  const a = argv[i];
50
- if (a === '--help' || a === '-h' || a === 'help') o.cmd = 'help';
51
- else if (a === '--version' || a === '-v') o.cmd = 'version';
84
+ if (a === '--help' || a === '-h' || a === 'help') asked = 'help';
85
+ else if (a === '--version' || a === '-v') asked = 'version';
52
86
  else if (a === '--claude') o.agents.push('claude_code');
53
87
  else if (a === '--codex') o.agents.push('codex');
54
88
  else if (a === '--new') o.fresh = true;
@@ -57,11 +91,35 @@ function parse(argv) {
57
91
  else if (a === '--api') o.api = argv[++i] ?? '';
58
92
  else if (a === '--name') o.name = argv[++i] ?? '';
59
93
  else if (a === 'messages' && ['on', 'off'].includes(argv[i + 1])) {
60
- o.cmd = 'messages';
94
+ command('messages');
61
95
  o.messages = argv[++i] === 'on';
62
- } else if (['install', 'setup', 'status', 'off', 'on', 'uninstall'].includes(a)) o.cmd = a === 'setup' ? 'install' : a;
96
+ } else if (a === 'recipes' && ['on', 'off'].includes(argv[i + 1])) {
97
+ command('recipes');
98
+ o.recipes = argv[++i] === 'on';
99
+ } else if (a === 'ignore' || a === 'unignore') {
100
+ command(a);
101
+ if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) o.target = argv[++i];
102
+ } else if (a === 'phone') {
103
+ command('phone');
104
+ if (argv[i + 1] === 'trust') o.trust = !!argv[++i];
105
+ } else if (a === 'run') {
106
+ command('run');
107
+ while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
108
+ const x = argv[++i];
109
+ if (x === 'stop' && !o.roots.length) o.runStop = true;
110
+ else o.roots.push(x);
111
+ }
112
+ } else if (a === '--background' && o.cmd === 'run') o.background = true;
113
+ else if (a === '--repo' && o.cmd === 'run') {
114
+ const [name, at] = String(argv[++i] ?? '').split(/=(.*)/s);
115
+ const repo = name.toLowerCase();
116
+ if (!REPO_RE.test(repo)) fail('--repo is owner/name, or owner/name=path to pick its checkout.');
117
+ o.allow.set(repo, at ? path.resolve(at.replace(/^~(?=$|\/)/, os.homedir())) : null);
118
+ }
119
+ else if (['install', 'setup', 'status', 'off', 'on', 'uninstall'].includes(a)) command(a === 'setup' ? 'install' : a);
63
120
  else fail(`Unknown option ${a}. Run npx @winding-labs/glow --help`);
64
121
  }
122
+ if (asked) o.cmd = asked; // help and version win, whatever else is there
65
123
  if (!apiOk(o.api, o.dev)) fail(`${printable(o.api)} isn’t Glow. The API must be https://app.meetglow.ai (or localhost with --dev).`);
66
124
  return o;
67
125
  }
@@ -74,8 +132,15 @@ async function main() {
74
132
  if (o.cmd === 'help') return console.log(HELP);
75
133
  if (o.cmd === 'version') return console.log(VERSION);
76
134
  if (o.cmd === 'status') return status();
135
+ if (o.cmd === 'phone' && !o.trust) return phone(false);
136
+ // A session Glow started here can't change how Glow reaches this computer (stop the runner, turn sending off, …).
137
+ if (inRunnerSession()) fail('Not from a session Glow started: run it yourself, in a terminal.');
77
138
  if (o.cmd === 'off' || o.cmd === 'on') return toggle(o.cmd === 'off');
78
139
  if (o.cmd === 'messages') return messages(o.messages);
140
+ if (o.cmd === 'recipes') return recipes(o.recipes);
141
+ if (o.cmd === 'ignore' || o.cmd === 'unignore') return ignore(o.target, o.cmd === 'ignore');
142
+ if (o.cmd === 'run') return run(o);
143
+ if (o.cmd === 'phone') return phone(true);
79
144
  if (o.cmd === 'uninstall') return remove();
80
145
  return setup(o);
81
146
  }
@@ -98,8 +163,10 @@ async function setup(o) {
98
163
  let info = null;
99
164
  if (cfg) {
100
165
  const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
101
- if (res.status === 200) info = res.json;
102
- else if (res.status === 401) {
166
+ if (res.status === 200) {
167
+ info = res.json;
168
+ cfg = pinPhone(cfg, res.json?.phone, ok);
169
+ } else if (res.status === 401) {
103
170
  warn('This computer’s key no longer works (it was removed in Glow). Pairing again.');
104
171
  cfg = null;
105
172
  } else if (res.status === 403) warn('This computer is paused in Glow. Resume it there (Connections › Coding agents).');
@@ -118,7 +185,7 @@ async function setup(o) {
118
185
  writeConfig({ ...cfg, agents: [...new Set([...(cfg.agents ?? []), ...agents])], ...(info?.repos ? { repos: info.repos, repos_at: Date.now() } : {}), version: VERSION });
119
186
  }
120
187
 
121
- copyHook();
188
+ copyFiles();
122
189
  for (const a of agents) {
123
190
  try {
124
191
  const file = install(a);
@@ -126,18 +193,29 @@ async function setup(o) {
126
193
  } catch (e) {
127
194
  warn(`${AGENTS[a].label}: couldn’t update ${tilde(path.join(os.homedir(), ...AGENTS[a].file))} (${e.message}). Fix it, then run this again.`);
128
195
  }
196
+ try {
197
+ addPlugin(a);
198
+ const x = a === 'codex' ? '$' : '/';
199
+ ok(`${AGENTS[a].label}: ${x}glow:status, ${x}glow:ignore, ${x}glow:unignore and ${x}glow:run added`);
200
+ } catch (e) {
201
+ warn(`${AGENTS[a].label}: couldn’t add the /glow commands (${printable(e.message, 160)}). The hook works without them.`);
202
+ }
129
203
  }
130
204
 
131
- const repos = (info?.repos ?? []).map((r) => printable(r, 160));
205
+ const ignored = ignoredOf(readConfig());
206
+ const repos = (info?.repos ?? []).filter((r) => !ignored.includes(r)).map((r) => printable(r, 160));
132
207
  console.log('');
133
208
  console.log(repos.length ? `Repos that report to Glow: ${repos.join(', ')}` : 'No repos are linked to a product yet.');
209
+ if (ignored.length) console.log(`Ignored on this computer: ${ignored.join(', ')}`);
134
210
  const here = repoOf(process.cwd());
135
- if (here && !repos.includes(here)) console.log(`This repo (${here}) isn’t linked. Link it to a product: ${apiBase(cfg.api)}/app/pair?repo=${encodeURIComponent(here)}`);
211
+ if (here && !repos.includes(here) && !ignored.includes(here)) console.log(`This repo (${here}) isn’t linked. Link it to a product: ${linkUrl(cfg, here)}`);
136
212
  console.log('');
137
- if (agents.includes('claude_code')) console.log(`${bold('Claude Code')}: new sessions in those repos post to Glow when they finish a reply. Restart open sessions to pick it up.`);
213
+ if (agents.includes('claude_code')) console.log(`${bold('Claude Code')}: new sessions in those repos post to Glow when they finish a reply. Restart open sessions to pick it up (and the /glow commands).`);
138
214
  if (agents.includes('codex')) console.log(`${bold('Codex')}: open Codex and run ${bold('/hooks')} once to trust the Glow hook (Codex asks before running any new hook).`);
139
215
  console.log(`Messages you send a session from its post in Glow reach that agent${readConfig()?.messages === false ? ' (off here: npx @winding-labs/glow messages on)' : ' (npx @winding-labs/glow messages off to stop that)'}.`);
140
- console.log(dim('\nOnly sessions in linked repos send anything, and only to Glow. Off: npx @winding-labs/glow off · Remove: npx @winding-labs/glow uninstall'));
216
+ if (agents.includes('claude_code')) console.log(`To start Claude Code sessions here from Glow (Agents tab): ${bold('npx @winding-labs/glow run')} in your code folder.`);
217
+ console.log(dim('\nOnly sessions in linked repos send anything, and only to Glow. A repo you never want sent: /glow:ignore in it.'));
218
+ console.log(dim('Off: npx @winding-labs/glow off · Remove: npx @winding-labs/glow uninstall'));
141
219
  }
142
220
 
143
221
  /** The device flow: show a code, open Glow to approve it, then type the confirmation Glow shows to get the key. */
@@ -213,22 +291,38 @@ async function pair(o, agents) {
213
291
  }
214
292
 
215
293
 
216
- /** ~/.glow/{core,hook}.mjs and the shim the agents run, which uses the Node that ran this (or the one on PATH). */
217
- function copyHook() {
218
- fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
219
- fs.chmodSync(dir(), 0o700);
220
- for (const f of ['core.mjs', 'hook.mjs']) fs.copyFileSync(path.join(LIB, f), path.join(dir(), f));
221
- for (const f of ['core.mjs', 'hook.mjs']) fs.chmodSync(path.join(dir(), f), 0o600);
222
- // An empty environment but HOME and PATH: no NODE_OPTIONS, proxy, TLS or GIT_* variable from the agent's settings
223
- // can change where the hook sends or what it runs. (GLOW_POLL_MS only speeds up the tests.)
224
- const shim = `#!/bin/sh
225
- # Glow agent hook (npx @winding-labs/glow ${VERSION}). Sends coding agent replies in linked repos to Glow.
294
+ /**
295
+ * This package's code to ~/.glow (bin, lib, marketplace) and the two shims: ~/.glow/hook (the agents' Stop hooks) and
296
+ * ~/.glow/glow (this CLI, for the /glow commands). Both run the Node that ran this (or the one on PATH) with an empty
297
+ * environment but HOME and PATH: no NODE_OPTIONS, proxy, TLS or GIT_* variable from the agent's settings can change
298
+ * where they send or what they run. (GLOW_POLL_MS only speeds up the tests.)
299
+ */
300
+ function copyFiles() {
301
+ const home = dir();
302
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
303
+ fs.chmodSync(home, 0o700);
304
+ // Copied over in place (never removed first), so a hook that starts meanwhile still finds its files.
305
+ if (fs.realpathSync(PKG) !== fs.realpathSync(home)) {
306
+ for (const d of ['bin', 'lib', 'marketplace']) fs.cpSync(path.join(PKG, d), path.join(home, d), { recursive: true, force: true });
307
+ }
308
+ // 0.1.0–0.1.1 kept the hook at ~/.glow/hook.mjs and ~/.glow/core.mjs.
309
+ for (const f of ['core.mjs', 'hook.mjs']) fs.rmSync(path.join(home, f), { force: true });
310
+ const shim = (what, script, extra = '') => `#!/bin/sh
311
+ # ${what} (npx @winding-labs/glow ${VERSION})
226
312
  NODE=${shq(process.execPath)}
227
- [ -x "$NODE" ] || NODE=$(command -v node) || exit 0
228
- exec env -i HOME="$HOME" PATH="$PATH" \${GLOW_POLL_MS:+GLOW_POLL_MS="$GLOW_POLL_MS"} "$NODE" ${shq(path.join(dir(), 'hook.mjs'))} "$@"
313
+ [ -x "$NODE" ] || NODE=$(command -v node) || { echo "Glow needs Node 18 or newer: https://nodejs.org" >&2; exit 0; }
314
+ exec env -i HOME="$HOME" PATH="$PATH" ${extra}"$NODE" ${shq(path.join(home, script))} "$@"
229
315
  `;
230
- fs.writeFileSync(shimPath(), shim, { mode: 0o700 });
231
- fs.chmodSync(shimPath(), 0o700);
316
+ writeShim(shimPath(), shim('Glow agent hook: sends coding agent replies in linked repos to Glow', path.join('lib', 'hook.mjs'), '${GLOW_POLL_MS:+GLOW_POLL_MS="$GLOW_POLL_MS"} '));
317
+ // The session marker goes through (env -i drops the rest): glow refuses to change anything from a runner's session.
318
+ writeShim(cliPath(), shim('Glow: status, ignore, unignore, run and the rest of npx @winding-labs/glow, for the /glow commands', path.join('bin', 'glow.mjs'), `\${${SESSION_ENV}:+${SESSION_ENV}="$${SESSION_ENV}"} `));
319
+ }
320
+
321
+ const cliPath = () => path.join(dir(), 'glow');
322
+
323
+ function writeShim(file, text) {
324
+ fs.writeFileSync(file, text, { mode: 0o700 });
325
+ fs.chmodSync(file, 0o700);
232
326
  }
233
327
 
234
328
  function openUrl(url) {
@@ -241,20 +335,112 @@ function openUrl(url) {
241
335
  }
242
336
 
243
337
  const tilde = (p) => (p.startsWith(os.homedir()) ? `~${p.slice(os.homedir().length)}` : p);
338
+ const linkUrl = (cfg, repo) => `${apiBase(cfg.api)}/app/pair?repo=${encodeURIComponent(repo)}`;
244
339
 
245
340
  // ---------- status, off/on, uninstall ----------
246
341
 
342
+ /** What's set up here, what this folder sends (and why not), and the repos that report or are ignored. */
343
+ /** The Glow phone this computer trusts, and Glow's word on it; `trust`: pin the one Glow names now, once its owner,
344
+ * at this terminal, says it's theirs (its fingerprint, as the app shows it). */
345
+ async function phone(trust) {
346
+ const cfg = readConfig();
347
+ if (!cfg) fail('Glow isn’t set up on this computer. Set it up: npx @winding-labs/glow');
348
+ const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
349
+ if (res.status !== 200) fail(res.status === 401 ? 'This computer’s key no longer works. Pair again: npx @winding-labs/glow' : `Couldn’t ask Glow (${res.status || 'offline'}).`);
350
+ const named = phoneOf(res.json?.phone);
351
+ const line = (p) => `${bold(printable(p.name, 60))} ${dim(fingerprintText(p.fingerprint))}`;
352
+ if (!trust) {
353
+ console.log(cfg.phone?.fingerprint ? `Trusted here: ${line(cfg.phone)}. Only what it signs (after Face ID) runs on this computer.` : 'No Glow phone is trusted here yet: make your phone your Glow phone in the app (Settings › Glow phone).');
354
+ if (named && named.fingerprint !== cfg.phone?.fingerprint) console.log(`Glow names ${line(named)}. To trust it instead: npx @winding-labs/glow phone trust`);
355
+ if (!named) console.log(dim('Glow names no Glow phone for you right now.'));
356
+ return;
357
+ }
358
+ if (!named) fail('Glow names no Glow phone for you. Make your phone your Glow phone in the app first (Settings › Glow phone).');
359
+ if (named.fingerprint === cfg.phone?.fingerprint) return ok(`Already trusted: ${line(named)}`);
360
+ // Glow's word alone never swaps the key: the person at this terminal checks the fingerprint against their phone.
361
+ if (!process.stdin.isTTY || !tty) fail('Run it yourself, in a terminal: it asks you to check the fingerprint against your phone.');
362
+ console.log(`Glow says your Glow phone is now ${line(named)}.`);
363
+ console.log(`Open the app on that phone, Settings › Glow phone: it shows ${bold(fingerprintText(named.fingerprint))}. Not the same, or not your doing? Answer no.`);
364
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
365
+ const answer = await new Promise((r) => rl.question('Trust it on this computer? (yes/no) ', r));
366
+ rl.close();
367
+ if (String(answer).trim().toLowerCase() !== 'yes') return warn('Nothing changed.');
368
+ writeConfig({ ...(readConfig() ?? cfg), phone: { ...named, pinned_at: new Date().toISOString() } });
369
+ ok(`Trusted: ${line(named)}. What it signs runs here; what ${cfg.phone?.name ? printable(cfg.phone.name, 60) : 'any other phone'} signs doesn’t.`);
370
+ }
371
+
247
372
  async function status() {
248
373
  const cfg = readConfig();
249
- if (!cfg) return console.log('Not set up. Run npx @winding-labs/glow');
374
+ if (!cfg) return console.log('Glow isn’t set up on this computer, so nothing is sent from it. Set it up: npx @winding-labs/glow');
250
375
  const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
251
- console.log(`${bold('Glow')} ${dim(VERSION)} · ${apiBase(cfg.api)}`);
252
- console.log(`Computer: ${printable(cfg.computer?.name) || '?'} · workspace: ${printable(res.json?.workspace?.name ?? cfg.workspace?.name) || '?'}`);
253
- console.log(`Key: ${res.status === 200 ? 'works' : res.status === 401 ? 'revoked (run setup again)' : res.status === 403 ? 'paused in Glow' : `unreachable (${res.status})`}${cfg.off ? ' · sending is OFF here' : ''}`);
254
- console.log(`Messages from Glow: ${cfg.messages === false ? 'off' : 'on'}`);
255
- for (const a of Object.keys(AGENTS)) console.log(`${AGENTS[a].label}: ${installed(a) ? 'hooked' : 'not hooked'}`);
256
- const repos = (res.json?.repos ?? cfg.repos ?? []).map((r) => printable(r, 160));
257
- console.log(`Repos that report: ${repos.length ? repos.join(', ') : 'none yet'}`);
376
+ const ok200 = res.status === 200 && Array.isArray(res.json?.repos);
377
+ try {
378
+ if (ok200) writeConfig({ ...(readConfig() ?? cfg), repos: res.json.repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
379
+ } catch {
380
+ // a sandbox that can't write ~/.glow: the hook refreshes the list itself
381
+ }
382
+ const linkedRepos = (ok200 ? res.json.repos : Array.isArray(cfg.repos) ? cfg.repos : []).filter((r) => typeof r === 'string').map((r) => printable(r, 160));
383
+ const ignored = ignoredOf(cfg);
384
+ const key = res.status === 200 ? 'key works' : res.status === 401 ? 'key revoked: run npx @winding-labs/glow to pair again' : res.status === 403 ? 'paused in Glow (Connections › Coding agents)' : `Glow unreachable (${res.status || 'offline'})`;
385
+
386
+ const here = repoOf(process.cwd());
387
+ let folder;
388
+ if (!here) folder = 'not a GitHub repo: sends nothing';
389
+ else if (ignored.includes(here)) folder = `${here}: ignored here, sends nothing (undo: /glow:unignore)`;
390
+ else if (!linkedRepos.includes(here)) folder = `${here}: not linked to a product, sends nothing. Link it: ${linkUrl(cfg, here)}`;
391
+ else if (cfg.off) folder = `${here}: linked, but sending is off on this computer`;
392
+ else if (res.status === 401 || res.status === 403) folder = `${here}: linked, but this computer can’t send (see Computer)`;
393
+ else folder = `${here}: reports to Glow`;
394
+
395
+ const agents = Object.keys(AGENTS).map((a) => `${AGENTS[a].label} ${installed(a) ? 'hooked' : 'not hooked'}`).join(' · ');
396
+ const reporting = linkedRepos.filter((r) => !ignored.includes(r));
397
+ const rs = runnerState();
398
+ const rows = [
399
+ ['This folder', folder],
400
+ ['Computer', `${printable(cfg.computer?.name) || '?'} · workspace ${printable(res.json?.workspace?.name ?? cfg.workspace?.name) || '?'} · ${key}`],
401
+ ['Sending', `${cfg.off ? 'off (npx @winding-labs/glow on)' : 'on'} · messages from Glow ${cfg.messages === false ? 'off' : 'on'} · requests for recipes ${cfg.recipes === false ? 'off' : 'on'}`],
402
+ ['Agents', agents],
403
+ ['Reporting', reporting.length ? reporting.join(', ') : 'no repo yet'],
404
+ ['Ignored here', ignored.length ? ignored.join(', ') : 'none'],
405
+ ['Glow phone', cfg.phone?.fingerprint
406
+ ? `${printable(cfg.phone.name, 60)} (${fingerprintText(cfg.phone.fingerprint)}): only what it signs runs here${phoneOf(res.json?.phone) && phoneOf(res.json.phone).fingerprint !== cfg.phone.fingerprint ? ` · Glow names another: npx @winding-labs/glow phone trust` : ''}`
407
+ : 'none trusted yet: make your phone your Glow phone in the app (Settings › Glow phone)'],
408
+ ['Runner', rs
409
+ ? `on since ${new Date(rs.started_at).toLocaleString()} (pid ${rs.pid}) in ${(rs.roots ?? []).map((r) => printable(r, 160)).join(', ')}: Glow can start Claude Code sessions here · stop: npx @winding-labs/glow run stop`
410
+ : 'off: Glow can’t start sessions here (to let it: npx @winding-labs/glow run, in your code folder)'],
411
+ ];
412
+ console.log(`${bold('Glow')} ${dim(VERSION)} · ${apiBase(cfg.api)}\n`);
413
+ for (const [k, v] of rows) console.log(`${dim(k.padEnd(14))}${v}`);
414
+ }
415
+
416
+ /** The repo a folder is in (default: this one), or an owner/name typed out (or its GitHub URL). */
417
+ function repoArg(target) {
418
+ if (!target) return { repo: repoOf(process.cwd()), where: 'This folder' };
419
+ const p = path.resolve(target.replace(/^~(?=$|\/)/, os.homedir()));
420
+ if (fs.existsSync(p)) return { repo: repoOf(p), where: tilde(p) };
421
+ const r = target.trim().toLowerCase().replace(/^(?:https:\/\/)?github\.com\//, '').replace(/(?:\.git)?\/?$/, '');
422
+ return { repo: REPO_RE.test(r) ? r : null, where: printable(target, 160) };
423
+ }
424
+
425
+ /** Ignore (or stop ignoring) a repo on this computer. Local only: the hook checks it before sending anything. */
426
+ function ignore(target, on) {
427
+ const { repo, where } = repoArg(target);
428
+ if (!repo) return console.log(`${where} isn’t a GitHub repo, so Glow sends nothing from it.`);
429
+ const cfg = readConfig();
430
+ if (!cfg) return console.log(`Glow isn’t set up on this computer, so nothing is sent from ${repo} (or any repo).`);
431
+ const list = ignoredOf(cfg);
432
+ const linked = Array.isArray(cfg.repos) && cfg.repos.includes(repo);
433
+ if (on) {
434
+ if (list.includes(repo)) return ok(`${repo} is already ignored here: nothing is sent from it.`);
435
+ writeConfig({ ...cfg, ignored: [...list, repo].sort() });
436
+ ok(`Ignored ${repo}: Claude Code and Codex sessions in it send nothing to Glow from this computer.`);
437
+ console.log(dim(`${linked ? 'It’s linked to a product in Glow; other computers still report from it. ' : ''}Undo: /glow:unignore ($glow:unignore in Codex, or npx @winding-labs/glow unignore${target ? ` ${repo}` : ''})`));
438
+ return;
439
+ }
440
+ if (!list.includes(repo)) return console.log(`${repo} isn’t ignored here. ${linked ? 'Sessions in it report to Glow.' : 'It isn’t linked to a product, so it sends nothing.'}`);
441
+ writeConfig({ ...cfg, ignored: list.filter((r) => r !== repo) });
442
+ ok(`${repo} isn’t ignored anymore.`);
443
+ console.log(linked ? 'Sessions in it report to Glow again.' : `It isn’t linked to a product, so it still sends nothing. Link it: ${linkUrl(cfg, repo)}`);
258
444
  }
259
445
 
260
446
  function toggle(off) {
@@ -271,8 +457,25 @@ function messages(on) {
271
457
  ok(on ? 'Messages on: what you send a session from Glow reaches its agent' : 'Messages off: Glow holds nothing for this computer’s agents (npx @winding-labs/glow messages on to undo)');
272
458
  }
273
459
 
460
+ function recipes(on) {
461
+ const cfg = readConfig();
462
+ if (!cfg) fail('Not set up. Run npx @winding-labs/glow');
463
+ writeConfig({ ...cfg, recipes: on });
464
+ ok(on ? 'Recipes on: each report carries the request it answers, and Glow learns the recipes you run' : 'Recipes off: reports carry the agent’s reply only (npx @winding-labs/glow recipes on to undo)');
465
+ }
466
+
274
467
  async function remove() {
275
468
  const cfg = readConfig();
469
+ const rs = runnerState();
470
+ if (rs) {
471
+ try {
472
+ process.kill(rs.pid, 'SIGTERM');
473
+ for (let n = 0; n < 100 && runnerState(); n++) await sleep(100);
474
+ ok('Runner stopped');
475
+ } catch {
476
+ warn(`Couldn’t stop the runner (pid ${rs.pid}). Stop it yourself: kill ${rs.pid}`);
477
+ }
478
+ }
276
479
  for (const a of Object.keys(AGENTS)) {
277
480
  try {
278
481
  const file = uninstall(a);
@@ -280,6 +483,7 @@ async function remove() {
280
483
  } catch (e) {
281
484
  warn(`${AGENTS[a].label}: couldn’t update its settings (${e.message}); remove the Glow hook by hand.`);
282
485
  }
486
+ if (detect().includes(a) && removePlugin(a)) ok(`${AGENTS[a].label}: /glow commands removed`);
283
487
  }
284
488
  if (cfg) {
285
489
  const res = await api(cfg, 'DELETE', '/api/v1/agent/computer', undefined, 10_000);
@@ -290,4 +494,81 @@ async function remove() {
290
494
  ok(`Deleted ${tilde(dir())}`);
291
495
  }
292
496
 
497
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
498
+
499
+ // ---------- run: sessions Glow starts here ----------
500
+
501
+ /**
502
+ * `glow run [folder…]`: Glow can start Claude Code sessions here while this runs (lib/runner.mjs). In the foreground
503
+ * (Ctrl-C stops it and its sessions), or with --background as its own process logging to ~/.glow/runner.log. One per
504
+ * computer. `glow run stop` ends it.
505
+ */
506
+ async function run(o) {
507
+ const cfg = readConfig();
508
+ if (!cfg) fail('Glow isn’t set up on this computer. Set it up first: npx @winding-labs/glow');
509
+ const on = runnerState();
510
+ if (o.runStop) {
511
+ if (!on) return console.log('The runner isn’t on here, so Glow can’t start sessions on this computer.');
512
+ process.kill(on.pid, 'SIGTERM');
513
+ for (let n = 0; n < 120 && runnerState(); n++) await sleep(100);
514
+ return runnerState() ? warn(`Asked the runner (pid ${on.pid}) to stop; it’s still finishing.`) : ok('Runner stopped: Glow can’t start sessions here until you run it again.');
515
+ }
516
+ if (cfg.off) fail('Sending is off on this computer. Turn it on first: npx @winding-labs/glow on');
517
+ if (on) fail(`The runner is already on here (pid ${on.pid}, in ${(on.roots ?? []).map((r) => printable(r, 160)).join(', ')}). Stop it first: npx @winding-labs/glow run stop`);
518
+ const roots = (o.roots.length ? o.roots : [process.cwd()]).map((r) => path.resolve(r.replace(/^~(?=$|\/)/, os.homedir())));
519
+ for (const r of roots) if (!fs.existsSync(r) || !fs.statSync(r).isDirectory()) fail(`${printable(r, 160)} isn’t a folder.`);
520
+ // From 0.1.4 the hook leaves the sessions the runner starts to the runner (one report each): bring an older copy up.
521
+ let hookVersion;
522
+ try {
523
+ hookVersion = /VERSION = '([\d.]+)'/.exec(fs.readFileSync(path.join(dir(), 'lib', 'core.mjs'), 'utf8'))?.[1];
524
+ } catch {
525
+ hookVersion = undefined;
526
+ }
527
+ const older = (a, b) => {
528
+ const [x, y] = [a, b].map((v) => v.split('.').map(Number));
529
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
530
+ return false;
531
+ };
532
+ let shimNow = '';
533
+ try {
534
+ shimNow = fs.readFileSync(cliPath(), 'utf8');
535
+ } catch {
536
+ shimNow = '';
537
+ }
538
+ // Older, or a ~/.glow/glow that drops the session marker (an early 0.1.4): bring it up.
539
+ if (hookVersion && /^\d+\.\d+\.\d+$/.test(VERSION) && (older(hookVersion, VERSION) || (shimNow && !shimNow.includes(SESSION_ENV)))) {
540
+ copyFiles();
541
+ console.log(dim(older(hookVersion, VERSION) ? `Updated Glow’s hook on this computer from ${hookVersion} to ${VERSION}.` : 'Updated Glow’s files on this computer.'));
542
+ }
543
+ if (o.background) {
544
+ const log = logPath();
545
+ fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
546
+ try {
547
+ if (fs.statSync(log).size > 1_000_000) fs.rmSync(log, { force: true });
548
+ } catch {
549
+ // no log yet
550
+ }
551
+ const fd = fs.openSync(log, 'a', 0o600);
552
+ fs.chmodSync(log, 0o600);
553
+ const flags = [...o.allow].flatMap(([r, at]) => ['--repo', at ? `${r}=${at}` : r]);
554
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'run', ...roots, ...flags], { detached: true, stdio: ['ignore', fd, fd], env: process.env, cwd: roots[0] });
555
+ child.unref();
556
+ for (let n = 0; n < 80 && runnerState()?.pid !== child.pid; n++) await sleep(100);
557
+ if (runnerState()?.pid !== child.pid) fail(`The runner didn’t start. See ${tilde(log)}`);
558
+ ok(`Runner on in the background (pid ${child.pid}): it runs what your Glow phone signs, 3 at a time, in the linked repos under ${roots.map(tilde).join(', ')} (pause this computer in Glow to hold it).`);
559
+ console.log(dim(`Log: ${tilde(log)} · Stop: npx @winding-labs/glow run stop`));
560
+ return;
561
+ }
562
+ for (const [r, at] of o.allow) if (at && (!fs.existsSync(at) || !fs.statSync(at).isDirectory())) fail(`${printable(at, 160)} (for ${r}) isn’t a folder.`);
563
+ const r = startRunner({ roots, say: (line) => console.log(line), allow: o.allow });
564
+ const quit = () => {
565
+ console.log(dim('\nStopping the runner and its sessions…'));
566
+ void r.stop();
567
+ };
568
+ process.once('SIGINT', quit);
569
+ process.once('SIGTERM', quit);
570
+ process.once('SIGHUP', quit); // the terminal closed
571
+ process.exit(await r.done);
572
+ }
573
+
293
574
  main().catch((e) => fail(e?.message ?? String(e)));
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';
@@ -129,3 +133,41 @@ export const installed = (id) => {
129
133
  return false;
130
134
  }
131
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
+ }