@winding-labs/glow 0.1.0 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -7
- package/SECURITY.md +48 -2
- package/bin/glow.mjs +280 -39
- package/lib/agents.mjs +54 -3
- package/lib/core.mjs +165 -3
- package/lib/hook.mjs +14 -7
- package/lib/runner.mjs +669 -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/lib/runner.mjs
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// The runner: `npx @winding-labs/glow run` (docs/security/review-2026-11-02-computer-sessions.md). Opt-in: only while it
|
|
2
|
+
// runs can the person who paired this computer start Claude Code sessions on it from Glow's Agents tab.
|
|
3
|
+
//
|
|
4
|
+
// - Every 5 s (10 s once nothing has run for 10 minutes) it asks Glow for work with the computer's key, saying which
|
|
5
|
+
// linked repos it can run in (owner/name only, never a path) and which sessions it runs.
|
|
6
|
+
// - It doesn't take Glow's word for everything: a mode above --max-mode (read by default), a repo outside --repo (when
|
|
7
|
+
// given), a folder Claude Code hasn't been trusted in, a repo checked out twice (unless pinned with --repo
|
|
8
|
+
// owner/name=path) and a session id it has seen before are refused here, whatever Glow says.
|
|
9
|
+
// - A repo with worktrees runs in its main checkout (the one whose .git is a folder); two main checkouts of one repo
|
|
10
|
+
// are still refused.
|
|
11
|
+
// - A session runs as `claude -p` in that repo's checkout: arguments as a list (no shell), the prompt on stdin (never
|
|
12
|
+
// an argument, so no prompt can pass for a flag), in a mode that never skips permission checks (read: plan, edit:
|
|
13
|
+
// acceptEdits, auto: auto) with nobody there to approve anything else (--permission-prompts none), and never Glow's
|
|
14
|
+
// own CLI or files (--disallowedTools DENY: it can't turn Glow off, stop the runner or read the computer's key).
|
|
15
|
+
// The model and effort Glow names go to --model and --effort only if they're on the lists below (medium if none).
|
|
16
|
+
// - Inside a session `glow` refuses to change anything (GLOW_RUN_SESSION in its environment, a session's Claude Code
|
|
17
|
+
// among its parents, or a session's process group: inRunnerSession).
|
|
18
|
+
// - Its stream says what it's doing: the last tool or command goes to Glow, short and masked (core activityOf), at
|
|
19
|
+
// most every few seconds; then done, failed or stopped, and its reply becomes the session's post (masked, as the
|
|
20
|
+
// hook sends it).
|
|
21
|
+
// - Stop in Glow, pausing the computer, `glow off`, a revoked key or Ctrl-C end its sessions: SIGTERM to every process
|
|
22
|
+
// group under its Claude Code (its Bash tool and hooks start each command in a group of its own), then SIGKILL.
|
|
23
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import readline from 'node:readline';
|
|
29
|
+
import { activityOf, api, dir, gitContext, ignoredOf, mask, prIn, printable, readConfig, repoOf, runMarker, tools, VERSION, writeConfig, writeFileAtomic } from './core.mjs';
|
|
30
|
+
|
|
31
|
+
// GLOW_RUN_POLL_MS only speeds up the tests (timing, never where anything is sent or what runs).
|
|
32
|
+
const POLL_MS = Math.max(50, Number(process.env.GLOW_RUN_POLL_MS) || 5000);
|
|
33
|
+
const UPDATE_MS = Math.min(POLL_MS, 4000);
|
|
34
|
+
const REPOS_EVERY_MS = 5 * 60_000;
|
|
35
|
+
const MAX_MS = 3 * 3600_000;
|
|
36
|
+
const AT_ONCE = 3;
|
|
37
|
+
const KILL_AFTER_MS = 5000;
|
|
38
|
+
const MESSAGE_MAX = 20_000;
|
|
39
|
+
const IDLE_AFTER_MS = 10 * 60_000;
|
|
40
|
+
/** The modes, least to most it may do on its own (--max-mode, read unless widened). */
|
|
41
|
+
export const MODES = ['read', 'edit', 'auto'];
|
|
42
|
+
/** The --model and --effort values a session may ask for (core SESSION_MODELS, SESSION_EFFORTS). */
|
|
43
|
+
export const MODELS = ['opus', 'sonnet', 'haiku', 'fable'];
|
|
44
|
+
export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
45
|
+
/** In every session's environment (and passed through ~/.glow/glow): glow changes nothing from inside one. */
|
|
46
|
+
export const SESSION_ENV = 'GLOW_RUN_SESSION';
|
|
47
|
+
/**
|
|
48
|
+
* What a session may never use, whatever its mode or any allow rule (the plugin's included; a deny rule wins): Glow's
|
|
49
|
+
* CLI however it's spelled (~/.glow/glow, $HOME/.glow/…, npx/bunx @winding-labs/glow, node …/glow.mjs) and its files
|
|
50
|
+
* (the computer's key). Claude Code 2.1 matches `*` anywhere in a Bash rule, and each part of `a && b`.
|
|
51
|
+
*/
|
|
52
|
+
export const DENY = ['Bash(*.glow*)', 'Bash(*@winding-labs/glow*)', 'Bash(*glow.mjs*)', 'Read(~/.glow/**)', 'Edit(~/.glow/**)'];
|
|
53
|
+
/** Folders never searched for checkouts (besides hidden ones). */
|
|
54
|
+
const SKIP = new Set(['node_modules', 'Library', 'Applications', 'Pictures', 'Movies', 'Music', 'vendor', 'dist', 'build', 'target', 'Pods', 'DerivedData']);
|
|
55
|
+
|
|
56
|
+
export const statePath = () => path.join(dir(), 'runner.json');
|
|
57
|
+
export const logPath = () => path.join(dir(), 'runner.log');
|
|
58
|
+
|
|
59
|
+
/** The runner on this computer, if one is on: { pid, started_at, roots, sessions }. Only if that pid is alive and is
|
|
60
|
+
* still `glow … run` (a pid the system reused for something else is never signalled). */
|
|
61
|
+
export function runnerState() {
|
|
62
|
+
let s;
|
|
63
|
+
try {
|
|
64
|
+
s = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isInteger(s?.pid) || s.pid <= 1) return null;
|
|
69
|
+
try {
|
|
70
|
+
const cmd = execFileSync('ps', ['-o', 'command=', '-p', String(s.pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
|
|
71
|
+
return /glow(\.mjs)?\b.*\brun\b/.test(cmd) ? s : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null; // not running
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every process now: pid → { ppid, pgid }, from one `ps`. */
|
|
78
|
+
export function processes() {
|
|
79
|
+
const all = new Map();
|
|
80
|
+
const out = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
|
|
81
|
+
for (const line of out.split('\n')) {
|
|
82
|
+
const [pid, ppid, pgid] = line.trim().split(/\s+/).map(Number);
|
|
83
|
+
if (pid > 0) all.set(pid, { ppid, pgid });
|
|
84
|
+
}
|
|
85
|
+
return all;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every process group under this pid: its own, and each one its descendants run in (Claude Code's Bash tool and hooks
|
|
89
|
+
* start each command detached, in a group of its own). Never this process's group, init's or the kernel's. */
|
|
90
|
+
export function groupsUnder(root) {
|
|
91
|
+
let all;
|
|
92
|
+
try {
|
|
93
|
+
all = processes();
|
|
94
|
+
} catch {
|
|
95
|
+
return new Set([root]);
|
|
96
|
+
}
|
|
97
|
+
const kids = new Map();
|
|
98
|
+
for (const [pid, p] of all) kids.set(p.ppid, [...(kids.get(p.ppid) ?? []), pid]);
|
|
99
|
+
const groups = new Set([root]);
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const todo = [root]; todo.length; ) {
|
|
102
|
+
const p = todo.pop();
|
|
103
|
+
if (seen.has(p)) continue;
|
|
104
|
+
seen.add(p);
|
|
105
|
+
if (all.get(p)?.pgid) groups.add(all.get(p).pgid);
|
|
106
|
+
todo.push(...(kids.get(p) ?? []));
|
|
107
|
+
}
|
|
108
|
+
const mine = all.get(process.pid)?.pgid;
|
|
109
|
+
for (const g of groups) if (g <= 1 || g === mine) groups.delete(g);
|
|
110
|
+
return groups;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Signal each of these process groups (gone ones are skipped). */
|
|
114
|
+
function signalGroups(groups, sig) {
|
|
115
|
+
for (const g of groups) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(-g, sig);
|
|
118
|
+
} catch {
|
|
119
|
+
// gone
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** This process runs inside a session the runner started: the session's marker in its environment, one of the
|
|
125
|
+
* sessions' Claude Code among its parents, or its process group one of theirs. Any one is enough (a command can drop
|
|
126
|
+
* an environment variable, or start a group of its own). */
|
|
127
|
+
export function inRunnerSession(env = process.env, pid = process.pid) {
|
|
128
|
+
if (env[SESSION_ENV]) return true;
|
|
129
|
+
const s = runnerState();
|
|
130
|
+
const claudes = Array.isArray(s?.sessions) ? s.sessions.filter((x) => Number.isInteger(x) && x > 1) : [];
|
|
131
|
+
if (!claudes.length) return false;
|
|
132
|
+
let all;
|
|
133
|
+
try {
|
|
134
|
+
all = processes();
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (claudes.includes(all.get(pid)?.pgid)) return true;
|
|
139
|
+
for (let p = pid, n = 0; p > 1 && n < 200; p = all.get(p)?.ppid ?? 0, n++) if (claudes.includes(p)) return true;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The folder holding .git above (or at) this one. */
|
|
144
|
+
function topOf(folder) {
|
|
145
|
+
for (let f = path.resolve(folder); ; f = path.dirname(f)) {
|
|
146
|
+
if (fs.existsSync(path.join(f, '.git'))) return f;
|
|
147
|
+
if (path.dirname(f) === f) return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The linked repos checked out under these folders (a folder inside a checkout counts as that checkout): repo → every
|
|
153
|
+
* main checkout of it found, shallowest first (its worktrees, whose .git is a file, only when there's no main one).
|
|
154
|
+
* Hidden folders, symlinks and a checkout's own folders aren't searched; 3 levels, 4,000 folders.
|
|
155
|
+
*/
|
|
156
|
+
export function findRepos(roots, linked, ignored = [], depth = 3, limit = 4000) {
|
|
157
|
+
const want = new Set(linked);
|
|
158
|
+
const found = new Map();
|
|
159
|
+
const keep = (folder) => {
|
|
160
|
+
const repo = repoOf(folder);
|
|
161
|
+
if (!repo || !want.has(repo) || ignored.includes(repo)) return;
|
|
162
|
+
const real = fs.realpathSync(folder);
|
|
163
|
+
const all = found.get(repo) ?? [];
|
|
164
|
+
if (!all.includes(real)) found.set(repo, [...all, real]);
|
|
165
|
+
};
|
|
166
|
+
let seen = 0;
|
|
167
|
+
let level = [];
|
|
168
|
+
for (const r of roots) {
|
|
169
|
+
const top = topOf(r);
|
|
170
|
+
if (top) keep(top);
|
|
171
|
+
else level.push(path.resolve(r));
|
|
172
|
+
}
|
|
173
|
+
for (let d = 0; d <= depth && level.length; d++) {
|
|
174
|
+
const next = [];
|
|
175
|
+
for (const folder of level) {
|
|
176
|
+
if (++seen > limit) return mainsOf(found);
|
|
177
|
+
if (fs.existsSync(path.join(folder, '.git'))) {
|
|
178
|
+
keep(folder);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (d === depth) continue;
|
|
182
|
+
let entries = [];
|
|
183
|
+
try {
|
|
184
|
+
entries = fs.readdirSync(folder, { withFileTypes: true });
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) if (e.isDirectory() && !e.name.startsWith('.') && !SKIP.has(e.name)) next.push(path.join(folder, e.name));
|
|
189
|
+
}
|
|
190
|
+
level = next;
|
|
191
|
+
}
|
|
192
|
+
return mainsOf(found);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Each repo's main checkouts, if it has any (a worktree of it is the same repo, not a second copy). */
|
|
196
|
+
function mainsOf(found) {
|
|
197
|
+
for (const [repo, all] of found) {
|
|
198
|
+
const mains = all.filter((p) => isDir(path.join(p, '.git')));
|
|
199
|
+
if (mains.length) found.set(repo, mains);
|
|
200
|
+
}
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Has Claude Code been trusted in this folder (its "Do you trust the files in this folder?" accepted, here or in a folder
|
|
206
|
+
* above it, not the home folder itself)? `claude -p` skips that question, so a repo's own .claude settings and hooks
|
|
207
|
+
* would apply in a folder nobody looked at: the runner only runs where it was answered.
|
|
208
|
+
*/
|
|
209
|
+
export function trusted(folder, env = process.env) {
|
|
210
|
+
let projects = {};
|
|
211
|
+
try {
|
|
212
|
+
const file = env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(os.homedir(), '.claude.json');
|
|
213
|
+
projects = JSON.parse(fs.readFileSync(file, 'utf8'))?.projects ?? {};
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
let f;
|
|
218
|
+
try {
|
|
219
|
+
f = fs.realpathSync(folder);
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const home = os.homedir();
|
|
224
|
+
for (; f !== path.dirname(f); f = path.dirname(f)) {
|
|
225
|
+
if (f !== folder && f === home) break;
|
|
226
|
+
if (projects[f]?.hasTrustDialogAccepted === true) return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Claude Code on this computer: on PATH, or where its installer puts it. */
|
|
232
|
+
export function findClaude(env = process.env) {
|
|
233
|
+
const home = os.homedir();
|
|
234
|
+
const dirs = [...String(env.PATH ?? '').split(path.delimiter).filter(Boolean), path.join(home, '.local', 'bin'), path.join(home, '.claude', 'local'), '/opt/homebrew/bin', '/usr/local/bin'];
|
|
235
|
+
for (const d of dirs) {
|
|
236
|
+
const p = path.join(d, 'claude');
|
|
237
|
+
try {
|
|
238
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
239
|
+
return p;
|
|
240
|
+
} catch {
|
|
241
|
+
// not here
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What this Claude Code can do, from its --help: plan mode, the name of its plain permission mode, auto mode, the flags. */
|
|
248
|
+
export function claudeFlags(help) {
|
|
249
|
+
const h = String(help);
|
|
250
|
+
return {
|
|
251
|
+
plan: /"plan"/.test(h), manual: /"manual"/.test(h) ? 'manual' : 'default', auto: /"auto"/.test(h), prompts: h.includes('--permission-prompts'), sessionId: h.includes('--session-id'), name: /--name\b/.test(h),
|
|
252
|
+
model: /--model\b/.test(h), effort: /--effort\b/.test(h),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** claude's arguments for a session: print mode, stream-json, its session id, its mode's permissions (Look only: plan
|
|
257
|
+
* mode, which changes nothing), a name to find it by in `claude --resume`, its model and effort (medium unless Glow
|
|
258
|
+
* names another on the list). Never a flag that skips permission checks; the prompt goes on stdin. */
|
|
259
|
+
export function argsFor(mode, f, sessionId, title, model, effort) {
|
|
260
|
+
const perm = mode === 'auto' ? 'auto' : mode === 'edit' ? 'acceptEdits' : f.plan ? 'plan' : f.manual;
|
|
261
|
+
const name = `Glow: ${String(title).replace(/[^\p{L}\p{N} ’'.,:_-]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)}`;
|
|
262
|
+
return [
|
|
263
|
+
'-p', '--output-format', 'stream-json', '--verbose', ...(f.sessionId ? ['--session-id', sessionId] : []), '--permission-mode', perm,
|
|
264
|
+
...(f.prompts ? ['--permission-prompts', 'none'] : []), '--disallowedTools', ...DENY, ...(f.name ? ['--name', name] : []),
|
|
265
|
+
...(f.model && MODELS.includes(model) ? ['--model', model] : []), ...(f.effort ? ['--effort', EFFORTS.includes(effort) ? effort : 'medium'] : []),
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The environment a session gets: this one, minus a parent Claude Code's own markers and Glow's test knobs, with the
|
|
270
|
+
* basics a shell needs (the runner may have been started with only HOME and PATH, from /glow:run). */
|
|
271
|
+
function childEnv() {
|
|
272
|
+
const env = { ...process.env };
|
|
273
|
+
for (const k of Object.keys(env)) if (['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_SSE_PORT'].includes(k) || k.startsWith('GLOW_')) delete env[k];
|
|
274
|
+
env[SESSION_ENV] = '1';
|
|
275
|
+
let u = null;
|
|
276
|
+
try {
|
|
277
|
+
u = os.userInfo();
|
|
278
|
+
} catch {
|
|
279
|
+
u = null;
|
|
280
|
+
}
|
|
281
|
+
env.HOME ??= os.homedir();
|
|
282
|
+
env.SHELL ??= u?.shell || '/bin/sh';
|
|
283
|
+
if (u?.username) env.USER ??= u.username;
|
|
284
|
+
env.LANG ??= 'en_US.UTF-8';
|
|
285
|
+
return env;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
289
|
+
const MODE_LABEL = { read: 'look only', edit: 'edit files', auto: 'auto' };
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Run until told to stop. `say` prints a line (the terminal, or ~/.glow/runner.log in the background). Resolves with
|
|
293
|
+
* the exit code. `stop()` on the returned handle ends it (Ctrl-C): its sessions are stopped and Glow is told.
|
|
294
|
+
* maxMode the most a session may do on its own here (read | edit | auto); Glow can't ask for more
|
|
295
|
+
* allow repo → pinned checkout (or null): only these repos run here; empty: any linked repo found under `roots`
|
|
296
|
+
*/
|
|
297
|
+
export function startRunner({ roots, say = console.log, maxMode = 'read', allow = new Map() }) {
|
|
298
|
+
const sessions = new Map();
|
|
299
|
+
/** Every session id handed over while this runner runs: one sent again never runs twice. */
|
|
300
|
+
const seen = new Set();
|
|
301
|
+
let cfg = readConfig();
|
|
302
|
+
let repos = new Map();
|
|
303
|
+
/** Why a repo can't run here: repo → error code (not_trusted, repo_twice, no_checkout). */
|
|
304
|
+
let why = new Map();
|
|
305
|
+
let reposAt = 0;
|
|
306
|
+
let activeAt = Date.now();
|
|
307
|
+
let claude = null;
|
|
308
|
+
let flags = null;
|
|
309
|
+
let ending = null;
|
|
310
|
+
let paused = false;
|
|
311
|
+
let offline = false;
|
|
312
|
+
let wake = null;
|
|
313
|
+
const sleep = (ms) => new Promise((r) => {
|
|
314
|
+
const t = setTimeout(r, ms);
|
|
315
|
+
wake = () => {
|
|
316
|
+
clearTimeout(t);
|
|
317
|
+
r();
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const refreshRepos = async () => {
|
|
322
|
+
const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
|
|
323
|
+
if (res.status === 200 && Array.isArray(res.json?.repos)) {
|
|
324
|
+
try {
|
|
325
|
+
writeConfig({ ...(readConfig() ?? cfg), repos: res.json.repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
|
|
326
|
+
} catch {
|
|
327
|
+
// read-only home: the list is still used below
|
|
328
|
+
}
|
|
329
|
+
cfg = { ...cfg, repos: res.json.repos };
|
|
330
|
+
}
|
|
331
|
+
const linked = (Array.isArray(cfg.repos) ? cfg.repos : []).filter((r) => !allow.size || allow.has(r));
|
|
332
|
+
const found = findRepos(roots, linked, ignoredOf(cfg));
|
|
333
|
+
const next = new Map();
|
|
334
|
+
const not = new Map();
|
|
335
|
+
const notes = [];
|
|
336
|
+
for (const repo of linked) {
|
|
337
|
+
const pin = allow.get(repo);
|
|
338
|
+
let at = found.get(repo) ?? [];
|
|
339
|
+
if (pin) {
|
|
340
|
+
if (repoOf(pin) !== repo) {
|
|
341
|
+
notes.push(`${repo}: ${tilde(pin)} isn’t a checkout of it`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
at = [fs.realpathSync(pin)];
|
|
346
|
+
} catch {
|
|
347
|
+
continue; // gone since
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!at.length) continue;
|
|
351
|
+
if (ignoredOf(cfg).includes(repo)) continue;
|
|
352
|
+
if (at.length > 1) {
|
|
353
|
+
not.set(repo, 'repo_twice');
|
|
354
|
+
notes.push(`${repo} is checked out ${at.length} times (${at.map(tilde).join(', ')}): pick one with --repo ${repo}=PATH`);
|
|
355
|
+
} else if (!trusted(at[0])) {
|
|
356
|
+
not.set(repo, 'not_trusted');
|
|
357
|
+
notes.push(`${repo} (${tilde(at[0])}): Claude Code hasn’t been trusted in this folder. Open claude there once and say yes.`);
|
|
358
|
+
} else next.set(repo, at[0]);
|
|
359
|
+
}
|
|
360
|
+
const before = [...repos].join(',') + [...why].join(',');
|
|
361
|
+
repos = next;
|
|
362
|
+
why = not;
|
|
363
|
+
reposAt = Date.now();
|
|
364
|
+
if ([...repos].join(',') + [...why].join(',') !== before) {
|
|
365
|
+
say(repos.size ? `Sessions can run here in: ${[...repos].map(([r, p]) => `${r} (${tilde(p)})`).join(', ')}` : 'No linked repo can run here yet.');
|
|
366
|
+
for (const n of notes) say(` ! ${n}`);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** runner.json: who runs (`run stop` signals only this pid) and its sessions' process groups (whose commands may not
|
|
371
|
+
* turn Glow off or stop the runner: lib/runner inRunnerSession). */
|
|
372
|
+
const writeState = () => {
|
|
373
|
+
const mine = { pid: process.pid, started_at: startedAt, roots: roots.map(tilde), version: VERSION, max_mode: maxMode, sessions: [...sessions.values()].map((x) => x.child.pid).filter(Boolean) };
|
|
374
|
+
try {
|
|
375
|
+
writeFileAtomic(statePath(), `${JSON.stringify(mine)}\n`, 0o600);
|
|
376
|
+
} catch {
|
|
377
|
+
// a read-only home: run stop can't find it, Ctrl-C still works
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const startedAt = new Date().toISOString();
|
|
381
|
+
|
|
382
|
+
/** Tell Glow how a session goes. The answer may say to stop it. */
|
|
383
|
+
const send = async (x, body) => {
|
|
384
|
+
const res = await api(cfg, 'POST', `/api/v1/agent/sessions/${encodeURIComponent(x.id)}`, body, 10_000);
|
|
385
|
+
if (res.status === 200 && res.json?.stop === true && body.status === 'running') stop(x, 'stopped');
|
|
386
|
+
return res;
|
|
387
|
+
};
|
|
388
|
+
const progress = (x) => {
|
|
389
|
+
if (!x.dirty || x.sending || x.done) return;
|
|
390
|
+
x.dirty = false;
|
|
391
|
+
x.sending = true;
|
|
392
|
+
void send(x, { status: 'running', ...(x.sessionId ? { session_id: x.sessionId } : {}), ...(x.activity ? { activity: x.activity } : {}), turns: x.turns }).finally(() => {
|
|
393
|
+
x.sending = false;
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const fail = async (s, error, why) => {
|
|
398
|
+
say(`${clock()} ✗ ${s.repo} · ${printable(s.title, 80)}: ${why}`);
|
|
399
|
+
await api(cfg, 'POST', `/api/v1/agent/sessions/${encodeURIComponent(s.id)}`, { status: 'failed', error }, 10_000);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const launch = async (s) => {
|
|
403
|
+
// Handed over once: an id seen before (Glow sending it again, or anyone who could) never runs a second time.
|
|
404
|
+
if (seen.has(s.id)) return;
|
|
405
|
+
seen.add(s.id);
|
|
406
|
+
activeAt = Date.now();
|
|
407
|
+
if (allow.size && !allow.has(s.repo)) return fail(s, 'not_allowed', `${s.repo} isn’t one of this runner’s --repo`);
|
|
408
|
+
if (!MODES.includes(s.mode) || MODES.indexOf(s.mode) > MODES.indexOf(maxMode)) return fail(s, 'not_allowed', `this runner allows up to ${maxMode} (--max-mode)`);
|
|
409
|
+
if (why.has(s.repo)) return fail(s, why.get(s.repo), why.get(s.repo) === 'repo_twice' ? 'checked out more than once here' : 'Claude Code isn’t trusted in that folder');
|
|
410
|
+
let cwd = repos.get(s.repo);
|
|
411
|
+
try {
|
|
412
|
+
cwd = cwd && fs.realpathSync(cwd);
|
|
413
|
+
} catch {
|
|
414
|
+
cwd = undefined;
|
|
415
|
+
}
|
|
416
|
+
if (!cwd || repoOf(cwd) !== s.repo || ignoredOf(readConfig() ?? cfg).includes(s.repo)) return fail(s, 'no_checkout', 'not checked out in these folders');
|
|
417
|
+
if (!trusted(cwd)) return fail(s, 'not_trusted', 'Claude Code isn’t trusted in that folder');
|
|
418
|
+
if (!claude) claude = findClaude();
|
|
419
|
+
if (!claude) return fail(s, 'claude_missing', 'Claude Code isn’t installed here (or not on PATH)');
|
|
420
|
+
if (!flags) {
|
|
421
|
+
try {
|
|
422
|
+
flags = claudeFlags(execFileSync(claude, ['--help'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'], env: childEnv(), maxBuffer: 1024 * 1024 }));
|
|
423
|
+
} catch {
|
|
424
|
+
flags = claudeFlags('');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if ((s.mode === 'auto' && !flags.auto) || (s.mode === 'read' && !flags.plan)) return fail(s, 'mode_unsupported', `this Claude Code can’t run in ${s.mode} mode (update it: claude update)`);
|
|
428
|
+
const sessionId = randomUUID();
|
|
429
|
+
const marker = runMarker(sessionId);
|
|
430
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
|
|
431
|
+
fs.writeFileSync(marker, s.id, { mode: 0o600 });
|
|
432
|
+
const child = spawn(claude, argsFor(s.mode, flags, sessionId, s.title, s.model, s.effort), { cwd, env: childEnv(), stdio: ['pipe', 'pipe', 'pipe'], detached: true });
|
|
433
|
+
const x = { ...s, cwd, child, sessionId: flags.sessionId ? sessionId : null, marker, activity: '', turns: 0, dirty: false, sending: false, done: false, stopWhy: null, result: null, err: '' };
|
|
434
|
+
sessions.set(s.id, x);
|
|
435
|
+
writeState();
|
|
436
|
+
say(`${clock()} ▶ ${s.repo} · ${printable(s.title, 80)} (${[MODE_LABEL[s.mode], MODELS.includes(s.model) ? s.model : '', EFFORTS.includes(s.effort) ? s.effort : 'medium'].filter(Boolean).join(', ')})`);
|
|
437
|
+
child.on('error', () => undefined);
|
|
438
|
+
child.stdin.on('error', () => undefined);
|
|
439
|
+
child.stdin.end(s.prompt);
|
|
440
|
+
child.stderr.on('data', (d) => {
|
|
441
|
+
x.err = `${x.err}${d}`.slice(-2000);
|
|
442
|
+
});
|
|
443
|
+
readline.createInterface({ input: child.stdout, crlfDelay: Infinity }).on('line', (line) => {
|
|
444
|
+
try {
|
|
445
|
+
onEvent(x, line);
|
|
446
|
+
} catch {
|
|
447
|
+
// a line it can't make sense of changes nothing
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
x.tick = setInterval(() => progress(x), UPDATE_MS);
|
|
451
|
+
x.limit = setTimeout(() => stop(x, 'timeout'), MAX_MS);
|
|
452
|
+
child.on('close', (code) => void ended(x, code));
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const onEvent = (x, line) => {
|
|
456
|
+
let e;
|
|
457
|
+
try {
|
|
458
|
+
e = JSON.parse(line);
|
|
459
|
+
} catch {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (!e || typeof e !== 'object') return;
|
|
463
|
+
if (e.type === 'system' && e.subtype === 'init') {
|
|
464
|
+
if (!x.sessionId && typeof e.session_id === 'string') {
|
|
465
|
+
x.sessionId = e.session_id;
|
|
466
|
+
fs.writeFileSync(runMarker(e.session_id), x.id, { mode: 0o600 });
|
|
467
|
+
}
|
|
468
|
+
x.dirty = true;
|
|
469
|
+
progress(x);
|
|
470
|
+
} else if (e.type === 'assistant' && Array.isArray(e.message?.content)) {
|
|
471
|
+
// One event per content block; a turn is one message (its id), or each event when there's no id.
|
|
472
|
+
const mid = typeof e.message.id === 'string' ? e.message.id : null;
|
|
473
|
+
if (!mid || mid !== x.lastMessage) x.turns += 1;
|
|
474
|
+
x.lastMessage = mid;
|
|
475
|
+
const uses = e.message.content.filter((c) => c?.type === 'tool_use');
|
|
476
|
+
const last = uses[uses.length - 1];
|
|
477
|
+
if (last) {
|
|
478
|
+
x.activity = activityOf(last.name, last.input, x.cwd);
|
|
479
|
+
say(`${clock()} ${x.activity}`);
|
|
480
|
+
}
|
|
481
|
+
x.dirty = true;
|
|
482
|
+
} else if (e.type === 'result') {
|
|
483
|
+
x.result = {
|
|
484
|
+
error: e.is_error === true || (typeof e.subtype === 'string' && e.subtype.startsWith('error')),
|
|
485
|
+
text: typeof e.result === 'string' ? e.result : '',
|
|
486
|
+
turns: Number.isInteger(e.num_turns) ? e.num_turns : x.turns,
|
|
487
|
+
cost: Number.isFinite(e.total_cost_usd) ? e.total_cost_usd : undefined,
|
|
488
|
+
denied: Array.isArray(e.permission_denials) ? e.permission_denials.length : 0,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/** Ends a session: SIGTERM to every process group under its Claude Code (seen while it's still their parent), SIGKILL
|
|
494
|
+
* to them 5 s later, even if Claude Code is gone by then (a command it started may not be). */
|
|
495
|
+
function stop(x, why) {
|
|
496
|
+
if (x.done || x.stopWhy) return;
|
|
497
|
+
x.stopWhy = why;
|
|
498
|
+
const live = () => x.child.exitCode === null && x.child.signalCode === null;
|
|
499
|
+
x.groups = live() ? groupsUnder(x.child.pid) : new Set([x.child.pid]);
|
|
500
|
+
signalGroups(x.groups, 'SIGTERM');
|
|
501
|
+
killing.add(x);
|
|
502
|
+
x.killer = setTimeout(() => {
|
|
503
|
+
if (live()) for (const g of groupsUnder(x.child.pid)) x.groups.add(g);
|
|
504
|
+
signalGroups(x.groups, 'SIGKILL');
|
|
505
|
+
killing.delete(x);
|
|
506
|
+
}, KILL_AFTER_MS);
|
|
507
|
+
}
|
|
508
|
+
/** Stopped sessions whose SIGKILL is still to come. */
|
|
509
|
+
const killing = new Set();
|
|
510
|
+
|
|
511
|
+
const ended = async (x, code) => {
|
|
512
|
+
x.done = true;
|
|
513
|
+
clearInterval(x.tick);
|
|
514
|
+
clearTimeout(x.limit);
|
|
515
|
+
const r = x.result;
|
|
516
|
+
let status = 'done';
|
|
517
|
+
let error;
|
|
518
|
+
if (x.stopWhy === 'timeout') [status, error] = ['failed', 'timeout'];
|
|
519
|
+
else if (x.stopWhy) [status, error] = ['stopped', x.stopWhy];
|
|
520
|
+
else if (!r || r.error) [status, error] = ['failed', 'claude_failed'];
|
|
521
|
+
const quiet = x.stopWhy === 'paused' || x.stopWhy === 'revoked'; // Glow already knows (and won't take a word)
|
|
522
|
+
if (!quiet) {
|
|
523
|
+
await send(x, {
|
|
524
|
+
status, ...(error ? { error } : {}), ...(x.sessionId ? { session_id: x.sessionId } : {}), ...(x.activity ? { activity: x.activity } : {}),
|
|
525
|
+
turns: r?.turns ?? x.turns, ...(r?.cost !== undefined ? { cost_usd: r.cost } : {}), ...(r ? { denied: r.denied } : {}),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const tail = status === 'failed' && !r && x.err.trim() ? ` (${printable(mask(x.err.trim().split('\n').pop()), 160)})` : '';
|
|
529
|
+
say(`${clock()} ${status === 'done' ? '✓' : status === 'stopped' ? '■' : '✗'} ${x.repo} · ${printable(x.title, 80)}: ${status}${error && error !== status ? ` (${error})` : ''}${r ? ` · ${r.turns} turns` : ''}${r?.cost !== undefined ? ` · $${r.cost.toFixed(2)}` : ''}${r?.denied ? ` · ${r.denied} turned down` : ''}${tail}${code !== null && code !== 0 && !x.stopWhy ? ` · exit ${code}` : ''}`);
|
|
530
|
+
// The reply becomes the session's post in Glow, masked, like a hooked session's.
|
|
531
|
+
if (!quiet && r?.text?.trim() && x.sessionId) {
|
|
532
|
+
const now = readConfig() ?? cfg;
|
|
533
|
+
const ctx = gitContext(x.cwd);
|
|
534
|
+
const pr = prIn(r.text, x.repo);
|
|
535
|
+
await api(cfg, 'POST', '/api/v1/agent/reports', {
|
|
536
|
+
agent: 'claude_code', session_id: x.sessionId, repo: x.repo, ...(ctx.branch ? { branch: ctx.branch } : {}), message: mask(r.text).slice(0, MESSAGE_MAX), commits: ctx.commits,
|
|
537
|
+
...(pr ? { pr_url: pr } : {}), inbox: now.messages !== false, tools: tools(), run_id: x.id,
|
|
538
|
+
}, 15_000);
|
|
539
|
+
}
|
|
540
|
+
sessions.delete(x.id);
|
|
541
|
+
activeAt = Date.now();
|
|
542
|
+
writeState();
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// However it ends (even a crash), no session outlives it: on the way out, every group under a running session gets
|
|
546
|
+
// SIGTERM, and those of stopped ones still waiting for theirs get SIGKILL.
|
|
547
|
+
const killAll = () => {
|
|
548
|
+
for (const x of sessions.values()) if (!x.stopWhy) signalGroups(groupsUnder(x.child.pid), 'SIGTERM');
|
|
549
|
+
for (const x of killing) signalGroups(x.groups, 'SIGKILL');
|
|
550
|
+
};
|
|
551
|
+
process.once('exit', killAll);
|
|
552
|
+
|
|
553
|
+
const loop = async () => {
|
|
554
|
+
if (!cfg) return 1;
|
|
555
|
+
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
556
|
+
writeState();
|
|
557
|
+
say(`Glow runner ${VERSION} · ${printable(cfg.computer?.name) || 'this computer'} · ${printable(cfg.workspace?.name) || 'your workspace'} · up to ${MODE_LABEL[maxMode]}`);
|
|
558
|
+
say(allow.size ? `Only for ${[...allow.keys()].join(', ')}` : `Looking for linked repos in ${roots.map(tilde).join(', ')}`);
|
|
559
|
+
await refreshRepos();
|
|
560
|
+
say('Start a session from Glow (Agents tab). Ctrl-C stops the runner and its sessions.');
|
|
561
|
+
try {
|
|
562
|
+
while (!ending) {
|
|
563
|
+
const now = readConfig();
|
|
564
|
+
if (!now || now.off) {
|
|
565
|
+
say(now ? 'Sending is off on this computer (npx @winding-labs/glow on), so the runner stops.' : 'Glow isn’t set up here any more, so the runner stops.');
|
|
566
|
+
await shutdown('runner_stopped');
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
cfg = { ...now, repos: cfg.repos };
|
|
570
|
+
if (Date.now() - reposAt > REPOS_EVERY_MS) await refreshRepos();
|
|
571
|
+
const res = await api(cfg, 'POST', '/api/v1/agent/runner', { repos: [...repos.keys()], running: [...sessions.keys()], version: VERSION, max_mode: maxMode }, 15_000);
|
|
572
|
+
if (res.status === 401) {
|
|
573
|
+
say('This computer’s key no longer works (removed in Glow). Pair again: npx @winding-labs/glow');
|
|
574
|
+
await shutdown('revoked');
|
|
575
|
+
return 1;
|
|
576
|
+
}
|
|
577
|
+
if (res.status === 403) {
|
|
578
|
+
if (!paused) say(`Paused in Glow${sessions.size ? `: stopping ${sessions.size} session${sessions.size === 1 ? '' : 's'}` : ''}. Waiting to be resumed.`);
|
|
579
|
+
paused = true;
|
|
580
|
+
for (const x of sessions.values()) stop(x, 'paused');
|
|
581
|
+
await sleep(Math.max(POLL_MS, 30_000));
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (paused) say('Resumed in Glow.');
|
|
585
|
+
paused = false;
|
|
586
|
+
if (res.status !== 200) {
|
|
587
|
+
if (!offline) say(`Can’t reach Glow (${res.status || 'offline'}). Trying again…`);
|
|
588
|
+
offline = true;
|
|
589
|
+
await sleep(res.status === 429 ? 30_000 : Math.max(POLL_MS, 15_000));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (offline) say('Glow is back.');
|
|
593
|
+
offline = false;
|
|
594
|
+
for (const id of Array.isArray(res.json?.stop) ? res.json.stop : []) {
|
|
595
|
+
const x = sessions.get(id);
|
|
596
|
+
if (x) {
|
|
597
|
+
say(`${clock()} ■ Stop from Glow: ${x.repo} · ${printable(x.title, 80)}`);
|
|
598
|
+
stop(x, 'stopped');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const s of Array.isArray(res.json?.sessions) ? res.json.sessions : []) {
|
|
602
|
+
if (sessions.size >= AT_ONCE) break;
|
|
603
|
+
if (!s || typeof s.id !== 'string' || typeof s.repo !== 'string' || typeof s.prompt !== 'string' || typeof s.title !== 'string') continue;
|
|
604
|
+
try {
|
|
605
|
+
await launch(s);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
sessions.delete(s.id);
|
|
608
|
+
await fail(s, 'claude_failed', `couldn’t start it (${printable(e?.message, 120)})`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Quiet for 10 minutes: ask half as often (a session started then waits a few seconds more).
|
|
612
|
+
await sleep(sessions.size || Date.now() - activeAt < IDLE_AFTER_MS ? POLL_MS : POLL_MS * 2);
|
|
613
|
+
}
|
|
614
|
+
await shutdown(ending);
|
|
615
|
+
return 0;
|
|
616
|
+
} finally {
|
|
617
|
+
try {
|
|
618
|
+
if (JSON.parse(fs.readFileSync(statePath(), 'utf8')).pid === process.pid) fs.rmSync(statePath(), { force: true });
|
|
619
|
+
} catch {
|
|
620
|
+
// someone else's, or gone
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/** Stop every session (and say so to Glow), waiting up to 8 s for them to end. */
|
|
626
|
+
async function shutdown(why) {
|
|
627
|
+
for (const x of sessions.values()) stop(x, why);
|
|
628
|
+
// Until Claude Code and everything it started are gone (or the SIGKILL is due): then whatever is left gets it now.
|
|
629
|
+
const alive = (g) => {
|
|
630
|
+
try {
|
|
631
|
+
process.kill(-g, 0);
|
|
632
|
+
return true;
|
|
633
|
+
} catch (e) {
|
|
634
|
+
return e?.code === 'EPERM';
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
const until = Date.now() + KILL_AFTER_MS + 3000;
|
|
638
|
+
while ((sessions.size || [...killing].some((x) => [...x.groups].some(alive))) && Date.now() < until) await new Promise((r) => setTimeout(r, 100));
|
|
639
|
+
for (const x of killing) {
|
|
640
|
+
clearTimeout(x.killer);
|
|
641
|
+
signalGroups(x.groups, 'SIGKILL');
|
|
642
|
+
}
|
|
643
|
+
killing.clear();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const done = loop();
|
|
647
|
+
return {
|
|
648
|
+
done,
|
|
649
|
+
/** Ctrl-C: end the loop; it stops its sessions (and tells Glow) before `done` resolves. */
|
|
650
|
+
stop() {
|
|
651
|
+
ending ??= 'runner_stopped';
|
|
652
|
+
wake?.();
|
|
653
|
+
return done;
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const isDir = (p) => {
|
|
659
|
+
try {
|
|
660
|
+
return fs.lstatSync(p).isDirectory();
|
|
661
|
+
} catch {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const tilde = (p) => {
|
|
667
|
+
const h = os.homedir();
|
|
668
|
+
return p.startsWith(h) ? `~${p.slice(h.length)}` : p;
|
|
669
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "glow",
|
|
3
|
+
"interface": { "displayName": "Glow" },
|
|
4
|
+
"plugins": [
|
|
5
|
+
{
|
|
6
|
+
"name": "glow",
|
|
7
|
+
"source": { "source": "local", "path": "./glow" },
|
|
8
|
+
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
|
|
9
|
+
"category": "Productivity"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|