@winding-labs/glow 0.0.0-stage → 0.1.0
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/LICENSE +16 -0
- package/README.md +44 -2
- package/SECURITY.md +27 -0
- package/bin/glow.mjs +293 -0
- package/lib/agents.mjs +122 -0
- package/lib/core.mjs +204 -0
- package/lib/hook.mjs +173 -0
- package/package.json +14 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Winding Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Glow for coding agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
```
|
|
4
|
+
npx @winding-labs/glow
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Run it once on your computer. It finds the coding agents you use (Claude Code, Codex), pairs the computer with your
|
|
8
|
+
Glow workspace (you approve it in Glow with your passkey, then type the 6-digit code Glow shows you back into the
|
|
9
|
+
terminal), and adds a small hook to each agent. From then on, every
|
|
10
|
+
session in a repo that's linked to one of your products keeps one post in that product's channel: what it did, its
|
|
11
|
+
branch, commits and pull request. From that post in Glow you can send the session a message, and the agent gets it.
|
|
12
|
+
|
|
13
|
+
## What it sends, and where
|
|
14
|
+
|
|
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
|
+
an environment variable (the hook runs with an empty environment but `HOME` and `PATH`).
|
|
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**.
|
|
21
|
+
- 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.
|
|
23
|
+
|
|
24
|
+
## Messages back
|
|
25
|
+
|
|
26
|
+
Only the person who paired the computer can message its sessions, and Glow asks them for Face ID or their passkey
|
|
27
|
+
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
|
+
current turn ends. Don't want that? `npx @winding-labs/glow messages off` on the computer.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
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
|
|
35
|
+
npx @winding-labs/glow off | on stop or start sending from this computer
|
|
36
|
+
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
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Codex asks before it runs any new hook: open Codex and run `/hooks` once to trust Glow's.
|
|
41
|
+
|
|
42
|
+
Files: `~/.glow/` (the key, owner-only), a `Stop` hook in `~/.claude/settings.json` and `~/.codex/hooks.json` (a
|
|
43
|
+
symlinked settings file stays a link). The first time, the originals are copied to `*.before-glow`.
|
|
44
|
+
|
|
45
|
+
Security: [SECURITY.md](SECURITY.md).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report a vulnerability to **security@meetglow.ai**. Please don't open a public issue for it.
|
|
4
|
+
|
|
5
|
+
## What this package can do on your computer
|
|
6
|
+
|
|
7
|
+
- **Talk to one host:** `https://app.meetglow.ai`, fixed at setup in `~/.glow/config.json` (owner-only) and checked on
|
|
8
|
+
every call. Redirects are refused. The hook runs with an empty environment except `HOME` and `PATH`, so no proxy, TLS
|
|
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.
|
|
12
|
+
- **Run git read-only** (the origin URL, the branch, commit titles), with no shell, hooks, fsmonitor, pager, signature
|
|
13
|
+
checks or `GIT_*` variables, so a repo's `.git/config` can't make it run a program.
|
|
14
|
+
- **Edit two settings files:** add one `Stop` hook to `~/.claude/settings.json` and `~/.codex/hooks.json`, keep
|
|
15
|
+
everything else, back up the originals once (`*.before-glow`), and never overwrite a file that isn't valid JSON.
|
|
16
|
+
|
|
17
|
+
## What the key can do in Glow
|
|
18
|
+
|
|
19
|
+
Pairing is a device flow: you approve the computer in Glow with a fresh second factor, then type the 6-digit code Glow
|
|
20
|
+
shows you into the terminal. A code someone else approves gets nothing.
|
|
21
|
+
|
|
22
|
+
The key (`glowc_…`, stored in Glow only as a hash) can send a session report, read which repos are linked, fetch the
|
|
23
|
+
messages you sent to this computer's sessions, and revoke itself. It can't read anything else in your workspace. It
|
|
24
|
+
stops working when you remove the computer, leave the workspace, or don't use it for 90 days.
|
|
25
|
+
|
|
26
|
+
Messages back: only the person who paired the computer can message its sessions, with a fresh second factor each time.
|
|
27
|
+
`npx @winding-labs/glow messages off` turns them off on the computer.
|
package/bin/glow.mjs
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx @winding-labs/glow — pair this computer with Glow and hook the coding agents on it (Claude Code, Codex), so each
|
|
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
|
+
//
|
|
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
|
|
7
|
+
// npx @winding-labs/glow off | on stop / start sending from this computer
|
|
8
|
+
// 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
|
+
import readline from 'node:readline';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
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';
|
|
18
|
+
|
|
19
|
+
const LIB = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib');
|
|
20
|
+
const tty = process.stdout.isTTY;
|
|
21
|
+
const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
22
|
+
const bold = (s) => c(1, s);
|
|
23
|
+
const dim = (s) => c(2, s);
|
|
24
|
+
const ok = (s) => console.log(`${c(32, '✓')} ${s}`);
|
|
25
|
+
const warn = (s) => console.log(`${c(33, '!')} ${s}`);
|
|
26
|
+
const fail = (s) => {
|
|
27
|
+
console.error(`${c(31, '✗')} ${s}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const HELP = `Glow ${VERSION}: send what your coding agents do to your Glow channels.
|
|
32
|
+
|
|
33
|
+
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
|
|
35
|
+
npx @winding-labs/glow off | on stop or start sending from this computer
|
|
36
|
+
npx @winding-labs/glow messages off stop messages from Glow reaching your agents here (messages on: again)
|
|
37
|
+
npx @winding-labs/glow uninstall remove the hooks and revoke this computer's key
|
|
38
|
+
|
|
39
|
+
Options for setup:
|
|
40
|
+
--claude, --codex only these agents (default: every one found)
|
|
41
|
+
--new pair again, as a new computer
|
|
42
|
+
--name <name> what Glow calls this computer (default: its name)
|
|
43
|
+
--no-open don't open the browser; print the link
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
function parse(argv) {
|
|
47
|
+
const o = { cmd: 'install', agents: [], api: DEFAULT_API, dev: false, open: true, fresh: false, name: '', messages: null };
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
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';
|
|
52
|
+
else if (a === '--claude') o.agents.push('claude_code');
|
|
53
|
+
else if (a === '--codex') o.agents.push('codex');
|
|
54
|
+
else if (a === '--new') o.fresh = true;
|
|
55
|
+
else if (a === '--no-open') o.open = false;
|
|
56
|
+
else if (a === '--dev') o.dev = true;
|
|
57
|
+
else if (a === '--api') o.api = argv[++i] ?? '';
|
|
58
|
+
else if (a === '--name') o.name = argv[++i] ?? '';
|
|
59
|
+
else if (a === 'messages' && ['on', 'off'].includes(argv[i + 1])) {
|
|
60
|
+
o.cmd = 'messages';
|
|
61
|
+
o.messages = argv[++i] === 'on';
|
|
62
|
+
} else if (['install', 'setup', 'status', 'off', 'on', 'uninstall'].includes(a)) o.cmd = a === 'setup' ? 'install' : a;
|
|
63
|
+
else fail(`Unknown option ${a}. Run npx @winding-labs/glow --help`);
|
|
64
|
+
}
|
|
65
|
+
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
|
+
return o;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
if (process.platform === 'win32') fail('Glow’s agent hook runs on macOS and Linux for now.');
|
|
71
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
72
|
+
if (major < 18) fail(`Glow needs Node 18 or newer (this is ${process.version}).`);
|
|
73
|
+
const o = parse(process.argv.slice(2));
|
|
74
|
+
if (o.cmd === 'help') return console.log(HELP);
|
|
75
|
+
if (o.cmd === 'version') return console.log(VERSION);
|
|
76
|
+
if (o.cmd === 'status') return status();
|
|
77
|
+
if (o.cmd === 'off' || o.cmd === 'on') return toggle(o.cmd === 'off');
|
|
78
|
+
if (o.cmd === 'messages') return messages(o.messages);
|
|
79
|
+
if (o.cmd === 'uninstall') return remove();
|
|
80
|
+
return setup(o);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- set up ----------
|
|
84
|
+
|
|
85
|
+
async function setup(o) {
|
|
86
|
+
console.log(`${bold('Glow')} ${dim(VERSION)}: your coding agents → your Glow channels\n`);
|
|
87
|
+
const found = detect();
|
|
88
|
+
const agents = o.agents.length ? o.agents : found;
|
|
89
|
+
for (const a of o.agents) if (!found.includes(a)) warn(`${AGENTS[a].label} isn’t installed here; adding its hook anyway.`);
|
|
90
|
+
if (!agents.length) fail('No coding agent found (Claude Code or Codex). Install one, then run this again.');
|
|
91
|
+
console.log(`Found: ${agents.map((a) => AGENTS[a].label).join(', ')}`);
|
|
92
|
+
|
|
93
|
+
let cfg = readConfig();
|
|
94
|
+
if (cfg && (o.fresh || apiBase(cfg.api) !== apiBase(o.api))) {
|
|
95
|
+
await api(cfg, 'DELETE', '/api/v1/agent/computer', undefined, 5_000);
|
|
96
|
+
cfg = null;
|
|
97
|
+
}
|
|
98
|
+
let info = null;
|
|
99
|
+
if (cfg) {
|
|
100
|
+
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) {
|
|
103
|
+
warn('This computer’s key no longer works (it was removed in Glow). Pairing again.');
|
|
104
|
+
cfg = null;
|
|
105
|
+
} else if (res.status === 403) warn('This computer is paused in Glow. Resume it there (Connections › Coding agents).');
|
|
106
|
+
else if (res.status === 0) fail(`Couldn’t reach ${apiBase(cfg.api)}. Check your connection and try again.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!cfg) {
|
|
110
|
+
const paired = await pair(o, agents);
|
|
111
|
+
cfg = { api: apiBase(o.api), ...(o.dev ? { dev: true } : {}), key: paired.key, computer: paired.computer, workspace: paired.workspace, repos: paired.repos, repos_at: Date.now(), agents, version: VERSION };
|
|
112
|
+
writeConfig(cfg);
|
|
113
|
+
info = { computer: { ...paired.computer, paused: false }, workspace: paired.workspace, repos: paired.repos };
|
|
114
|
+
console.log('');
|
|
115
|
+
ok(`Paired ${bold(printable(paired.computer.name))} with ${bold(printable(paired.workspace.name))}`);
|
|
116
|
+
} else {
|
|
117
|
+
ok(`Already paired: ${bold(printable(cfg.computer?.name) || 'this computer')} with ${bold(printable(info?.workspace?.name ?? cfg.workspace?.name) || 'your workspace')}`);
|
|
118
|
+
writeConfig({ ...cfg, agents: [...new Set([...(cfg.agents ?? []), ...agents])], ...(info?.repos ? { repos: info.repos, repos_at: Date.now() } : {}), version: VERSION });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
copyHook();
|
|
122
|
+
for (const a of agents) {
|
|
123
|
+
try {
|
|
124
|
+
const file = install(a);
|
|
125
|
+
ok(`${AGENTS[a].label}: hook added to ${tilde(file)}`);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
warn(`${AGENTS[a].label}: couldn’t update ${tilde(path.join(os.homedir(), ...AGENTS[a].file))} (${e.message}). Fix it, then run this again.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const repos = (info?.repos ?? []).map((r) => printable(r, 160));
|
|
132
|
+
console.log('');
|
|
133
|
+
console.log(repos.length ? `Repos that report to Glow: ${repos.join(', ')}` : 'No repos are linked to a product yet.');
|
|
134
|
+
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)}`);
|
|
136
|
+
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.`);
|
|
138
|
+
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
|
+
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'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The device flow: show a code, open Glow to approve it, then type the confirmation Glow shows to get the key. */
|
|
144
|
+
async function pair(o, agents) {
|
|
145
|
+
const name = (o.name || computerName()).slice(0, 60);
|
|
146
|
+
const repo = repoOf(process.cwd());
|
|
147
|
+
const start = await api({ api: o.api, dev: o.dev }, 'POST', '/api/v1/agent/pairings', { name, agents, ...(repo ? { repo } : {}) }, 15_000);
|
|
148
|
+
if (start.status === 429) fail('Too many pairings from this network. Wait 10 minutes and try again.');
|
|
149
|
+
if (start.status !== 201 || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(start.json?.code ?? '') || typeof start.json?.secret !== 'string') {
|
|
150
|
+
fail(start.status ? `Glow didn’t start the pairing (${start.status}). Try again in a minute.` : `Couldn’t reach ${apiBase(o.api)}. Check your connection and try again.`);
|
|
151
|
+
}
|
|
152
|
+
const { code, secret } = start.json;
|
|
153
|
+
const url = `${apiBase(o.api)}/app/pair?code=${code}`;
|
|
154
|
+
console.log(`\nPair ${bold(name)} with Glow:`);
|
|
155
|
+
console.log(` 1. Open ${c(36, url)}`);
|
|
156
|
+
console.log(` 2. Check it shows ${bold(code)}`);
|
|
157
|
+
console.log(' 3. Pick the workspace and approve');
|
|
158
|
+
console.log(' 4. Type the 6-digit code Glow then shows you here\n');
|
|
159
|
+
if (o.open) openUrl(url);
|
|
160
|
+
process.stdout.write(dim('Waiting for you to approve in Glow… (Ctrl-C to cancel)'));
|
|
161
|
+
const until = Date.now() + 10 * 60_000;
|
|
162
|
+
const every = Math.min(10, Math.max(2, Number(start.json.interval_s) || 3)) * 1000;
|
|
163
|
+
const claim = (confirm) => api({ api: o.api, dev: o.dev }, 'POST', '/api/v1/agent/pairings/claim', { secret, ...(confirm ? { confirm } : {}) }, 15_000);
|
|
164
|
+
for (;;) {
|
|
165
|
+
if (Date.now() > until) {
|
|
166
|
+
process.stdout.write('\n');
|
|
167
|
+
fail('The code expired. Run npx @winding-labs/glow again for a new one.');
|
|
168
|
+
}
|
|
169
|
+
await new Promise((r) => setTimeout(r, every));
|
|
170
|
+
const res = await claim();
|
|
171
|
+
if (res.status === 202 && res.json?.status === 'confirm') break;
|
|
172
|
+
if (res.status === 404) {
|
|
173
|
+
process.stdout.write('\n');
|
|
174
|
+
fail('The code expired or was turned down. Run npx @winding-labs/glow again for a new one.');
|
|
175
|
+
}
|
|
176
|
+
if (tty) process.stdout.write('.');
|
|
177
|
+
}
|
|
178
|
+
process.stdout.write('\n\n');
|
|
179
|
+
// Only the person who approved sees the confirmation, so a code someone else approved goes nowhere.
|
|
180
|
+
console.log(`Approved in Glow. It now shows a ${bold('6-digit code')}: type it here.`);
|
|
181
|
+
console.log(dim('Didn’t approve it yourself just now? Press Ctrl-C: someone else may have used your code.'));
|
|
182
|
+
// Lines are queued as they come (typed, or piped), so none is lost between one question and the next.
|
|
183
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
184
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
185
|
+
const ask = async (q) => {
|
|
186
|
+
process.stdout.write(q);
|
|
187
|
+
const r = await lines.next();
|
|
188
|
+
return r.done ? null : String(r.value);
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
for (let asked = 0; asked < 8; asked++) {
|
|
192
|
+
const a = await ask('Code: ');
|
|
193
|
+
if (a === null) fail('No code typed. Run npx @winding-labs/glow again.');
|
|
194
|
+
const digits = a.replace(/\s/g, '');
|
|
195
|
+
if (!/^\d{6}$/.test(digits)) {
|
|
196
|
+
warn('Six digits, as Glow shows them.');
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const res = await claim(digits);
|
|
200
|
+
if (res.status === 200 && res.json?.status === 'approved' && typeof res.json.key === 'string') return res.json;
|
|
201
|
+
if (res.status === 400) {
|
|
202
|
+
warn(printable(res.json?.error, 120) || 'That isn’t the code Glow showed.');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (res.status === 404) fail('Too many wrong codes, or it expired. Run npx @winding-labs/glow again for a new one.');
|
|
206
|
+
if (res.status === 403 || res.status === 409) fail(printable(res.json?.error, 160) || `Glow refused (${res.status}).`);
|
|
207
|
+
fail(`Couldn’t reach ${apiBase(o.api)} (${res.status}). Run npx @winding-labs/glow again.`);
|
|
208
|
+
}
|
|
209
|
+
return fail('No valid code. Run npx @winding-labs/glow again.');
|
|
210
|
+
} finally {
|
|
211
|
+
rl.close();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
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.
|
|
226
|
+
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'))} "$@"
|
|
229
|
+
`;
|
|
230
|
+
fs.writeFileSync(shimPath(), shim, { mode: 0o700 });
|
|
231
|
+
fs.chmodSync(shimPath(), 0o700);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function openUrl(url) {
|
|
235
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
236
|
+
try {
|
|
237
|
+
execFile(cmd, [url], { stdio: 'ignore' }, () => undefined).unref();
|
|
238
|
+
} catch {
|
|
239
|
+
// the link is printed anyway
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const tilde = (p) => (p.startsWith(os.homedir()) ? `~${p.slice(os.homedir().length)}` : p);
|
|
244
|
+
|
|
245
|
+
// ---------- status, off/on, uninstall ----------
|
|
246
|
+
|
|
247
|
+
async function status() {
|
|
248
|
+
const cfg = readConfig();
|
|
249
|
+
if (!cfg) return console.log('Not set up. Run npx @winding-labs/glow');
|
|
250
|
+
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'}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function toggle(off) {
|
|
261
|
+
const cfg = readConfig();
|
|
262
|
+
if (!cfg) fail('Not set up. Run npx @winding-labs/glow');
|
|
263
|
+
writeConfig({ ...cfg, off });
|
|
264
|
+
ok(off ? 'Off: nothing is sent from this computer until you run npx @winding-labs/glow on' : 'On: sessions in linked repos report to Glow again');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function messages(on) {
|
|
268
|
+
const cfg = readConfig();
|
|
269
|
+
if (!cfg) fail('Not set up. Run npx @winding-labs/glow');
|
|
270
|
+
writeConfig({ ...cfg, messages: on });
|
|
271
|
+
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
|
+
}
|
|
273
|
+
|
|
274
|
+
async function remove() {
|
|
275
|
+
const cfg = readConfig();
|
|
276
|
+
for (const a of Object.keys(AGENTS)) {
|
|
277
|
+
try {
|
|
278
|
+
const file = uninstall(a);
|
|
279
|
+
if (file) ok(`${AGENTS[a].label}: hook removed from ${tilde(file)}`);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
warn(`${AGENTS[a].label}: couldn’t update its settings (${e.message}); remove the Glow hook by hand.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (cfg) {
|
|
285
|
+
const res = await api(cfg, 'DELETE', '/api/v1/agent/computer', undefined, 10_000);
|
|
286
|
+
if (res.status === 204 || res.status === 401) ok('This computer’s key is revoked');
|
|
287
|
+
else warn(`Couldn’t reach Glow to revoke the key (${res.status}). Remove the computer in Glow › Connections.`);
|
|
288
|
+
}
|
|
289
|
+
fs.rmSync(dir(), { recursive: true, force: true });
|
|
290
|
+
ok(`Deleted ${tilde(dir())}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
main().catch((e) => fail(e?.message ?? String(e)));
|
package/lib/agents.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Which coding agents are on this computer, and adding or removing the Glow hook in each one's own settings:
|
|
2
|
+
// Claude Code ~/.claude/settings.json hooks.Stop: the hook in the background (asyncRewake), so it can wake an idle
|
|
3
|
+
// session when a message arrives
|
|
4
|
+
// Codex ~/.codex/hooks.json hooks.Stop: the hook before the turn ends (≤ 15 s; it takes about a second)
|
|
5
|
+
// Every other key in those files is kept as it was. A file that isn't valid JSON is never overwritten.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { dir, writeFileAtomic } from './core.mjs';
|
|
10
|
+
|
|
11
|
+
export const AGENTS = {
|
|
12
|
+
claude_code: { label: 'Claude Code', bin: 'claude', home: '.claude', file: ['.claude', 'settings.json'], mode: 'claude-stop' },
|
|
13
|
+
codex: { label: 'Codex', bin: 'codex', home: '.codex', file: ['.codex', 'hooks.json'], mode: 'codex-stop' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const settingsFile = (id) => path.join(os.homedir(), ...AGENTS[id].file);
|
|
17
|
+
export const shimPath = () => path.join(dir(), 'hook');
|
|
18
|
+
|
|
19
|
+
/** On PATH, or its settings folder exists. */
|
|
20
|
+
export function detect() {
|
|
21
|
+
const dirs = String(process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
22
|
+
const onPath = (bin) => dirs.some((d) => {
|
|
23
|
+
try {
|
|
24
|
+
fs.accessSync(path.join(d, bin), fs.constants.X_OK);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return Object.keys(AGENTS).filter((id) => onPath(AGENTS[id].bin) || fs.existsSync(path.join(os.homedir(), AGENTS[id].home)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** POSIX single-quoting for a path in a hook command. */
|
|
34
|
+
export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
35
|
+
|
|
36
|
+
export function hookEntry(id, shim = shimPath()) {
|
|
37
|
+
const command = `${shq(shim)} ${AGENTS[id].mode}`;
|
|
38
|
+
return id === 'claude_code' ? { type: 'command', command, asyncRewake: true, timeout: 1800 } : { type: 'command', command, timeout: 15 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One of ours: a command that runs ~/.glow/hook. */
|
|
42
|
+
export const isOurs = (h) => !!h && typeof h === 'object' && typeof h.command === 'string' && /[/\\]\.glow[/\\]hook['"]?\s/.test(`${h.command} `);
|
|
43
|
+
|
|
44
|
+
/** The settings with our Stop hook (replacing any older one of ours); everything else untouched. */
|
|
45
|
+
export function withHook(settings, entry) {
|
|
46
|
+
const s = { ...settings };
|
|
47
|
+
if (s.hooks !== undefined && (typeof s.hooks !== 'object' || s.hooks === null || Array.isArray(s.hooks))) throw new Error('its "hooks" isn’t an object');
|
|
48
|
+
const hooks = { ...(s.hooks ?? {}) };
|
|
49
|
+
if (hooks.Stop !== undefined && !Array.isArray(hooks.Stop)) throw new Error('its "hooks.Stop" isn’t a list');
|
|
50
|
+
const groups = stripOurs(hooks.Stop ?? []);
|
|
51
|
+
hooks.Stop = [...groups, { hooks: [entry] }];
|
|
52
|
+
s.hooks = hooks;
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The settings without our hook; empty groups and an empty "hooks" go too. */
|
|
57
|
+
export function withoutHook(settings) {
|
|
58
|
+
if (!settings?.hooks || typeof settings.hooks !== 'object' || !Array.isArray(settings.hooks.Stop)) return settings;
|
|
59
|
+
const s = { ...settings, hooks: { ...settings.hooks } };
|
|
60
|
+
const groups = stripOurs(s.hooks.Stop);
|
|
61
|
+
if (groups.length) s.hooks.Stop = groups;
|
|
62
|
+
else delete s.hooks.Stop;
|
|
63
|
+
if (!Object.keys(s.hooks).length) delete s.hooks;
|
|
64
|
+
return s;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stripOurs(groups) {
|
|
68
|
+
return groups
|
|
69
|
+
.map((g) => (g && typeof g === 'object' && Array.isArray(g.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isOurs(h)) } : g))
|
|
70
|
+
.filter((g) => !(g && typeof g === 'object' && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const hasHook = (settings) => Array.isArray(settings?.hooks?.Stop) && settings.hooks.Stop.some((g) => Array.isArray(g?.hooks) && g.hooks.some(isOurs));
|
|
74
|
+
|
|
75
|
+
/** The file's JSON ({} when missing). Throws when it exists but isn't a JSON object, so it's never clobbered. */
|
|
76
|
+
export function readJson(file) {
|
|
77
|
+
if (!fs.existsSync(file)) return {};
|
|
78
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
79
|
+
if (!text.trim()) return {};
|
|
80
|
+
const v = JSON.parse(text);
|
|
81
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('it isn’t a JSON object');
|
|
82
|
+
return v;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write next to the original with its mode kept; the first time, the original is copied to <file>.before-glow. A
|
|
87
|
+
* symlinked settings file (dotfiles) is written through to its target, so the link stays a link.
|
|
88
|
+
*/
|
|
89
|
+
export function writeJson(file, value) {
|
|
90
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
91
|
+
const target = fs.existsSync(file) ? fs.realpathSync(file) : file;
|
|
92
|
+
let mode = 0o644;
|
|
93
|
+
if (fs.existsSync(target)) {
|
|
94
|
+
mode = fs.statSync(target).mode & 0o777;
|
|
95
|
+
const backup = `${file}.before-glow`;
|
|
96
|
+
if (!fs.existsSync(backup)) fs.copyFileSync(target, backup);
|
|
97
|
+
}
|
|
98
|
+
writeFileAtomic(target, `${JSON.stringify(value, null, 2)}\n`, mode);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function install(id) {
|
|
102
|
+
const file = settingsFile(id);
|
|
103
|
+
writeJson(file, withHook(readJson(file), hookEntry(id)));
|
|
104
|
+
return file;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function uninstall(id) {
|
|
108
|
+
const file = settingsFile(id);
|
|
109
|
+
if (!fs.existsSync(file)) return null;
|
|
110
|
+
const before = readJson(file);
|
|
111
|
+
if (!hasHook(before)) return null;
|
|
112
|
+
writeJson(file, withoutHook(before));
|
|
113
|
+
return file;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const installed = (id) => {
|
|
117
|
+
try {
|
|
118
|
+
return hasHook(readJson(settingsFile(id)));
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
};
|
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Shared by the installer (bin/glow.mjs) and the hook (~/.glow/hook.mjs). Plain Node 18+, no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// Safety (docs/security/review-2026-11-02-agent-reports.md):
|
|
4
|
+
// - The only host this ever talks to is Glow's API: https://app.meetglow.ai (or staging.meetglow.ai), read from
|
|
5
|
+
// ~/.glow/config.json (written at install, mode 0600) and checked again on every use. Never from an environment
|
|
6
|
+
// variable (the hook runs with an empty environment but HOME and PATH). A local API (http://localhost) only when
|
|
7
|
+
// installed with --dev. Redirects are refused, so the key never follows one.
|
|
8
|
+
// - The key (glowc_…) can only report sessions, read the linked repo names, and fetch messages for this computer.
|
|
9
|
+
// - Nothing is sent from a repo that isn't linked to a product in Glow; the reply is masked for keys before it leaves.
|
|
10
|
+
// - git runs without a shell, with hooks, fsmonitor and signature checks off and none of the caller's GIT_* variables,
|
|
11
|
+
// so a repo (or its .git/config) can't make it run anything.
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const VERSION = '0.1.0';
|
|
19
|
+
export const DEFAULT_API = 'https://app.meetglow.ai';
|
|
20
|
+
export const dir = () => path.join(os.homedir(), '.glow');
|
|
21
|
+
export const configPath = () => path.join(dir(), 'config.json');
|
|
22
|
+
|
|
23
|
+
export const KEY_RE = /^glowc_[A-Za-z0-9_-]{43}$/;
|
|
24
|
+
export const SESSION_RE = /^[A-Za-z0-9._:-]{1,100}$/;
|
|
25
|
+
export const REPO_RE = /^[a-z0-9](?:[a-z0-9-]{0,38})\/[a-z0-9._-]{1,100}$/;
|
|
26
|
+
|
|
27
|
+
const HOSTS = new Set(['app.meetglow.ai', 'staging.meetglow.ai']);
|
|
28
|
+
|
|
29
|
+
/** Glow's API only: https on app.meetglow.ai (or staging), no port, path, query or credentials; localhost with --dev. */
|
|
30
|
+
export function apiOk(url, allowLocal = false) {
|
|
31
|
+
let u;
|
|
32
|
+
try {
|
|
33
|
+
u = new URL(url);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) return false;
|
|
38
|
+
if (u.protocol === 'https:' && !u.port && HOSTS.has(u.hostname)) return true;
|
|
39
|
+
return allowLocal && u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1');
|
|
40
|
+
}
|
|
41
|
+
export const apiBase = (url) => new URL(url).origin;
|
|
42
|
+
|
|
43
|
+
// ---------- config ----------
|
|
44
|
+
|
|
45
|
+
/** ~/.glow/config.json, or null when missing, unreadable or not well-formed (then nothing is sent). */
|
|
46
|
+
export function readConfig() {
|
|
47
|
+
try {
|
|
48
|
+
const c = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
49
|
+
if (!c || typeof c !== 'object' || !KEY_RE.test(c.key ?? '') || !apiOk(c.api, c.dev === true)) return null;
|
|
50
|
+
return c;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Atomic, owner-only. */
|
|
57
|
+
export function writeConfig(c) {
|
|
58
|
+
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
59
|
+
fs.chmodSync(dir(), 0o700);
|
|
60
|
+
writeFileAtomic(configPath(), `${JSON.stringify(c, null, 2)}\n`, 0o600);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeFileAtomic(file, text, mode) {
|
|
64
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
65
|
+
fs.writeFileSync(tmp, text, { mode });
|
|
66
|
+
fs.renameSync(tmp, file);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------- the API ----------
|
|
70
|
+
|
|
71
|
+
/** One call to Glow's API with the computer's key. Never throws: { status, json } (status 0 when unreachable). */
|
|
72
|
+
export async function api(c, method, p, body, timeoutMs = 10_000) {
|
|
73
|
+
if (!apiOk(c.api, c.dev === true)) return { status: 0, json: null };
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`${apiBase(c.api)}${p}`, {
|
|
76
|
+
method,
|
|
77
|
+
redirect: 'error',
|
|
78
|
+
headers: {
|
|
79
|
+
...(c.key ? { authorization: `Bearer ${c.key}` } : {}),
|
|
80
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
81
|
+
'x-glow-client': `glow-cli/${VERSION}`,
|
|
82
|
+
'user-agent': `glow-cli/${VERSION} (${process.platform})`,
|
|
83
|
+
},
|
|
84
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
85
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
86
|
+
});
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
let json = null;
|
|
89
|
+
try {
|
|
90
|
+
json = text ? JSON.parse(text) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
json = null;
|
|
93
|
+
}
|
|
94
|
+
return { status: res.status, json };
|
|
95
|
+
} catch {
|
|
96
|
+
return { status: 0, json: null };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------- git ----------
|
|
101
|
+
|
|
102
|
+
function git(cwd, args) {
|
|
103
|
+
try {
|
|
104
|
+
const opts = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'log.showSignature=false', '-c', 'core.pager=cat'];
|
|
105
|
+
return execFileSync('git', ['-C', cwd, ...opts, ...args], {
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
timeout: 3000,
|
|
108
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
109
|
+
// Not the caller's GIT_* (GIT_CONFIG_PARAMETERS and friends can set any config, GIT_DIR another repo).
|
|
110
|
+
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: os.homedir(), GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_NOSYSTEM: '1' },
|
|
111
|
+
maxBuffer: 256 * 1024,
|
|
112
|
+
}).trim();
|
|
113
|
+
} catch {
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A GitHub remote URL → "owner/name" (lower-case), else null. */
|
|
119
|
+
export function parseRemote(url) {
|
|
120
|
+
const m = /^(?:git@github\.com:|ssh:\/\/git@github\.com(?::22)?\/|https:\/\/(?:[^@/\s]+@)?github\.com\/)([A-Za-z0-9-]+\/[A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(String(url).trim());
|
|
121
|
+
const r = m?.[1]?.toLowerCase();
|
|
122
|
+
return r && REPO_RE.test(r) && !r.endsWith('/.') && !r.endsWith('/..') ? r : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The GitHub repo a directory is in (its origin), or null. */
|
|
126
|
+
export const repoOf = (cwd) => (cwd && fs.existsSync(cwd) ? parseRemote(git(cwd, ['config', '--get', 'remote.origin.url'])) : null);
|
|
127
|
+
|
|
128
|
+
/** Branch and the commits on it that aren't on the default branch yet (newest first, ≤ 5). */
|
|
129
|
+
export function gitContext(cwd) {
|
|
130
|
+
const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
131
|
+
const base = git(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']) || 'origin/main';
|
|
132
|
+
const out = { ...(branch && branch !== 'HEAD' && branch.length <= 200 ? { branch } : {}), commits: [] };
|
|
133
|
+
if (!branch || `origin/${branch}` === base) return out;
|
|
134
|
+
for (const line of git(cwd, ['log', '--no-show-signature', '-n', '5', '--format=%H%x1f%s', `${base}..HEAD`]).split('\n')) {
|
|
135
|
+
const [sha, title] = line.split('\x1f');
|
|
136
|
+
if (sha && /^[0-9a-f]{40}$/.test(sha) && title) out.commits.push({ sha: sha.slice(0, 12), title: mask(title).slice(0, 300) });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A pull request link on this repo, from the reply (the last one wins). */
|
|
142
|
+
export function prIn(text, repo) {
|
|
143
|
+
let found;
|
|
144
|
+
for (const m of String(text).matchAll(/https:\/\/github\.com\/([A-Za-z0-9-]+\/[A-Za-z0-9._-]+)\/pull\/(\d{1,7})\b/g)) {
|
|
145
|
+
if (m[1].toLowerCase() === repo) found = `https://github.com/${m[1]}/pull/${m[2]}`;
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Text from the server, safe to print in a terminal: no control characters (escape sequences), one line, clipped. */
|
|
151
|
+
export const printable = (s, max = 80) => String(s ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]/g, '').slice(0, max);
|
|
152
|
+
|
|
153
|
+
// ---------- masking ----------
|
|
154
|
+
|
|
155
|
+
const KEY_SHAPES = [
|
|
156
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
|
|
157
|
+
/\b(?:sk|rk|pk)_(?:live|test)_[0-9A-Za-z]{8,}/g,
|
|
158
|
+
/\bwhsec_[0-9A-Za-z+/=]{16,}/g,
|
|
159
|
+
/\bsk-or-v1-[0-9a-f]{16,}/g,
|
|
160
|
+
/\bsk-(?:ant-|proj-)?[0-9A-Za-z_-]{20,}/g,
|
|
161
|
+
/\bgithub_pat_[0-9A-Za-z_]{20,}/g,
|
|
162
|
+
/\bgh[pousr]_[0-9A-Za-z]{20,}/g,
|
|
163
|
+
/\bglpat-[0-9A-Za-z_-]{20,}/g,
|
|
164
|
+
/\bphx_[0-9A-Za-z]{20,}/g,
|
|
165
|
+
/\bGOCSPX-[0-9A-Za-z_-]{20,}/g,
|
|
166
|
+
/\bAIza[0-9A-Za-z_-]{30,}/g,
|
|
167
|
+
/\bya29\.[0-9A-Za-z_.-]{20,}/g,
|
|
168
|
+
/\bEAA[0-9A-Za-z]{40,}/g,
|
|
169
|
+
/\bxox[abposr]-[0-9A-Za-z-]{10,}/g,
|
|
170
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
171
|
+
/\bSG\.[0-9A-Za-z_-]{16,}\.[0-9A-Za-z_-]{16,}/g,
|
|
172
|
+
/\bnpm_[0-9A-Za-z]{30,}/g,
|
|
173
|
+
/\bshp(?:at|ss|ca|pa)_[0-9a-fA-F]{20,}/g,
|
|
174
|
+
/\beyJ[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}/g,
|
|
175
|
+
/\bglowc?_[0-9A-Za-z_-]{40,}/g,
|
|
176
|
+
];
|
|
177
|
+
const LABELLED = /(\b(?:api[_ -]?key|secret|token|password|passwd|pwd|access[_ -]?key|client[_ -]?secret|private[_ -]?key|auth(?:orization)?)["']?\s*[:=]\s*["']?(?:bearer\s+|basic\s+)?)([^\s"',;&#[\]<>]{8,})/gi;
|
|
178
|
+
const BEARER = /\b(bearer\s+)([0-9A-Za-z._~+/-]{16,}=*)/gi;
|
|
179
|
+
const URL_CREDS = /\b(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
|
|
180
|
+
|
|
181
|
+
/** Keys, tokens, passwords and credentials in URLs → [masked]. The server scrubs again before any model sees it. */
|
|
182
|
+
export function mask(text) {
|
|
183
|
+
let s = String(text);
|
|
184
|
+
for (const re of KEY_SHAPES) s = s.replace(re, '[masked]');
|
|
185
|
+
return s.replace(LABELLED, '$1[masked]').replace(BEARER, '$1[masked]').replace(URL_CREDS, '$1[masked]@');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- this computer ----------
|
|
189
|
+
|
|
190
|
+
/** "Ilya’s MacBook Pro": the computer's own name (macOS), else the host name. Letters, digits, spaces, ’'._- only. */
|
|
191
|
+
export function computerName() {
|
|
192
|
+
let name = '';
|
|
193
|
+
if (process.platform === 'darwin') {
|
|
194
|
+
try {
|
|
195
|
+
name = execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
196
|
+
} catch {
|
|
197
|
+
name = '';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!name) name = os.hostname().replace(/\.(local|lan|home)$/i, '').replace(/[-_]+/g, ' ');
|
|
201
|
+
return name.normalize('NFC').replace(/[^\p{L}\p{N} ’'._-]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 60) || 'My computer';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const sha = (s) => createHash('sha256').update(s).digest('hex');
|
package/lib/hook.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The Glow hook, run by the coding agent when it finishes a reply (installed to ~/.glow by `npx @winding-labs/glow`).
|
|
2
|
+
//
|
|
3
|
+
// claude-stop Claude Code's Stop hook, run in the background (asyncRewake). Reports the reply, then listens for a
|
|
4
|
+
// message from Glow for up to 25 minutes while the session is idle. A message wakes Claude: it is printed
|
|
5
|
+
// to stderr and the hook exits 2. A newer reply in the same session takes over the listening.
|
|
6
|
+
// codex-stop Codex's Stop hook (runs before the turn ends; ~1 s). Reports the reply; a waiting message comes back as
|
|
7
|
+
// {"decision":"block","reason":…}, which Codex takes as the next prompt.
|
|
8
|
+
//
|
|
9
|
+
// `npx @winding-labs/glow messages off` stops both: Glow then holds no message for this computer's sessions.
|
|
10
|
+
//
|
|
11
|
+
// Quiet by design: whatever goes wrong, it exits 0 and the agent carries on. Nothing is written to a log.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { api, dir, gitContext, mask, prIn, printable, readConfig, repoOf, SESSION_RE, sha, writeConfig } from './core.mjs';
|
|
15
|
+
|
|
16
|
+
const LISTEN_MS = 25 * 60_000;
|
|
17
|
+
// GLOW_POLL_MS only speeds up the tests (timing, never where anything is sent).
|
|
18
|
+
const POLL_MS = Math.max(50, Number(process.env.GLOW_POLL_MS) || 15_000);
|
|
19
|
+
const REPOS_FRESH_MS = 2 * 60_000;
|
|
20
|
+
const MESSAGE_MAX = 20_000;
|
|
21
|
+
|
|
22
|
+
const mode = process.argv[2];
|
|
23
|
+
const agent = mode === 'codex-stop' ? 'codex' : mode === 'claude-stop' ? 'claude_code' : null;
|
|
24
|
+
|
|
25
|
+
main().then(
|
|
26
|
+
(code) => process.exit(code ?? 0),
|
|
27
|
+
() => process.exit(0),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
if (!agent) return 0;
|
|
32
|
+
const input = await stdinJson();
|
|
33
|
+
const cfg = readConfig();
|
|
34
|
+
if (!cfg || cfg.off) return 0;
|
|
35
|
+
const session = typeof input.session_id === 'string' ? input.session_id : '';
|
|
36
|
+
if (!SESSION_RE.test(session)) return 0;
|
|
37
|
+
const cwd = typeof input.cwd === 'string' && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
|
|
38
|
+
const repo = repoOf(cwd);
|
|
39
|
+
if (!repo || !(await linked(cfg, repo))) return 0;
|
|
40
|
+
|
|
41
|
+
const reply = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : '';
|
|
42
|
+
const inbox = cfg.messages !== false;
|
|
43
|
+
let messages = [];
|
|
44
|
+
if (reply.trim()) {
|
|
45
|
+
const ctx = gitContext(cwd);
|
|
46
|
+
const pr = prIn(reply, repo);
|
|
47
|
+
const res = await api(cfg, 'POST', '/api/v1/agent/reports', {
|
|
48
|
+
agent, session_id: session, repo, ...(ctx.branch ? { branch: ctx.branch } : {}), message: mask(reply).slice(0, MESSAGE_MAX), commits: ctx.commits, ...(pr ? { pr_url: pr } : {}), inbox,
|
|
49
|
+
}, agent === 'codex' ? 6_000 : 10_000);
|
|
50
|
+
if (res.status === 401 || res.status === 403) return 0;
|
|
51
|
+
if (res.json?.skipped === 'repo_not_linked' && Array.isArray(res.json.repos)) saveRepos(cfg, res.json.repos);
|
|
52
|
+
messages = inbox && Array.isArray(res.json?.messages) ? res.json.messages : [];
|
|
53
|
+
}
|
|
54
|
+
if (messages.length) return deliver(messages);
|
|
55
|
+
if (agent === 'claude_code' && inbox) return listen(cfg, session);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Is this repo linked to a product? The list is cached in the config, refreshed when a repo isn't on it (≤ every 2 min). */
|
|
60
|
+
async function linked(cfg, repo) {
|
|
61
|
+
const repos = Array.isArray(cfg.repos) ? cfg.repos : [];
|
|
62
|
+
if (repos.includes(repo)) return true;
|
|
63
|
+
if (Date.now() - Number(cfg.repos_at ?? 0) < REPOS_FRESH_MS) return false;
|
|
64
|
+
const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 5_000);
|
|
65
|
+
if (res.status !== 200 || !Array.isArray(res.json?.repos)) return false;
|
|
66
|
+
saveRepos(cfg, res.json.repos);
|
|
67
|
+
return res.json.repos.includes(repo);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function saveRepos(cfg, repos) {
|
|
71
|
+
try {
|
|
72
|
+
const fresh = readConfig() ?? cfg;
|
|
73
|
+
writeConfig({ ...fresh, repos: repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
|
|
74
|
+
} catch {
|
|
75
|
+
// a read-only home: the next run asks again
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The messages, as the person who sent them, for the agent. Claude: stderr + exit 2. Codex: a block decision. */
|
|
80
|
+
function deliver(messages) {
|
|
81
|
+
const text = messages
|
|
82
|
+
.filter((m) => m && typeof m.body === 'string')
|
|
83
|
+
.map((m) => `Message from ${printable(m.from) || 'a teammate'} in Glow (sent to this session at ${timeOf(m.sent_at)}):\n\n${m.body}`)
|
|
84
|
+
.join('\n\n---\n\n');
|
|
85
|
+
if (!text) return 0;
|
|
86
|
+
if (agent === 'codex') {
|
|
87
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: text })}\n`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
process.stderr.write(`${text}\n`);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const timeOf = (iso) => {
|
|
95
|
+
const d = new Date(iso);
|
|
96
|
+
return Number.isNaN(+d) ? 'just now' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Claude Code, idle: ask Glow for a message every 15 s for up to 25 minutes. One listener per session: a newer reply
|
|
101
|
+
* writes its own token to the lock and this one stops. Stops too when Claude Code itself has gone.
|
|
102
|
+
*/
|
|
103
|
+
async function listen(cfg, session) {
|
|
104
|
+
const locks = path.join(dir(), 'sessions');
|
|
105
|
+
fs.mkdirSync(locks, { recursive: true, mode: 0o700 });
|
|
106
|
+
const lock = path.join(locks, `${sha(`${agent}:${session}`).slice(0, 24)}.lock`);
|
|
107
|
+
const token = `${process.pid}:${Date.now()}`;
|
|
108
|
+
fs.writeFileSync(lock, token, { mode: 0o600 });
|
|
109
|
+
tidy(locks);
|
|
110
|
+
const parent = process.ppid;
|
|
111
|
+
const until = Date.now() + LISTEN_MS;
|
|
112
|
+
const q = `/api/v1/agent/inbox?agent=${agent}&session=${encodeURIComponent(session)}`;
|
|
113
|
+
try {
|
|
114
|
+
while (Date.now() < until) {
|
|
115
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
116
|
+
if (!mine(lock, token) || !alive(parent)) return 0;
|
|
117
|
+
const now = readConfig();
|
|
118
|
+
if (!now || now.off || now.messages === false) return 0;
|
|
119
|
+
const res = await api(now, 'GET', q, undefined, 10_000);
|
|
120
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) return 0;
|
|
121
|
+
const messages = Array.isArray(res.json?.messages) ? res.json.messages : [];
|
|
122
|
+
if (messages.length) return deliver(messages);
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
} finally {
|
|
126
|
+
if (mine(lock, token)) fs.rmSync(lock, { force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mine = (lock, token) => {
|
|
131
|
+
try {
|
|
132
|
+
return fs.readFileSync(lock, 'utf8') === token;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const alive = (pid) => {
|
|
138
|
+
if (process.ppid !== pid || pid <= 1) return false;
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pid, 0);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return e?.code === 'EPERM';
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
/** Locks older than a day go. */
|
|
147
|
+
function tidy(locks) {
|
|
148
|
+
try {
|
|
149
|
+
for (const f of fs.readdirSync(locks)) {
|
|
150
|
+
const p = path.join(locks, f);
|
|
151
|
+
if (Date.now() - fs.statSync(p).mtimeMs > 86_400_000) fs.rmSync(p, { force: true });
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// best effort
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The hook's input (≤ 4 MB of JSON on stdin), or {}. */
|
|
159
|
+
async function stdinJson() {
|
|
160
|
+
const chunks = [];
|
|
161
|
+
let n = 0;
|
|
162
|
+
for await (const c of process.stdin) {
|
|
163
|
+
n += c.length;
|
|
164
|
+
if (n > 4 * 1024 * 1024) return {};
|
|
165
|
+
chunks.push(c);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const v = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
169
|
+
return v && typeof v === 'object' ? v : {};
|
|
170
|
+
} catch {
|
|
171
|
+
return {};
|
|
172
|
+
}
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@winding-labs/glow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Send what your coding agents (Claude Code, Codex) do to your Glow channels, and message them back from Glow.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "glow": "bin/glow.mjs" },
|
|
7
|
+
"files": ["bin/", "lib/", "README.md", "SECURITY.md", "LICENSE"],
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"scripts": { "test": "bun test", "typecheck": "true" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"homepage": "https://meetglow.ai",
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/Winding-Labs/glow-agent.git" },
|
|
13
|
+
"bugs": "https://github.com/Winding-Labs/glow-agent/issues",
|
|
14
|
+
"keywords": ["glow", "claude-code", "codex", "coding-agents", "hooks"],
|
|
15
|
+
"publishConfig": { "access": "public" }
|
|
16
|
+
}
|