@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/README.md +81 -7
- package/SECURITY.md +78 -4
- package/bin/glow.mjs +322 -41
- package/lib/agents.mjs +42 -0
- package/lib/core.mjs +289 -6
- package/lib/hook.mjs +36 -9
- package/lib/runner.mjs +860 -0
- package/marketplace/.agents/plugins/marketplace.json +12 -0
- package/marketplace/.claude-plugin/marketplace.json +13 -0
- package/marketplace/glow/.claude-plugin/plugin.json +10 -0
- package/marketplace/glow/.codex-plugin/plugin.json +22 -0
- package/marketplace/glow/skills/ignore/SKILL.md +15 -0
- package/marketplace/glow/skills/run/SKILL.md +26 -0
- package/marketplace/glow/skills/status/SKILL.md +15 -0
- package/marketplace/glow/skills/unignore/SKILL.md +16 -0
- package/package.json +3 -3
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
|
|
10
|
-
|
|
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
|
|
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
|
|
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')
|
|
51
|
-
else if (a === '--version' || a === '-v')
|
|
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
|
-
|
|
94
|
+
command('messages');
|
|
61
95
|
o.messages = argv[++i] === 'on';
|
|
62
|
-
} else if (
|
|
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)
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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: ${
|
|
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(
|
|
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
|
-
/**
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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"
|
|
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
|
-
|
|
231
|
-
|
|
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('
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}
|