@winding-labs/glow 0.1.1 → 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/README.md CHANGED
@@ -15,11 +15,19 @@ branch, commits and pull request. From that post in Glow you can send the sessio
15
15
  - **Only to Glow** (`https://app.meetglow.ai`). The address is fixed when you set up and can't be changed by a repo or
16
16
  an environment variable (the hook runs with an empty environment but `HOME` and `PATH`).
17
17
  - **Only from linked repos.** A session in any other repo sends nothing. You link repos to products in Glow.
18
- - **Only the agent's last reply**, with keys and tokens masked, plus the branch, the titles of the branch's commits and
19
- a pull request link on the same repo. Never your code, files, prompts or the transcript.
20
- - Glow turns the reply into a short summary right away and **doesn't keep the reply itself**.
18
+ - **Never from a repo you ignore.** `/glow:ignore` in a repo (or `npx @winding-labs/glow ignore`) and its sessions send
19
+ nothing from this computer, even if it's linked: the hook stops before it makes any request.
20
+ - **Only the agent's last reply and the request it answers**, with keys and tokens masked, plus the branch, the titles
21
+ of the branch's commits and a pull request link on the same repo, and which of GitHub (`gh` signed in), Chrome, Docker
22
+ and Xcode this computer has (yes or no each, for its tags in Glow). Never your code, files or the transcript: the hook
23
+ reads only your latest request from the agent's own session file.
24
+ - Glow turns the reply into a short summary right away and **doesn't keep the reply or the request**. From the request
25
+ it keeps only the recipe it names when it's a job you'd run again ("Pen test the API", "Optimize SEO"), so it can
26
+ count how often you run it and offer to run it for you. Don't want that? `npx @winding-labs/glow recipes off`.
21
27
  - This computer's key can do three things: send a report, read which repos are linked, and fetch messages you sent to
22
- its sessions. It can't read anything else in your workspace. It stops working after 90 days unused.
28
+ its sessions. While `glow run` is on, it also picks up the sessions you start here and says how they're going (the
29
+ repo, the last command or file, masked; never output or code). It can't read anything else in your workspace. It
30
+ stops working after 90 days unused.
23
31
 
24
32
  ## Messages back
25
33
 
@@ -27,19 +35,67 @@ Only the person who paired the computer can message its sessions, and Glow asks
27
35
  first. Claude Code picks a message up while it's idle (for 25 minutes after its last reply); Codex picks it up when its
28
36
  current turn ends. Don't want that? `npx @winding-labs/glow messages off` on the computer.
29
37
 
38
+ ## Start sessions from Glow
39
+
40
+ ```
41
+ npx @winding-labs/glow run ~/code
42
+ ```
43
+
44
+ Only if you want it, and only while it runs: Glow's Agents tab can then start a Claude Code session on this computer,
45
+ from a queued task ("Run on …") or a prompt you type ("New session"). It runs in your checkout of a repo that's linked
46
+ to a product and found under the folders you give it (the current folder by default), and the Agents tab shows it live:
47
+ the repo, what it's on, its status, how long it's been running, its last command or tool, and a Stop button. Its reply
48
+ posts to the product's channel like any session's, except for a task someone else wrote or one filed from a capture:
49
+ then only you see it, under the session.
50
+
51
+ - **Only you** (the person who paired the computer) can start one, and Glow asks for Face ID or your passkey first.
52
+ - **A mode you pick each time:** *Look only* (Claude Code's plan mode: it reads, changes nothing), *Edit files* (also
53
+ edits files in the repo) or *Auto* (Claude Code's auto mode; never for a queued task). Never a mode that skips
54
+ permission checks. Opus at medium effort unless you pick another model or effort.
55
+ - **This computer decides too**, whatever Glow sends: `--max-mode read|edit|auto` is the most a session may do here
56
+ (*Look only* by default: `--max-mode edit` lets sessions edit files, `--max-mode auto` allows *Auto*; the app offers
57
+ only what the computer allows); `--repo owner/name` (repeat it) runs only those repos,
58
+ and `--repo owner/name=~/code/zest` picks the checkout when a repo is checked out more than once (otherwise it doesn't
59
+ run; worktrees don't count: it runs in the main checkout). Only in a folder you've trusted in Claude Code (opened `claude` there and said yes), since its own `.claude`
60
+ settings and hooks apply. A session id is run once, never again.
61
+ - **Up to 3 at once** on a computer, one per repo, 30 a day, 3 hours each at most. Stop from Glow ends the session and
62
+ the commands it started. Pausing the computer in Glow stops them all; Ctrl-C (or closing the terminal) stops the
63
+ runner and its sessions, and everything they started.
64
+ - `run --background` keeps it going after you close the terminal (log: `~/.glow/runner.log`); `run stop` stops it. A
65
+ session the runner started can't stop it or turn Glow off.
66
+
67
+ ## In Claude Code and Codex
68
+
69
+ ```
70
+ /glow:status what this folder sends (and why not), what's set up, which repos report or are ignored
71
+ /glow:ignore this repo never sends to Glow from this computer (add a folder or owner/name for another)
72
+ /glow:unignore let it send again (only when you type it: an agent can't run it on its own in Claude Code)
73
+ /glow:run start (or stop) the runner in the background, for the linked repos under the session's folder
74
+ (only when you type it; Look only: --max-mode and --repo are for the terminal)
75
+ ```
76
+
77
+ In Codex they're `$glow:status`, `$glow:ignore`, `$glow:unignore` and `$glow:run`. Setup adds them as a `glow` plugin, through each
78
+ agent's own `plugin` command; each may run only its own `~/.glow/glow` command, and a session Glow started can't run
79
+ any of them.
80
+
30
81
  ## Commands
31
82
 
32
83
  ```
33
84
  npx @winding-labs/glow set up (or add an agent you installed later)
34
- npx @winding-labs/glow status what's set up and which repos report
85
+ npx @winding-labs/glow status what this folder sends, what's set up, which repos report
86
+ npx @winding-labs/glow ignore [repo] this repo (or a folder, or owner/name) never sends from this computer
87
+ npx @winding-labs/glow unignore [repo] let it send again
35
88
  npx @winding-labs/glow off | on stop or start sending from this computer
36
89
  npx @winding-labs/glow messages off messages from Glow no longer reach your agents here (messages on: again)
37
- npx @winding-labs/glow uninstall remove the hooks, revoke the key, delete ~/.glow
90
+ npx @winding-labs/glow run [folders] let Glow start Claude Code sessions in the linked repos under these folders
91
+ [--max-mode read|edit|auto] [--repo owner/name[=path]…] [--background]
92
+ npx @winding-labs/glow run stop stop the runner (and its sessions)
93
+ npx @winding-labs/glow uninstall remove the hooks and the plugin, revoke the key, delete ~/.glow
38
94
  ```
39
95
 
40
96
  Codex asks before it runs any new hook: open Codex and run `/hooks` once to trust Glow's.
41
97
 
42
- Files: `~/.glow/` (the key, owner-only), a `Stop` hook in `~/.claude/settings.json` and `~/.codex/hooks.json` (a
98
+ Files: `~/.glow/` (owner-only: the key, the ignored repos, a copy of this package and the plugin), a `Stop` hook in `~/.claude/settings.json` and `~/.codex/hooks.json` (a
43
99
  symlinked settings file stays a link). The first time, the originals are copied to `*.before-glow`.
44
100
 
45
101
  Security: [SECURITY.md](SECURITY.md).
package/SECURITY.md CHANGED
@@ -7,12 +7,48 @@ Report a vulnerability to **security@meetglow.ai**. Please don't open a public i
7
7
  - **Talk to one host:** `https://app.meetglow.ai`, fixed at setup in `~/.glow/config.json` (owner-only) and checked on
8
8
  every call. Redirects are refused. The hook runs with an empty environment except `HOME` and `PATH`, so no proxy, TLS
9
9
  or `NODE_*` setting can send it anywhere else.
10
- - **Send only from repos you linked** to a product in Glow: the agent's last reply (keys and tokens masked), the branch,
11
- up to five commit titles on it, and a pull request link on the same repo. Never code, files, prompts or transcripts.
10
+ - **Send only from repos you linked** to a product in Glow: the agent's last reply and the request it answers (keys and
11
+ tokens masked; the request only from a .jsonl under ~/.claude/projects or ~/.codex/sessions, never with `recipes
12
+ off`), the branch, up to five commit titles on it, and a pull request link on the same repo. Never code, files or
13
+ transcripts.
14
+ A repo you ignore here (`/glow:ignore`) sends nothing, linked or not: the hook stops before any request.
12
15
  - **Run git read-only** (the origin URL, the branch, commit titles), with no shell, hooks, fsmonitor, pager, signature
13
16
  checks or `GIT_*` variables, so a repo's `.git/config` can't make it run a program.
14
17
  - **Edit two settings files:** add one `Stop` hook to `~/.claude/settings.json` and `~/.codex/hooks.json`, keep
15
18
  everything else, back up the originals once (`*.before-glow`), and never overwrite a file that isn't valid JSON.
19
+ - **Add one plugin** (`glow`, from `~/.glow/marketplace`) with each agent's own `claude plugin` / `codex plugin`
20
+ command. Each may run only its own `~/.glow/glow` command (`status`, `ignore …`, `unignore …`; `/glow:run`: exactly
21
+ `run --background`, `run stop` or `status`), and `/glow:unignore` and `/glow:run` run only when you type them.
22
+
23
+ - **Start Claude Code, only while `glow run` runs** (you start it; nothing installs it to start on its own). It runs
24
+ `claude` as an argument list (no shell), in your checkout of a linked repo found under the folders you gave it, with
25
+ the prompt on stdin (so a prompt can't become a flag), in the mode picked in Glow: `plan` for *Look only* (it changes
26
+ nothing), `acceptEdits` for *Edit files*, `auto` for *Auto*, with permission prompts turned down. Never
27
+ `bypassPermissions` or `--dangerously-skip-permissions`; and always `--disallowedTools` for Glow's own CLI
28
+ (`~/.glow/glow`, `npx @winding-labs/glow`, `node …/glow.mjs`) and files (`~/.glow`, the computer's key), which no
29
+ allow rule (the plugin's included) can grant back. That list matches the command's text, so a session already
30
+ allowed arbitrary shell commands (*Auto*, or a broad allow rule in your own or the repo's settings) could spell its
31
+ way around it (`~/.gl?w/glow`); *Look only* and *Edit files* don't allow that, and the CLI also refuses any command
32
+ run from inside a session. One command per call: `glow ignore x run …` is refused, so a skill allowed to run one
33
+ command can't reach another. Stop, a pause in Glow, the 3-hour limit, Ctrl-C or closing the
34
+ terminal ends a session and everything it started: Claude Code's Bash tool and hooks start each command in a process
35
+ group of its own, so the runner signals every group under that Claude Code (SIGTERM, then SIGKILL after 5 s; SIGTERM
36
+ at once if the runner itself exits).
37
+ - **Not take Glow's word for it.** The runner refuses, on your computer: a mode above `--max-mode` (*Look only*
38
+ unless you widen it: `--max-mode edit` or `auto`, also for `/glow:run`); a repo outside `--repo` when you gave it; a folder Claude Code hasn't been trusted in (`claude -p` skips
39
+ that question, and a repo's own `.claude` settings and hooks would apply); a repo with more than one main checkout
40
+ unless you pinned one (`--repo owner/name=path`; its worktrees aren't copies: it runs in the main one); a model or
41
+ effort not on its list (only `--model opus|sonnet|haiku|fable` and `--effort low…max` ever reach `claude`); and a
42
+ session id it has already been given. A command run inside one of
43
+ its sessions can't stop the runner, turn Glow off or change what it sends: besides the denied tools, `glow` refuses
44
+ when its environment has the session's marker (`GLOW_RUN_SESSION`, kept by `~/.glow/glow`), when a session's Claude
45
+ Code is among its parent processes, or when it runs in a session's process group.
46
+ Signed launch tokens (Glow signs each session, the computer checks it with a key it keeps) are a later step: today a
47
+ compromised Glow account or server could start a session within those limits.
48
+ - **Report how a session goes:** running, done, failed or stopped; turns, cost, how many tool calls were turned down;
49
+ and its last step as one line: a command by its program and subcommand only (`$ psql …`, never its arguments), a
50
+ file path relative to the repo, a search pattern or a web host. Keys and tokens are masked before anything is cut,
51
+ the home folder is `~`, and Glow cuts and scrubs it again. Never output, file contents or the transcript.
16
52
 
17
53
  ## What the key can do in Glow
18
54
 
@@ -25,3 +61,13 @@ stops working when you remove the computer, leave the workspace, or don't use it
25
61
 
26
62
  Messages back: only the person who paired the computer can message its sessions, with a fresh second factor each time.
27
63
  `npx @winding-labs/glow messages off` turns them off on the computer.
64
+
65
+ Sessions started from Glow: only the person who paired the computer can start one, with a fresh second factor, in a
66
+ repo linked to a product and checked out on the computer, while `glow run` is on there. With the runner on, the key can
67
+ also say which linked repos are checked out, pick up a waiting session (Glow hands the prompt over once and then
68
+ deletes it) and report its status. Glow allows 3 at once per computer, one per repo, and 30 a day, and never more
69
+ than the runner's `--max-mode`; a session not picked up in 10 minutes fails (closed nightly at the latest, prompt
70
+ deleted), and pausing or removing the computer stops its sessions, as does Glow's own kill switch. A queued task never
71
+ runs in *Auto*, and the start sheet says who wrote the task's text. When someone else wrote the task, or it came from a
72
+ capture, the session's reply goes to you only (under the session in the Agents tab), never to a channel. Only you see
73
+ which repos the runner found on your computer.
package/bin/glow.mjs CHANGED
@@ -3,20 +3,30 @@
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 uninstall remove the hooks and plugins, revoke this computer's key, delete ~/.glow
15
+ //
16
+ // Setup copies this package to ~/.glow (bin, lib, marketplace) with two shims: ~/.glow/hook, which the agents' Stop
17
+ // hooks run, and ~/.glow/glow, this CLI, which the /glow:status, /glow:ignore, /glow:unignore and /glow:run plugin
18
+ // commands run.
19
+ import { execFile, spawn } from 'node:child_process';
11
20
  import readline from 'node:readline';
12
21
  import fs from 'node:fs';
13
22
  import os from 'node:os';
14
23
  import path from 'node:path';
15
24
  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';
25
+ import { addPlugin, AGENTS, detect, install, installed, removePlugin, shimPath, shq, uninstall } from '../lib/agents.mjs';
26
+ import { api, apiBase, apiOk, computerName, DEFAULT_API, dir, ignoredOf, printable, readConfig, REPO_RE, repoOf, VERSION, writeConfig } from '../lib/core.mjs';
27
+ import { inRunnerSession, logPath, MODES, runnerState, SESSION_ENV, startRunner } from '../lib/runner.mjs';
18
28
 
19
- const LIB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib');
29
+ const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
20
30
  const tty = process.stdout.isTTY;
21
31
  const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
22
32
  const bold = (s) => c(1, s);
@@ -31,11 +41,23 @@ const fail = (s) => {
31
41
  const HELP = `Glow ${VERSION}: send what your coding agents do to your Glow channels.
32
42
 
33
43
  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
44
+ npx @winding-labs/glow status what's set up, what this folder sends, which repos report
45
+ npx @winding-labs/glow ignore [repo] never send from this repo on this computer (or a folder, or owner/name)
46
+ npx @winding-labs/glow unignore [repo] let it send again
35
47
  npx @winding-labs/glow off | on stop or start sending from this computer
36
48
  npx @winding-labs/glow messages off stop messages from Glow reaching your agents here (messages on: again)
49
+ npx @winding-labs/glow recipes off don't send your requests, so Glow learns no recipes from them (recipes on: again)
50
+ npx @winding-labs/glow run [folder…] let Glow start Claude Code sessions here (Agents tab), in the linked repos
51
+ checked out under these folders (default: this one), while it runs
52
+ --background keep it running after this terminal closes (log: ~/.glow/runner.log)
53
+ --max-mode read|edit|auto the most a session may do on its own here, whatever Glow asks (default: read,
54
+ Look only; edit lets it edit files, auto runs Claude Code's auto mode)
55
+ --repo owner/name[=path] only this repo (repeat for more); =path picks its checkout
56
+ npx @winding-labs/glow run stop stop it, and the sessions it runs
37
57
  npx @winding-labs/glow uninstall remove the hooks and revoke this computer's key
38
58
 
59
+ In Claude Code: /glow:status, /glow:ignore, /glow:unignore, /glow:run (in Codex: $glow:status, $glow:ignore, …)
60
+
39
61
  Options for setup:
40
62
  --claude, --codex only these agents (default: every one found)
41
63
  --new pair again, as a new computer
@@ -44,11 +66,19 @@ Options for setup:
44
66
  `;
45
67
 
46
68
  function parse(argv) {
47
- const o = { cmd: 'install', agents: [], api: DEFAULT_API, dev: false, open: true, fresh: false, name: '', messages: null };
69
+ 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, maxMode: 'read', allow: new Map() };
70
+ // One command per call: `glow ignore x run …` must not become `run` (a skill allowed to run `glow ignore` can't
71
+ // reach any other command).
72
+ let named = '', asked = '';
73
+ const command = (c) => {
74
+ if (named) fail(`One command at a time (${named}, then ${c}).`);
75
+ named = c;
76
+ o.cmd = c;
77
+ };
48
78
  for (let i = 0; i < argv.length; i++) {
49
79
  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';
80
+ if (a === '--help' || a === '-h' || a === 'help') asked = 'help';
81
+ else if (a === '--version' || a === '-v') asked = 'version';
52
82
  else if (a === '--claude') o.agents.push('claude_code');
53
83
  else if (a === '--codex') o.agents.push('codex');
54
84
  else if (a === '--new') o.fresh = true;
@@ -57,11 +87,35 @@ function parse(argv) {
57
87
  else if (a === '--api') o.api = argv[++i] ?? '';
58
88
  else if (a === '--name') o.name = argv[++i] ?? '';
59
89
  else if (a === 'messages' && ['on', 'off'].includes(argv[i + 1])) {
60
- o.cmd = 'messages';
90
+ command('messages');
61
91
  o.messages = argv[++i] === 'on';
62
- } else if (['install', 'setup', 'status', 'off', 'on', 'uninstall'].includes(a)) o.cmd = a === 'setup' ? 'install' : a;
92
+ } else if (a === 'recipes' && ['on', 'off'].includes(argv[i + 1])) {
93
+ command('recipes');
94
+ o.recipes = argv[++i] === 'on';
95
+ } else if (a === 'ignore' || a === 'unignore') {
96
+ command(a);
97
+ if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) o.target = argv[++i];
98
+ } else if (a === 'run') {
99
+ command('run');
100
+ while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
101
+ const x = argv[++i];
102
+ if (x === 'stop' && !o.roots.length) o.runStop = true;
103
+ else o.roots.push(x);
104
+ }
105
+ } else if (a === '--background' && o.cmd === 'run') o.background = true;
106
+ else if (a === '--max-mode' && o.cmd === 'run') {
107
+ o.maxMode = argv[++i] ?? '';
108
+ if (!MODES.includes(o.maxMode)) fail('--max-mode is read, edit or auto.');
109
+ } else if (a === '--repo' && o.cmd === 'run') {
110
+ const [name, at] = String(argv[++i] ?? '').split(/=(.*)/s);
111
+ const repo = name.toLowerCase();
112
+ if (!REPO_RE.test(repo)) fail('--repo is owner/name, or owner/name=path to pick its checkout.');
113
+ o.allow.set(repo, at ? path.resolve(at.replace(/^~(?=$|\/)/, os.homedir())) : null);
114
+ }
115
+ else if (['install', 'setup', 'status', 'off', 'on', 'uninstall'].includes(a)) command(a === 'setup' ? 'install' : a);
63
116
  else fail(`Unknown option ${a}. Run npx @winding-labs/glow --help`);
64
117
  }
118
+ if (asked) o.cmd = asked; // help and version win, whatever else is there
65
119
  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
120
  return o;
67
121
  }
@@ -74,8 +128,13 @@ async function main() {
74
128
  if (o.cmd === 'help') return console.log(HELP);
75
129
  if (o.cmd === 'version') return console.log(VERSION);
76
130
  if (o.cmd === 'status') return status();
131
+ // A session Glow started here can't change how Glow reaches this computer (stop the runner, turn sending off, …).
132
+ if (inRunnerSession()) fail('Not from a session Glow started: run it yourself, in a terminal.');
77
133
  if (o.cmd === 'off' || o.cmd === 'on') return toggle(o.cmd === 'off');
78
134
  if (o.cmd === 'messages') return messages(o.messages);
135
+ if (o.cmd === 'recipes') return recipes(o.recipes);
136
+ if (o.cmd === 'ignore' || o.cmd === 'unignore') return ignore(o.target, o.cmd === 'ignore');
137
+ if (o.cmd === 'run') return run(o);
79
138
  if (o.cmd === 'uninstall') return remove();
80
139
  return setup(o);
81
140
  }
@@ -118,7 +177,7 @@ async function setup(o) {
118
177
  writeConfig({ ...cfg, agents: [...new Set([...(cfg.agents ?? []), ...agents])], ...(info?.repos ? { repos: info.repos, repos_at: Date.now() } : {}), version: VERSION });
119
178
  }
120
179
 
121
- copyHook();
180
+ copyFiles();
122
181
  for (const a of agents) {
123
182
  try {
124
183
  const file = install(a);
@@ -126,18 +185,29 @@ async function setup(o) {
126
185
  } catch (e) {
127
186
  warn(`${AGENTS[a].label}: couldn’t update ${tilde(path.join(os.homedir(), ...AGENTS[a].file))} (${e.message}). Fix it, then run this again.`);
128
187
  }
188
+ try {
189
+ addPlugin(a);
190
+ const x = a === 'codex' ? '$' : '/';
191
+ ok(`${AGENTS[a].label}: ${x}glow:status, ${x}glow:ignore, ${x}glow:unignore and ${x}glow:run added`);
192
+ } catch (e) {
193
+ warn(`${AGENTS[a].label}: couldn’t add the /glow commands (${printable(e.message, 160)}). The hook works without them.`);
194
+ }
129
195
  }
130
196
 
131
- const repos = (info?.repos ?? []).map((r) => printable(r, 160));
197
+ const ignored = ignoredOf(readConfig());
198
+ const repos = (info?.repos ?? []).filter((r) => !ignored.includes(r)).map((r) => printable(r, 160));
132
199
  console.log('');
133
200
  console.log(repos.length ? `Repos that report to Glow: ${repos.join(', ')}` : 'No repos are linked to a product yet.');
201
+ if (ignored.length) console.log(`Ignored on this computer: ${ignored.join(', ')}`);
134
202
  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)}`);
203
+ 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
204
  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.`);
205
+ 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
206
  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
207
  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'));
208
+ 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.`);
209
+ console.log(dim('\nOnly sessions in linked repos send anything, and only to Glow. A repo you never want sent: /glow:ignore in it.'));
210
+ console.log(dim('Off: npx @winding-labs/glow off · Remove: npx @winding-labs/glow uninstall'));
141
211
  }
142
212
 
143
213
  /** The device flow: show a code, open Glow to approve it, then type the confirmation Glow shows to get the key. */
@@ -213,22 +283,38 @@ async function pair(o, agents) {
213
283
  }
214
284
 
215
285
 
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.
286
+ /**
287
+ * This package's code to ~/.glow (bin, lib, marketplace) and the two shims: ~/.glow/hook (the agents' Stop hooks) and
288
+ * ~/.glow/glow (this CLI, for the /glow commands). Both run the Node that ran this (or the one on PATH) with an empty
289
+ * environment but HOME and PATH: no NODE_OPTIONS, proxy, TLS or GIT_* variable from the agent's settings can change
290
+ * where they send or what they run. (GLOW_POLL_MS only speeds up the tests.)
291
+ */
292
+ function copyFiles() {
293
+ const home = dir();
294
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
295
+ fs.chmodSync(home, 0o700);
296
+ // Copied over in place (never removed first), so a hook that starts meanwhile still finds its files.
297
+ if (fs.realpathSync(PKG) !== fs.realpathSync(home)) {
298
+ for (const d of ['bin', 'lib', 'marketplace']) fs.cpSync(path.join(PKG, d), path.join(home, d), { recursive: true, force: true });
299
+ }
300
+ // 0.1.0–0.1.1 kept the hook at ~/.glow/hook.mjs and ~/.glow/core.mjs.
301
+ for (const f of ['core.mjs', 'hook.mjs']) fs.rmSync(path.join(home, f), { force: true });
302
+ const shim = (what, script, extra = '') => `#!/bin/sh
303
+ # ${what} (npx @winding-labs/glow ${VERSION})
226
304
  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'))} "$@"
305
+ [ -x "$NODE" ] || NODE=$(command -v node) || { echo "Glow needs Node 18 or newer: https://nodejs.org" >&2; exit 0; }
306
+ exec env -i HOME="$HOME" PATH="$PATH" ${extra}"$NODE" ${shq(path.join(home, script))} "$@"
229
307
  `;
230
- fs.writeFileSync(shimPath(), shim, { mode: 0o700 });
231
- fs.chmodSync(shimPath(), 0o700);
308
+ 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"} '));
309
+ // The session marker goes through (env -i drops the rest): glow refuses to change anything from a runner's session.
310
+ 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}"} `));
311
+ }
312
+
313
+ const cliPath = () => path.join(dir(), 'glow');
314
+
315
+ function writeShim(file, text) {
316
+ fs.writeFileSync(file, text, { mode: 0o700 });
317
+ fs.chmodSync(file, 0o700);
232
318
  }
233
319
 
234
320
  function openUrl(url) {
@@ -241,20 +327,80 @@ function openUrl(url) {
241
327
  }
242
328
 
243
329
  const tilde = (p) => (p.startsWith(os.homedir()) ? `~${p.slice(os.homedir().length)}` : p);
330
+ const linkUrl = (cfg, repo) => `${apiBase(cfg.api)}/app/pair?repo=${encodeURIComponent(repo)}`;
244
331
 
245
332
  // ---------- status, off/on, uninstall ----------
246
333
 
334
+ /** What's set up here, what this folder sends (and why not), and the repos that report or are ignored. */
247
335
  async function status() {
248
336
  const cfg = readConfig();
249
- if (!cfg) return console.log('Not set up. Run npx @winding-labs/glow');
337
+ 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
338
  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'}`);
339
+ const ok200 = res.status === 200 && Array.isArray(res.json?.repos);
340
+ try {
341
+ if (ok200) writeConfig({ ...(readConfig() ?? cfg), repos: res.json.repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
342
+ } catch {
343
+ // a sandbox that can't write ~/.glow: the hook refreshes the list itself
344
+ }
345
+ const linkedRepos = (ok200 ? res.json.repos : Array.isArray(cfg.repos) ? cfg.repos : []).filter((r) => typeof r === 'string').map((r) => printable(r, 160));
346
+ const ignored = ignoredOf(cfg);
347
+ 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'})`;
348
+
349
+ const here = repoOf(process.cwd());
350
+ let folder;
351
+ if (!here) folder = 'not a GitHub repo: sends nothing';
352
+ else if (ignored.includes(here)) folder = `${here}: ignored here, sends nothing (undo: /glow:unignore)`;
353
+ else if (!linkedRepos.includes(here)) folder = `${here}: not linked to a product, sends nothing. Link it: ${linkUrl(cfg, here)}`;
354
+ else if (cfg.off) folder = `${here}: linked, but sending is off on this computer`;
355
+ else if (res.status === 401 || res.status === 403) folder = `${here}: linked, but this computer can’t send (see Computer)`;
356
+ else folder = `${here}: reports to Glow`;
357
+
358
+ const agents = Object.keys(AGENTS).map((a) => `${AGENTS[a].label} ${installed(a) ? 'hooked' : 'not hooked'}`).join(' · ');
359
+ const reporting = linkedRepos.filter((r) => !ignored.includes(r));
360
+ const rs = runnerState();
361
+ const rows = [
362
+ ['This folder', folder],
363
+ ['Computer', `${printable(cfg.computer?.name) || '?'} · workspace ${printable(res.json?.workspace?.name ?? cfg.workspace?.name) || '?'} · ${key}`],
364
+ ['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'}`],
365
+ ['Agents', agents],
366
+ ['Reporting', reporting.length ? reporting.join(', ') : 'no repo yet'],
367
+ ['Ignored here', ignored.length ? ignored.join(', ') : 'none'],
368
+ ['Runner', rs
369
+ ? `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`
370
+ : 'off: Glow can’t start sessions here (to let it: npx @winding-labs/glow run, in your code folder)'],
371
+ ];
372
+ console.log(`${bold('Glow')} ${dim(VERSION)} · ${apiBase(cfg.api)}\n`);
373
+ for (const [k, v] of rows) console.log(`${dim(k.padEnd(14))}${v}`);
374
+ }
375
+
376
+ /** The repo a folder is in (default: this one), or an owner/name typed out (or its GitHub URL). */
377
+ function repoArg(target) {
378
+ if (!target) return { repo: repoOf(process.cwd()), where: 'This folder' };
379
+ const p = path.resolve(target.replace(/^~(?=$|\/)/, os.homedir()));
380
+ if (fs.existsSync(p)) return { repo: repoOf(p), where: tilde(p) };
381
+ const r = target.trim().toLowerCase().replace(/^(?:https:\/\/)?github\.com\//, '').replace(/(?:\.git)?\/?$/, '');
382
+ return { repo: REPO_RE.test(r) ? r : null, where: printable(target, 160) };
383
+ }
384
+
385
+ /** Ignore (or stop ignoring) a repo on this computer. Local only: the hook checks it before sending anything. */
386
+ function ignore(target, on) {
387
+ const { repo, where } = repoArg(target);
388
+ if (!repo) return console.log(`${where} isn’t a GitHub repo, so Glow sends nothing from it.`);
389
+ const cfg = readConfig();
390
+ if (!cfg) return console.log(`Glow isn’t set up on this computer, so nothing is sent from ${repo} (or any repo).`);
391
+ const list = ignoredOf(cfg);
392
+ const linked = Array.isArray(cfg.repos) && cfg.repos.includes(repo);
393
+ if (on) {
394
+ if (list.includes(repo)) return ok(`${repo} is already ignored here: nothing is sent from it.`);
395
+ writeConfig({ ...cfg, ignored: [...list, repo].sort() });
396
+ ok(`Ignored ${repo}: Claude Code and Codex sessions in it send nothing to Glow from this computer.`);
397
+ 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}` : ''})`));
398
+ return;
399
+ }
400
+ 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.'}`);
401
+ writeConfig({ ...cfg, ignored: list.filter((r) => r !== repo) });
402
+ ok(`${repo} isn’t ignored anymore.`);
403
+ 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
404
  }
259
405
 
260
406
  function toggle(off) {
@@ -271,8 +417,25 @@ function messages(on) {
271
417
  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
418
  }
273
419
 
420
+ function recipes(on) {
421
+ const cfg = readConfig();
422
+ if (!cfg) fail('Not set up. Run npx @winding-labs/glow');
423
+ writeConfig({ ...cfg, recipes: on });
424
+ 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)');
425
+ }
426
+
274
427
  async function remove() {
275
428
  const cfg = readConfig();
429
+ const rs = runnerState();
430
+ if (rs) {
431
+ try {
432
+ process.kill(rs.pid, 'SIGTERM');
433
+ for (let n = 0; n < 100 && runnerState(); n++) await sleep(100);
434
+ ok('Runner stopped');
435
+ } catch {
436
+ warn(`Couldn’t stop the runner (pid ${rs.pid}). Stop it yourself: kill ${rs.pid}`);
437
+ }
438
+ }
276
439
  for (const a of Object.keys(AGENTS)) {
277
440
  try {
278
441
  const file = uninstall(a);
@@ -280,6 +443,7 @@ async function remove() {
280
443
  } catch (e) {
281
444
  warn(`${AGENTS[a].label}: couldn’t update its settings (${e.message}); remove the Glow hook by hand.`);
282
445
  }
446
+ if (detect().includes(a) && removePlugin(a)) ok(`${AGENTS[a].label}: /glow commands removed`);
283
447
  }
284
448
  if (cfg) {
285
449
  const res = await api(cfg, 'DELETE', '/api/v1/agent/computer', undefined, 10_000);
@@ -290,4 +454,81 @@ async function remove() {
290
454
  ok(`Deleted ${tilde(dir())}`);
291
455
  }
292
456
 
457
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
458
+
459
+ // ---------- run: sessions Glow starts here ----------
460
+
461
+ /**
462
+ * `glow run [folder…]`: Glow can start Claude Code sessions here while this runs (lib/runner.mjs). In the foreground
463
+ * (Ctrl-C stops it and its sessions), or with --background as its own process logging to ~/.glow/runner.log. One per
464
+ * computer. `glow run stop` ends it.
465
+ */
466
+ async function run(o) {
467
+ const cfg = readConfig();
468
+ if (!cfg) fail('Glow isn’t set up on this computer. Set it up first: npx @winding-labs/glow');
469
+ const on = runnerState();
470
+ if (o.runStop) {
471
+ if (!on) return console.log('The runner isn’t on here, so Glow can’t start sessions on this computer.');
472
+ process.kill(on.pid, 'SIGTERM');
473
+ for (let n = 0; n < 120 && runnerState(); n++) await sleep(100);
474
+ 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.');
475
+ }
476
+ if (cfg.off) fail('Sending is off on this computer. Turn it on first: npx @winding-labs/glow on');
477
+ 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`);
478
+ const roots = (o.roots.length ? o.roots : [process.cwd()]).map((r) => path.resolve(r.replace(/^~(?=$|\/)/, os.homedir())));
479
+ for (const r of roots) if (!fs.existsSync(r) || !fs.statSync(r).isDirectory()) fail(`${printable(r, 160)} isn’t a folder.`);
480
+ // From 0.1.4 the hook leaves the sessions the runner starts to the runner (one report each): bring an older copy up.
481
+ let hookVersion;
482
+ try {
483
+ hookVersion = /VERSION = '([\d.]+)'/.exec(fs.readFileSync(path.join(dir(), 'lib', 'core.mjs'), 'utf8'))?.[1];
484
+ } catch {
485
+ hookVersion = undefined;
486
+ }
487
+ const older = (a, b) => {
488
+ const [x, y] = [a, b].map((v) => v.split('.').map(Number));
489
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
490
+ return false;
491
+ };
492
+ let shimNow = '';
493
+ try {
494
+ shimNow = fs.readFileSync(cliPath(), 'utf8');
495
+ } catch {
496
+ shimNow = '';
497
+ }
498
+ // Older, or a ~/.glow/glow that drops the session marker (an early 0.1.4): bring it up.
499
+ if (hookVersion && /^\d+\.\d+\.\d+$/.test(VERSION) && (older(hookVersion, VERSION) || (shimNow && !shimNow.includes(SESSION_ENV)))) {
500
+ copyFiles();
501
+ console.log(dim(older(hookVersion, VERSION) ? `Updated Glow’s hook on this computer from ${hookVersion} to ${VERSION}.` : 'Updated Glow’s files on this computer.'));
502
+ }
503
+ if (o.background) {
504
+ const log = logPath();
505
+ fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
506
+ try {
507
+ if (fs.statSync(log).size > 1_000_000) fs.rmSync(log, { force: true });
508
+ } catch {
509
+ // no log yet
510
+ }
511
+ const fd = fs.openSync(log, 'a', 0o600);
512
+ fs.chmodSync(log, 0o600);
513
+ const flags = ['--max-mode', o.maxMode, ...[...o.allow].flatMap(([r, at]) => ['--repo', at ? `${r}=${at}` : r])];
514
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'run', ...roots, ...flags], { detached: true, stdio: ['ignore', fd, fd], env: process.env, cwd: roots[0] });
515
+ child.unref();
516
+ for (let n = 0; n < 80 && runnerState()?.pid !== child.pid; n++) await sleep(100);
517
+ if (runnerState()?.pid !== child.pid) fail(`The runner didn’t start. See ${tilde(log)}`);
518
+ ok(`Runner on in the background (pid ${child.pid}): Glow can start Claude Code sessions in the linked repos under ${roots.map(tilde).join(', ')}.`);
519
+ console.log(dim(`Log: ${tilde(log)} · Stop: npx @winding-labs/glow run stop`));
520
+ return;
521
+ }
522
+ 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.`);
523
+ const r = startRunner({ roots, say: (line) => console.log(line), maxMode: o.maxMode, allow: o.allow });
524
+ const quit = () => {
525
+ console.log(dim('\nStopping the runner and its sessions…'));
526
+ void r.stop();
527
+ };
528
+ process.once('SIGINT', quit);
529
+ process.once('SIGTERM', quit);
530
+ process.once('SIGHUP', quit); // the terminal closed
531
+ process.exit(await r.done);
532
+ }
533
+
293
534
  main().catch((e) => fail(e?.message ?? String(e)));