@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/lib/runner.mjs ADDED
@@ -0,0 +1,860 @@
1
+ // The runner: `npx @winding-labs/glow run` (docs/security/review-2026-11-02-computer-sessions.md, …-queue-auto.md,
2
+ // review-2026-11-03-signed-runs.md). Opt-in: only while it runs can the person who paired this computer start Claude
3
+ // Code sessions on it from Glow (Run, a new session, or their queue's Run), a few at once.
4
+ //
5
+ // - Nothing runs without their Glow phone's signature: Glow hands each session over with it, made after Face ID over
6
+ // the session's id, this computer, the repo and the prompt, and it's checked here against the phone key this computer
7
+ // pinned (core checkSigned: the first phone Glow named, or the one `glow phone trust` trusts), within 6 hours and
8
+ // once, before anything else happens. One that doesn't check out fails (not_signed, bad_signature, untrusted_phone).
9
+ // - Every 5 s (10 s once nothing has run for 10 minutes) it asks Glow for work with the computer's key, saying which
10
+ // linked repos it can run in (owner/name only, never a path), which sessions it runs, and its version and Claude
11
+ // Code's and Codex's (each one's own --version, read beside the asking, never in its way).
12
+ // - It doesn't take Glow's word for everything: a repo outside --repo (when given), a folder Claude Code hasn't been
13
+ // trusted in, a repo checked out twice (unless pinned with --repo owner/name=path) and a session id it has seen
14
+ // before are refused here, whatever Glow says.
15
+ // - A repo with worktrees runs in its main checkout (the one whose .git is a folder); two main checkouts of one repo
16
+ // are still refused. Each session gets a git worktree of its own, made here (addWorktree: .claude/worktrees/glow-…,
17
+ // a new branch from the default branch, kept after), so it never changes the checkout you work in.
18
+ // - A session runs as `claude -p --dangerously-skip-permissions` in that worktree, like your own Claude Code with that
19
+ // flag (docs/security/review-2026-11-03-full-access.md): your settings pick its model and effort, as they would for
20
+ // `claude` in a terminal. Arguments as a list (no shell), the prompt on stdin (never an argument, so no prompt can
21
+ // pass for a flag), nothing that would still ask (--permission-prompts none), and never Glow's own CLI or files
22
+ // (--disallowedTools DENY, which holds even then: it can't turn Glow off, stop the runner or read the computer's
23
+ // key). It's told it runs unattended and to ship its work as a pull request, in zsh, with no Grep tool
24
+ // (UNATTENDED); pnpm and yarn work even where only corepack is installed (toolchain). The model it says it runs on
25
+ // and how much of your Claude plan is used go to Glow, and its tool calls are counted (how many failed).
26
+ // - Inside a session `glow` refuses to change anything (GLOW_RUN_SESSION in its environment, a session's Claude Code
27
+ // among its parents, or a session's process group: inRunnerSession).
28
+ // - Its stream says what it's doing: the last tool or command goes to Glow, short and masked (core activityOf), at
29
+ // most every few seconds, with its usage (tokens, how full its context is) and its transcript (Claude Code's words,
30
+ // its tool calls, the first lines of each result, its reply: masked, cut short, at most a few hundred KB) for the
31
+ // person who started it; then done, failed or stopped, and its reply becomes the session's post (masked, as the
32
+ // hook sends it).
33
+ // - Stop in Glow, pausing the computer, `glow off`, a revoked key or Ctrl-C end its sessions: SIGTERM to every process
34
+ // group under its Claude Code (its Bash tool and hooks start each command in a group of its own), then SIGKILL.
35
+ import { execFile, execFileSync, spawn } from 'node:child_process';
36
+ import { randomUUID } from 'node:crypto';
37
+ import fs from 'node:fs';
38
+ import os from 'node:os';
39
+ import path from 'node:path';
40
+ import readline from 'node:readline';
41
+ import {
42
+ activityOf, api, checkSigned, dir, fingerprintText, GIT_SAFE, gitContext, ignoredOf, mask, pinPhone, prIn, printable, readConfig, repoOf, runMarker, runPayload, sha, SIGNED_RUN_TTL_S, tools, VERSION,
43
+ writeConfig, writeFileAtomic,
44
+ } from './core.mjs';
45
+
46
+ // GLOW_RUN_POLL_MS only speeds up the tests (timing, never where anything is sent or what runs).
47
+ const POLL_MS = Math.max(50, Number(process.env.GLOW_RUN_POLL_MS) || 5000);
48
+ const UPDATE_MS = Math.min(POLL_MS, 4000);
49
+ const REPOS_EVERY_MS = 5 * 60_000;
50
+ const MAX_MS = 3 * 3600_000;
51
+ const AT_ONCE = 3;
52
+ const KILL_AFTER_MS = 5000;
53
+ const MESSAGE_MAX = 20_000;
54
+ const IDLE_AFTER_MS = 10 * 60_000;
55
+ /** A session's transcript: per entry, per result, per batch sent, per session (core SESSION_LOG_*). */
56
+ const LOG_TEXT = 4000;
57
+ const LOG_OUT = 600;
58
+ const LOG_BATCH = 100;
59
+ const LOG_MAX = 2000;
60
+ /** An environment variable named like a secret, set (PGPASSWORD=…, GH_TOKEN="…"): masked in the transcript too. */
61
+ const ENV_SECRET = /\b([A-Z][A-Z0-9_]*(?:PASS|PASSWD|PASSWORD|PWD|TOKEN|SECRET|KEY|CREDENTIALS?)[A-Z0-9_]*)=("[^"\n]*"|'[^'\n]*'|[^\s"']+)/g;
62
+ /** In every session's environment (and passed through ~/.glow/glow): glow changes nothing from inside one. */
63
+ export const SESSION_ENV = 'GLOW_RUN_SESSION';
64
+ /**
65
+ * What a session may never use, with permission checks skipped or any allow rule (the plugin's included; a deny rule wins): Glow's
66
+ * CLI however it's spelled (~/.glow/glow, $HOME/.glow/…, npx/bunx @winding-labs/glow, node …/glow.mjs) and its files
67
+ * (the computer's key). Claude Code 2.1 matches `*` anywhere in a Bash rule, and each part of `a && b`.
68
+ */
69
+ export const DENY = ['Bash(*.glow*)', 'Bash(*@winding-labs/glow*)', 'Bash(*glow.mjs*)', 'Read(~/.glow/**)', 'Edit(~/.glow/**)'];
70
+ /** Folders never searched for checkouts (besides hidden ones). */
71
+ const SKIP = new Set(['node_modules', 'Library', 'Applications', 'Pictures', 'Movies', 'Music', 'vendor', 'dist', 'build', 'target', 'Pods', 'DerivedData']);
72
+
73
+ export const statePath = () => path.join(dir(), 'runner.json');
74
+ export const logPath = () => path.join(dir(), 'runner.log');
75
+
76
+ /** The runner on this computer, if one is on: { pid, started_at, roots, sessions }. Only if that pid is alive and is
77
+ * still `glow … run` (a pid the system reused for something else is never signalled). */
78
+ export function runnerState() {
79
+ let s;
80
+ try {
81
+ s = JSON.parse(fs.readFileSync(statePath(), 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ if (!Number.isInteger(s?.pid) || s.pid <= 1) return null;
86
+ try {
87
+ const cmd = execFileSync('ps', ['-o', 'command=', '-p', String(s.pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
88
+ return /glow(\.mjs)?\b.*\brun\b/.test(cmd) ? s : null;
89
+ } catch {
90
+ return null; // not running
91
+ }
92
+ }
93
+
94
+ /** Every process now: pid → { ppid, pgid }, from one `ps`. */
95
+ export function processes() {
96
+ const all = new Map();
97
+ const out = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
98
+ for (const line of out.split('\n')) {
99
+ const [pid, ppid, pgid] = line.trim().split(/\s+/).map(Number);
100
+ if (pid > 0) all.set(pid, { ppid, pgid });
101
+ }
102
+ return all;
103
+ }
104
+
105
+ /** Every process group under this pid: its own, and each one its descendants run in (Claude Code's Bash tool and hooks
106
+ * start each command detached, in a group of its own). Never this process's group, init's or the kernel's. */
107
+ export function groupsUnder(root) {
108
+ let all;
109
+ try {
110
+ all = processes();
111
+ } catch {
112
+ return new Set([root]);
113
+ }
114
+ const kids = new Map();
115
+ for (const [pid, p] of all) kids.set(p.ppid, [...(kids.get(p.ppid) ?? []), pid]);
116
+ const groups = new Set([root]);
117
+ const seen = new Set();
118
+ for (const todo = [root]; todo.length; ) {
119
+ const p = todo.pop();
120
+ if (seen.has(p)) continue;
121
+ seen.add(p);
122
+ if (all.get(p)?.pgid) groups.add(all.get(p).pgid);
123
+ todo.push(...(kids.get(p) ?? []));
124
+ }
125
+ const mine = all.get(process.pid)?.pgid;
126
+ for (const g of groups) if (g <= 1 || g === mine) groups.delete(g);
127
+ return groups;
128
+ }
129
+
130
+ /** Signal each of these process groups (gone ones are skipped). */
131
+ function signalGroups(groups, sig) {
132
+ for (const g of groups) {
133
+ try {
134
+ process.kill(-g, sig);
135
+ } catch {
136
+ // gone
137
+ }
138
+ }
139
+ }
140
+
141
+ /** This process runs inside a session the runner started: the session's marker in its environment, one of the
142
+ * sessions' Claude Code among its parents, or its process group one of theirs. Any one is enough (a command can drop
143
+ * an environment variable, or start a group of its own). */
144
+ export function inRunnerSession(env = process.env, pid = process.pid) {
145
+ if (env[SESSION_ENV]) return true;
146
+ const s = runnerState();
147
+ const claudes = Array.isArray(s?.sessions) ? s.sessions.filter((x) => Number.isInteger(x) && x > 1) : [];
148
+ if (!claudes.length) return false;
149
+ let all;
150
+ try {
151
+ all = processes();
152
+ } catch {
153
+ return false;
154
+ }
155
+ if (claudes.includes(all.get(pid)?.pgid)) return true;
156
+ for (let p = pid, n = 0; p > 1 && n < 200; p = all.get(p)?.ppid ?? 0, n++) if (claudes.includes(p)) return true;
157
+ return false;
158
+ }
159
+
160
+ /** The folder holding .git above (or at) this one. */
161
+ function topOf(folder) {
162
+ for (let f = path.resolve(folder); ; f = path.dirname(f)) {
163
+ if (fs.existsSync(path.join(f, '.git'))) return f;
164
+ if (path.dirname(f) === f) return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The linked repos checked out under these folders (a folder inside a checkout counts as that checkout): repo → every
170
+ * main checkout of it found, shallowest first (its worktrees, whose .git is a file, only when there's no main one).
171
+ * Hidden folders, symlinks and a checkout's own folders aren't searched; 3 levels, 4,000 folders.
172
+ */
173
+ export function findRepos(roots, linked, ignored = [], depth = 3, limit = 4000) {
174
+ const want = new Set(linked);
175
+ const found = new Map();
176
+ const keep = (folder) => {
177
+ const repo = repoOf(folder);
178
+ if (!repo || !want.has(repo) || ignored.includes(repo)) return;
179
+ const real = fs.realpathSync(folder);
180
+ const all = found.get(repo) ?? [];
181
+ if (!all.includes(real)) found.set(repo, [...all, real]);
182
+ };
183
+ let seen = 0;
184
+ let level = [];
185
+ for (const r of roots) {
186
+ const top = topOf(r);
187
+ if (top) keep(top);
188
+ else level.push(path.resolve(r));
189
+ }
190
+ for (let d = 0; d <= depth && level.length; d++) {
191
+ const next = [];
192
+ for (const folder of level) {
193
+ if (++seen > limit) return mainsOf(found);
194
+ if (fs.existsSync(path.join(folder, '.git'))) {
195
+ keep(folder);
196
+ continue;
197
+ }
198
+ if (d === depth) continue;
199
+ let entries = [];
200
+ try {
201
+ entries = fs.readdirSync(folder, { withFileTypes: true });
202
+ } catch {
203
+ continue;
204
+ }
205
+ 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));
206
+ }
207
+ level = next;
208
+ }
209
+ return mainsOf(found);
210
+ }
211
+
212
+ /** Each repo's main checkouts, if it has any (a worktree of it is the same repo, not a second copy). */
213
+ function mainsOf(found) {
214
+ for (const [repo, all] of found) {
215
+ const mains = all.filter((p) => isDir(path.join(p, '.git')));
216
+ if (mains.length) found.set(repo, mains);
217
+ }
218
+ return found;
219
+ }
220
+
221
+ /**
222
+ * Has Claude Code been trusted in this folder (its "Do you trust the files in this folder?" accepted, here or in a folder
223
+ * above it, not the home folder itself)? `claude -p` skips that question, so a repo's own .claude settings and hooks
224
+ * would apply in a folder nobody looked at: the runner only runs where it was answered.
225
+ */
226
+ export function trusted(folder, env = process.env) {
227
+ let projects = {};
228
+ try {
229
+ const file = env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(os.homedir(), '.claude.json');
230
+ projects = JSON.parse(fs.readFileSync(file, 'utf8'))?.projects ?? {};
231
+ } catch {
232
+ return false;
233
+ }
234
+ let f;
235
+ try {
236
+ f = fs.realpathSync(folder);
237
+ } catch {
238
+ return false;
239
+ }
240
+ const home = os.homedir();
241
+ for (; f !== path.dirname(f); f = path.dirname(f)) {
242
+ if (f !== folder && f === home) break;
243
+ if (projects[f]?.hasTrustDialogAccepted === true) return true;
244
+ }
245
+ return false;
246
+ }
247
+
248
+ /** A program on PATH (or in these folders after it), if it's here. */
249
+ function which(name, env = process.env, more = []) {
250
+ for (const d of [...String(env.PATH ?? '').split(path.delimiter).filter(Boolean), ...more]) {
251
+ try {
252
+ fs.accessSync(path.join(d, name), fs.constants.X_OK);
253
+ return path.join(d, name);
254
+ } catch {
255
+ // not here
256
+ }
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /** Claude Code on this computer: on PATH, or where its installer puts it. */
262
+ export function findClaude(env = process.env) {
263
+ const home = os.homedir();
264
+ return which('claude', env, [path.join(home, '.local', 'bin'), path.join(home, '.claude', 'local'), '/opt/homebrew/bin', '/usr/local/bin']);
265
+ }
266
+
267
+ /** Claude Code's and Codex's versions, into `to` as each answers (it takes them milliseconds, and nothing waits). */
268
+ function readAgentVersions(to) {
269
+ for (const [id, bin] of [['claude_code', findClaude()], ['codex', which('codex')]]) {
270
+ if (!bin) continue;
271
+ execFile(bin, ['--version'], { encoding: 'utf8', timeout: 5000, env: childEnv(), maxBuffer: 64 * 1024 }, (_, out) => {
272
+ const v = /\b\d+\.\d+\.\d+\b/.exec(String(out))?.[0];
273
+ if (v) to[id] = v;
274
+ });
275
+ }
276
+ }
277
+
278
+ /** The flags this Claude Code has, from its --help (an older one lacks some). */
279
+ export function claudeFlags(help) {
280
+ const h = String(help);
281
+ return { prompts: h.includes('--permission-prompts'), sessionId: h.includes('--session-id'), name: /--name\b/.test(h), append: /--append-system-prompt\b/.test(h) };
282
+ }
283
+
284
+ /** Said to every session Glow starts (--append-system-prompt): where it runs, what trips it up there, and to finish the
285
+ * job (2026-11-03: none of the five full-access runs pushed; three left their change uncommitted, one because the
286
+ * repo's CLAUDE.md says to commit only when asked). */
287
+ export const UNATTENDED = [
288
+ 'You were started from Glow on this computer and run unattended: nobody can answer a question or a permission prompt, so decide for yourself and see the task through.',
289
+ 'Do the whole job: make the change, run the checks the repo has (tests, typecheck, lint, build) and fix what fails.',
290
+ 'You are in a fresh git worktree of your own, on a new branch made from the default branch: install dependencies first if the work needs them, with the package manager its lockfile names.',
291
+ 'The person who queued this task asks you to ship it as a pull request (this is the ask a "commit only when asked" rule waits for): when files changed, commit them on this branch (in the repo’s commit style), push it (git push -u origin HEAD; if a push hook asks for something, such as a changeset, add it and push again) and open a pull request with gh pr create. Never push to the default branch or merge.',
292
+ 'End with what you did, how you checked it, what is left for a person, and the pull request link.',
293
+ "The Bash tool runs zsh, where an unmatched glob is an error: quote globs in arguments (grep -rn x --include='*.ts' .).",
294
+ 'There is no Grep or Glob tool here: search with rg or grep, and find files with rg --files or find, in Bash.',
295
+ "Glow's own CLI and ~/.glow are off limits.",
296
+ ].join(' ');
297
+
298
+ /** claude's arguments for a session: print mode, stream-json, its session id, permission checks skipped (like your own
299
+ * `claude --dangerously-skip-permissions`; Glow's own CLI and files stay denied, as a deny rule holds there too) and a
300
+ * name to find it by in `claude --resume`. No --model or --effort: your Claude Code settings pick them, as in a
301
+ * terminal. No --worktree: it runs in one the runner made (addWorktree). The prompt goes on stdin. */
302
+ export function argsFor(f, sessionId, title) {
303
+ const name = `Glow: ${String(title).replace(/[^\p{L}\p{N} ’'.,:_-]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)}`;
304
+ return [
305
+ '-p', '--output-format', 'stream-json', '--verbose', ...(f.sessionId ? ['--session-id', sessionId] : []), '--dangerously-skip-permissions',
306
+ ...(f.prompts ? ['--permission-prompts', 'none'] : []), '--disallowedTools', ...DENY, ...(f.name ? ['--name', name] : []),
307
+ ...(f.append ? ['--append-system-prompt', UNATTENDED] : []),
308
+ ];
309
+ }
310
+
311
+ /** git for the runner's own steps in a checkout (fetch, worktree add): GIT_SAFE (nothing of the repo's runs in the
312
+ * runner) and none of its GIT_* variables, but with your credentials (the keychain helper in git's system config, an
313
+ * ssh agent), so a fetch of a private repo works. Its output, or null when it fails. */
314
+ const gitRun = (cwd, args, timeout) => new Promise((resolve) => {
315
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
316
+ execFile('git', ['-C', cwd, ...GIT_SAFE, ...args], { encoding: 'utf8', timeout, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 1024 * 1024 }, (err, out) => resolve(err ? null : String(out).trim()));
317
+ });
318
+
319
+ /**
320
+ * A git worktree of its own for a session: <checkout>/.claude/worktrees/<name>, on a new branch of that name (no
321
+ * upstream, so a bare `git push` can't land on the default branch), from the default branch as origin has it now
322
+ * (fetched first; as last fetched, or the checkout's HEAD, when that fails). Kept afterwards. Made here rather than with
323
+ * `claude --worktree`, whose isolation refuses any command it can't prove stays inside the worktree (`corepack enable`,
324
+ * `PATH=… pnpm`, `xargs sh -c`): 2026-11-03, most of the failed steps of the full-access runs. Its folder, or null.
325
+ */
326
+ export async function addWorktree(cwd, name) {
327
+ if (!/^glow-[a-z0-9]{1,30}$/.test(name)) return null;
328
+ const head = await gitRun(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], 5000); // origin/main
329
+ if (head?.startsWith('origin/')) await gitRun(cwd, ['fetch', '--quiet', '--no-tags', 'origin', head.slice('origin/'.length)], 60_000);
330
+ const base = head && (await gitRun(cwd, ['rev-parse', '--verify', '--quiet', `${head}^{commit}`], 5000)) ? head : 'HEAD';
331
+ const at = path.join(cwd, '.claude', 'worktrees', name);
332
+ return (await gitRun(cwd, ['worktree', 'add', '--quiet', '--no-track', '-b', name, at, base], 120_000)) === null ? null : at;
333
+ }
334
+
335
+ /**
336
+ * pnpm and yarn for sessions where either is missing from PATH: corepack's own shims (Node 16.9 to 24 ships corepack),
337
+ * made in ~/.cache/glow/bin and put last on a session's PATH, so one you install yourself wins. Without them a repo
338
+ * pinned to pnpm cost a session its first dozen steps (2026-11-03). The folder, or null.
339
+ */
340
+ export function toolchain(home = os.homedir(), env = process.env) {
341
+ if (which('pnpm', env) && which('yarn', env)) return null;
342
+ const corepack = which('corepack', env);
343
+ if (!corepack) return null;
344
+ const bin = path.join(home, '.cache', 'glow', 'bin');
345
+ try {
346
+ fs.mkdirSync(bin, { recursive: true });
347
+ execFileSync(corepack, ['enable', '--install-directory', bin, 'pnpm', 'yarn'], { stdio: 'ignore', timeout: 30_000, env });
348
+ } catch {
349
+ return null;
350
+ }
351
+ return fs.existsSync(path.join(bin, 'pnpm')) ? bin : null;
352
+ }
353
+
354
+ /** The environment a session gets: this one, minus a parent Claude Code's own markers and Glow's test knobs, with the
355
+ * basics a shell needs (the runner may have been started with only HOME and PATH, from /glow:run), and corepack's
356
+ * pnpm and yarn last on PATH when they aren't installed (it downloads a repo's pinned version without asking). */
357
+ function childEnv(shims = null) {
358
+ const env = { ...process.env };
359
+ for (const k of Object.keys(env)) if (['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_SSE_PORT'].includes(k) || k.startsWith('GLOW_')) delete env[k];
360
+ env[SESSION_ENV] = '1';
361
+ let u = null;
362
+ try {
363
+ u = os.userInfo();
364
+ } catch {
365
+ u = null;
366
+ }
367
+ env.HOME ??= os.homedir();
368
+ env.SHELL ??= u?.shell || '/bin/sh';
369
+ if (u?.username) env.USER ??= u.username;
370
+ env.LANG ??= 'en_US.UTF-8';
371
+ env.COREPACK_ENABLE_DOWNLOAD_PROMPT ??= '0';
372
+ if (shims) env.PATH = [env.PATH, shims].filter(Boolean).join(path.delimiter);
373
+ return env;
374
+ }
375
+
376
+ /** How much of your Claude plan is used, from Claude Code's rate_limit_event (once a session, after its first answer):
377
+ * its fullest window (seven_day, five_hour…), used 0 to 1. Null when it doesn't say (an API key has no plan). */
378
+ export function planOf(e) {
379
+ const info = e?.rate_limit_info ?? {};
380
+ const windows = info.unifiedWindows ?? (info.rateLimitType ? { [info.rateLimitType]: info } : {});
381
+ const [window, w] = Object.entries(windows).filter(([k, v]) => /^[a-z0-9_]{1,32}$/.test(k) && Number.isFinite(v?.utilization)).sort((a, b) => b[1].utilization - a[1].utilization)[0] ?? [];
382
+ return window ? { used: Math.min(1, Math.max(0, w.utilization)), window } : null;
383
+ }
384
+
385
+ const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
386
+
387
+ /**
388
+ * Run until told to stop. `say` prints a line (the terminal, or ~/.glow/runner.log in the background). Resolves with
389
+ * the exit code. `stop()` on the returned handle ends it (Ctrl-C): its sessions are stopped and Glow is told.
390
+ * allow repo → pinned checkout (or null): only these repos run here; empty: any linked repo found under `roots`
391
+ */
392
+ export function startRunner({ roots, say = console.log, allow = new Map() }) {
393
+ const sessions = new Map();
394
+ /** Every session id handed over while this runner runs: one sent again never runs twice. */
395
+ const seen = new Set();
396
+ /** Each model's context window, as Claude Code last said (its result names it): a session's context ring from its
397
+ * first turn. */
398
+ const windows = new Map();
399
+ let cfg = readConfig();
400
+ let repos = new Map();
401
+ /** Why a repo can't run here: repo → error code (not_trusted, repo_twice, no_checkout). */
402
+ let why = new Map();
403
+ let reposAt = 0;
404
+ let activeAt = Date.now();
405
+ let claude = null;
406
+ let flags = null;
407
+ /** corepack's pnpm and yarn for its sessions (toolchain), once looked for. */
408
+ let shims;
409
+ let ending = null;
410
+ let paused = false;
411
+ let offline = false;
412
+ let wake = null;
413
+ const sleep = (ms) => new Promise((r) => {
414
+ const t = setTimeout(r, ms);
415
+ wake = () => {
416
+ clearTimeout(t);
417
+ r();
418
+ };
419
+ });
420
+
421
+ let phoneAt = 0;
422
+ /** Glow's word on the Glow phone: pinned here the first time, else only reported (core pinPhone). */
423
+ const refreshPhone = async () => {
424
+ phoneAt = Date.now();
425
+ const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
426
+ if (res.status === 200) cfg = { ...pinPhone(readConfig() ?? cfg, res.json?.phone, say), repos: cfg.repos };
427
+ };
428
+ const agentVersions = {};
429
+ const refreshRepos = async () => {
430
+ readAgentVersions(agentVersions);
431
+ const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
432
+ if (res.status === 200) cfg = { ...pinPhone(readConfig() ?? cfg, res.json?.phone, say), repos: cfg.repos };
433
+ if (res.status === 200 && Array.isArray(res.json?.repos)) {
434
+ try {
435
+ writeConfig({ ...(readConfig() ?? cfg), repos: res.json.repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
436
+ } catch {
437
+ // read-only home: the list is still used below
438
+ }
439
+ cfg = { ...cfg, repos: res.json.repos };
440
+ }
441
+ const linked = (Array.isArray(cfg.repos) ? cfg.repos : []).filter((r) => !allow.size || allow.has(r));
442
+ const found = findRepos(roots, linked, ignoredOf(cfg));
443
+ const next = new Map();
444
+ const not = new Map();
445
+ const notes = [];
446
+ for (const repo of linked) {
447
+ const pin = allow.get(repo);
448
+ let at = found.get(repo) ?? [];
449
+ if (pin) {
450
+ if (repoOf(pin) !== repo) {
451
+ notes.push(`${repo}: ${tilde(pin)} isn’t a checkout of it`);
452
+ continue;
453
+ }
454
+ try {
455
+ at = [fs.realpathSync(pin)];
456
+ } catch {
457
+ continue; // gone since
458
+ }
459
+ }
460
+ if (!at.length) continue;
461
+ if (ignoredOf(cfg).includes(repo)) continue;
462
+ if (at.length > 1) {
463
+ not.set(repo, 'repo_twice');
464
+ notes.push(`${repo} is checked out ${at.length} times (${at.map(tilde).join(', ')}): pick one with --repo ${repo}=PATH`);
465
+ } else if (!trusted(at[0])) {
466
+ not.set(repo, 'not_trusted');
467
+ notes.push(`${repo} (${tilde(at[0])}): Claude Code hasn’t been trusted in this folder. Open claude there once and say yes.`);
468
+ } else next.set(repo, at[0]);
469
+ }
470
+ const before = [...repos].join(',') + [...why].join(',');
471
+ repos = next;
472
+ why = not;
473
+ reposAt = Date.now();
474
+ if ([...repos].join(',') + [...why].join(',') !== before) {
475
+ say(repos.size ? `Sessions can run here in: ${[...repos].map(([r, p]) => `${r} (${tilde(p)})`).join(', ')}` : 'No linked repo can run here yet.');
476
+ for (const n of notes) say(` ! ${n}`);
477
+ }
478
+ };
479
+
480
+ /** runner.json: who runs (`run stop` signals only this pid) and its sessions' process groups (whose commands may not
481
+ * turn Glow off or stop the runner: lib/runner inRunnerSession). */
482
+ const writeState = () => {
483
+ const mine = { pid: process.pid, started_at: startedAt, roots: roots.map(tilde), version: VERSION, sessions: [...sessions.values()].map((x) => x.child.pid).filter(Boolean) };
484
+ try {
485
+ writeFileAtomic(statePath(), `${JSON.stringify(mine)}\n`, 0o600);
486
+ } catch {
487
+ // a read-only home: run stop can't find it, Ctrl-C still works
488
+ }
489
+ };
490
+ const startedAt = new Date().toISOString();
491
+
492
+ /** Tell Glow how a session goes. The answer may say to stop it. */
493
+ const send = async (x, body) => {
494
+ const res = await api(cfg, 'POST', `/api/v1/agent/sessions/${encodeURIComponent(x.id)}`, body, 10_000);
495
+ if (res.status === 200 && res.json?.stop === true && body.status === 'running') stop(x, 'stopped');
496
+ return res;
497
+ };
498
+ /** What it sends each time: how it's going, its usage, and the next transcript entries (counted once Glow has them). */
499
+ const update = (x, status) => {
500
+ const batch = x.log.slice(x.logSent, x.logSent + LOG_BATCH);
501
+ const window = x.window ?? windows.get(x.model);
502
+ return {
503
+ body: {
504
+ status, ...(x.sessionId ? { session_id: x.sessionId } : {}), ...(x.model ? { model: x.model } : {}), ...(x.activity ? { activity: x.activity } : {}), turns: x.result?.turns ?? x.turns,
505
+ ...(x.context ? { context_tokens: x.context } : {}), ...(window ? { context_window: window } : {}),
506
+ ...(x.toolCalls ? { tool_calls: x.toolCalls, tool_errors: x.toolErrors } : {}),
507
+ ...(batch.length ? { log: batch, log_from: x.logSent } : {}),
508
+ },
509
+ sent: batch.length,
510
+ };
511
+ };
512
+ const progress = (x) => {
513
+ if (!x.dirty || x.sending || x.done) return;
514
+ x.dirty = false;
515
+ x.sending = true;
516
+ const u = update(x, 'running');
517
+ void send(x, u.body).then((res) => {
518
+ if (res.status === 200) x.logSent += u.sent;
519
+ if (x.logSent < x.log.length) x.dirty = true;
520
+ }).finally(() => {
521
+ x.sending = false;
522
+ });
523
+ };
524
+ /** Into the transcript (masked, cut short), up to LOG_MAX entries; its last reply always. */
525
+ const note = (x, kind, text, error) => {
526
+ const max = kind === 'out' ? LOG_OUT : LOG_TEXT;
527
+ const full = mask(String(text ?? '')).replace(ENV_SECRET, '$1=[masked]').replace(/\r\n?/g, '\n').trim();
528
+ const t = full.length > max ? `${full.slice(0, max - 1)}…` : full;
529
+ if (!t || (x.log.length >= LOG_MAX && kind !== 'result')) return;
530
+ x.log.push({ kind, text: t, at: Date.now(), ...(error ? { error: true } : {}) });
531
+ };
532
+
533
+ const fail = async (s, error, why) => {
534
+ say(`${clock()} ✗ ${s.repo} · ${printable(s.title, 80)}: ${why}`);
535
+ await api(cfg, 'POST', `/api/v1/agent/sessions/${encodeURIComponent(s.id)}`, { status: 'failed', error }, 10_000);
536
+ };
537
+
538
+ const launch = async (s) => {
539
+ // Handed over once: an id seen before (Glow sending it again, or anyone who could) never runs a second time.
540
+ if (seen.has(s.id)) return;
541
+ seen.add(s.id);
542
+ activeAt = Date.now();
543
+ // Signed by this computer's Glow phone, over exactly this: before anything else. A phone not pinned yet (its owner
544
+ // just made it theirs) is looked up once.
545
+ const payload = (at) => runPayload({ session: s.id, computer: cfg.computer?.id ?? '', repo: s.repo, prompt_sha256: sha(s.prompt), issued_at: at });
546
+ if (!cfg.phone?.fingerprint && Date.now() - phoneAt > 30_000) await refreshPhone();
547
+ const unsigned = checkSigned(cfg, s.signed, payload, SIGNED_RUN_TTL_S);
548
+ if (unsigned) {
549
+ return fail(s, unsigned, unsigned === 'untrusted_phone'
550
+ ? (cfg.phone?.fingerprint ? 'signed by a phone this computer doesn’t trust (npx @winding-labs/glow phone trust)' : 'no Glow phone is trusted here yet: make your phone your Glow phone in the app')
551
+ : unsigned === 'not_signed' ? 'your Glow phone didn’t sign it, or not recently' : 'your Glow phone’s signature doesn’t check out');
552
+ }
553
+ if (allow.size && !allow.has(s.repo)) return fail(s, 'not_allowed', `${s.repo} isn’t one of this runner’s --repo`);
554
+ 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');
555
+ let cwd = repos.get(s.repo);
556
+ try {
557
+ cwd = cwd && fs.realpathSync(cwd);
558
+ } catch {
559
+ cwd = undefined;
560
+ }
561
+ if (!cwd || repoOf(cwd) !== s.repo || ignoredOf(readConfig() ?? cfg).includes(s.repo)) return fail(s, 'no_checkout', 'not checked out in these folders');
562
+ if (!trusted(cwd)) return fail(s, 'not_trusted', 'Claude Code isn’t trusted in that folder');
563
+ if (!claude) claude = findClaude();
564
+ if (!claude) return fail(s, 'claude_missing', 'Claude Code isn’t installed here (or not on PATH)');
565
+ if (!flags) {
566
+ try {
567
+ flags = claudeFlags(execFileSync(claude, ['--help'], { encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'ignore'], env: childEnv(), maxBuffer: 1024 * 1024 }));
568
+ } catch {
569
+ flags = claudeFlags('');
570
+ }
571
+ }
572
+ if (shims === undefined) shims = toolchain();
573
+ const workdir = await addWorktree(cwd, `glow-${String(s.id).toLowerCase().replace(/[^a-z0-9]/g, '').slice(-12)}`);
574
+ if (!workdir) return fail(s, 'no_worktree', `couldn’t make a git worktree in ${tilde(cwd)}`);
575
+ const sessionId = randomUUID();
576
+ const marker = runMarker(sessionId);
577
+ fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
578
+ fs.writeFileSync(marker, s.id, { mode: 0o600 });
579
+ const child = spawn(claude, argsFor(flags, sessionId, s.title), { cwd: workdir, env: childEnv(shims), stdio: ['pipe', 'pipe', 'pipe'], detached: true });
580
+ const x = {
581
+ ...s, cwd, workdir, child, sessionId: flags.sessionId ? sessionId : null, marker, model: '', activity: '', turns: 0, dirty: false, sending: false, done: false, stopWhy: null, result: null, err: '',
582
+ log: [], logSent: 0, context: 0, window: undefined, tools: new Map(), toolCalls: 0, toolErrors: 0,
583
+ };
584
+ sessions.set(s.id, x);
585
+ writeState();
586
+ say(`${clock()} ▶ ${s.repo} · ${printable(s.title, 80)}${s.auto ? ' (from your queue)' : ''}`);
587
+ child.on('error', () => undefined);
588
+ child.stdin.on('error', () => undefined);
589
+ child.stdin.end(s.prompt);
590
+ child.stderr.on('data', (d) => {
591
+ x.err = `${x.err}${d}`.slice(-2000);
592
+ });
593
+ readline.createInterface({ input: child.stdout, crlfDelay: Infinity }).on('line', (line) => {
594
+ try {
595
+ onEvent(x, line);
596
+ } catch {
597
+ // a line it can't make sense of changes nothing
598
+ }
599
+ });
600
+ x.tick = setInterval(() => progress(x), UPDATE_MS);
601
+ x.limit = setTimeout(() => stop(x, 'timeout'), MAX_MS);
602
+ child.on('close', (code) => void ended(x, code));
603
+ };
604
+
605
+ const onEvent = (x, line) => {
606
+ let e;
607
+ try {
608
+ e = JSON.parse(line);
609
+ } catch {
610
+ return;
611
+ }
612
+ if (!e || typeof e !== 'object') return;
613
+ if (e.type === 'system' && e.subtype === 'init') {
614
+ if (!x.sessionId && typeof e.session_id === 'string') {
615
+ x.sessionId = e.session_id;
616
+ fs.writeFileSync(runMarker(e.session_id), x.id, { mode: 0o600 });
617
+ }
618
+ // The model your settings picked ("claude-opus-5-5"): the run's page shows it.
619
+ if (typeof e.model === 'string' && /^[a-z0-9][a-z0-9.[\]-]{0,63}$/.test(e.model)) x.model = e.model;
620
+ x.dirty = true;
621
+ progress(x);
622
+ } else if (e.type === 'assistant' && Array.isArray(e.message?.content)) {
623
+ // One event per content block; a turn is one message (its id), or each event when there's no id.
624
+ const mid = typeof e.message.id === 'string' ? e.message.id : null;
625
+ if (!mid || mid !== x.lastMessage) x.turns += 1;
626
+ x.lastMessage = mid;
627
+ const u = e.message.usage;
628
+ if (u && !e.parent_tool_use_id) x.context = [u.input_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens, u.output_tokens].reduce((a, n) => a + (Number.isFinite(n) ? n : 0), 0);
629
+ for (const c of e.message.content) {
630
+ if (c?.type === 'text' && !e.parent_tool_use_id) note(x, 'text', c.text);
631
+ if (c?.type !== 'tool_use') continue;
632
+ x.toolCalls += 1; // its subagents' too
633
+ x.activity = activityOf(c.name, c.input, x.workdir);
634
+ // The transcript shows a command whole (masked); the status line only its program.
635
+ note(x, 'tool', c.name === 'Bash' && typeof c.input?.command === 'string' ? `$ ${c.input.command}` : x.activity);
636
+ if (typeof c.id === 'string') x.tools.set(c.id, c.name);
637
+ say(`${clock()} ${x.activity}`);
638
+ }
639
+ x.dirty = true;
640
+ } else if (e.type === 'user' && Array.isArray(e.message?.content)) {
641
+ for (const c of e.message.content) {
642
+ if (c?.type !== 'tool_result') continue;
643
+ if (c.is_error === true) x.toolErrors += 1;
644
+ if (e.parent_tool_use_id) continue; // a subagent's: counted, not in the transcript
645
+ const out = typeof c.content === 'string' ? c.content : Array.isArray(c.content) ? c.content.filter((p) => p?.type === 'text').map((p) => p.text).join('\n') : '';
646
+ note(x, 'out', out || (c.is_error ? 'It failed.' : ''), c.is_error === true);
647
+ }
648
+ x.dirty = true;
649
+ } else if (e.type === 'rate_limit_event') {
650
+ // How much of your Claude plan is used: on its own, so a Glow from before plan_used turns down only this.
651
+ const plan = planOf(e);
652
+ if (plan) void send(x, { status: 'running', plan_used: plan.used, plan_window: plan.window });
653
+ } else if (e.type === 'result') {
654
+ const windowOf = Object.values(e.modelUsage ?? {}).map((m) => Number(m?.contextWindow)).filter((n) => Number.isFinite(n) && n > 0);
655
+ if (windowOf.length) {
656
+ x.window = Math.max(...windowOf);
657
+ windows.set(x.model, x.window);
658
+ }
659
+ const u = e.usage ?? {};
660
+ const n = (v) => (Number.isFinite(v) ? v : 0);
661
+ x.result = {
662
+ error: e.is_error === true || (typeof e.subtype === 'string' && e.subtype.startsWith('error')),
663
+ text: typeof e.result === 'string' ? e.result : '',
664
+ turns: Number.isInteger(e.num_turns) ? e.num_turns : x.turns,
665
+ cost: Number.isFinite(e.total_cost_usd) ? e.total_cost_usd : undefined,
666
+ denied: Array.isArray(e.permission_denials) ? e.permission_denials.length : 0,
667
+ tokens: { in: n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens), out: n(u.output_tokens) },
668
+ };
669
+ if (x.result.text.trim()) note(x, 'result', x.result.text, x.result.error);
670
+ }
671
+ };
672
+
673
+ /** Ends a session: SIGTERM to every process group under its Claude Code (seen while it's still their parent), SIGKILL
674
+ * to them 5 s later, even if Claude Code is gone by then (a command it started may not be). */
675
+ function stop(x, why) {
676
+ if (x.done || x.stopWhy) return;
677
+ x.stopWhy = why;
678
+ const live = () => x.child.exitCode === null && x.child.signalCode === null;
679
+ x.groups = live() ? groupsUnder(x.child.pid) : new Set([x.child.pid]);
680
+ signalGroups(x.groups, 'SIGTERM');
681
+ killing.add(x);
682
+ x.killer = setTimeout(() => {
683
+ if (live()) for (const g of groupsUnder(x.child.pid)) x.groups.add(g);
684
+ signalGroups(x.groups, 'SIGKILL');
685
+ killing.delete(x);
686
+ }, KILL_AFTER_MS);
687
+ }
688
+ /** Stopped sessions whose SIGKILL is still to come. */
689
+ const killing = new Set();
690
+
691
+ const ended = async (x, code) => {
692
+ x.done = true;
693
+ clearInterval(x.tick);
694
+ clearTimeout(x.limit);
695
+ const r = x.result;
696
+ let status = 'done';
697
+ let error;
698
+ if (x.stopWhy === 'timeout') [status, error] = ['failed', 'timeout'];
699
+ else if (x.stopWhy) [status, error] = ['stopped', x.stopWhy];
700
+ else if (!r || r.error) [status, error] = ['failed', 'claude_failed'];
701
+ const quiet = x.stopWhy === 'paused' || x.stopWhy === 'revoked'; // Glow already knows (and won't take a word)
702
+ if (!quiet) {
703
+ // What's left of its transcript first (a batch at a time), then its last word.
704
+ while (x.sending) await new Promise((res) => setTimeout(res, 50));
705
+ for (let n = 0; n < 30 && x.log.length - x.logSent > LOG_BATCH; n++) {
706
+ const u = update(x, 'running');
707
+ if ((await send(x, u.body)).status !== 200) break;
708
+ x.logSent += u.sent;
709
+ }
710
+ const u = update(x, status);
711
+ await send(x, {
712
+ ...u.body, ...(error ? { error } : {}), ...(r?.cost !== undefined ? { cost_usd: r.cost } : {}), ...(r ? { denied: r.denied, tokens_in: r.tokens.in, tokens_out: r.tokens.out } : {}),
713
+ });
714
+ }
715
+ const tail = status === 'failed' && !r && x.err.trim() ? ` (${printable(mask(x.err.trim().split('\n').pop()), 160)})` : '';
716
+ 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}` : ''}`);
717
+ // The reply becomes the session's post in Glow, masked, like a hooked session's.
718
+ if (!quiet && r?.text?.trim() && x.sessionId) {
719
+ const now = readConfig() ?? cfg;
720
+ const ctx = gitContext(x.workdir);
721
+ const pr = prIn(r.text, x.repo);
722
+ await api(cfg, 'POST', '/api/v1/agent/reports', {
723
+ 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,
724
+ ...(pr ? { pr_url: pr } : {}), inbox: now.messages !== false, tools: tools(), run_id: x.id,
725
+ }, 15_000);
726
+ }
727
+ sessions.delete(x.id);
728
+ activeAt = Date.now();
729
+ writeState();
730
+ };
731
+
732
+ // However it ends (even a crash), no session outlives it: on the way out, every group under a running session gets
733
+ // SIGTERM, and those of stopped ones still waiting for theirs get SIGKILL.
734
+ const killAll = () => {
735
+ for (const x of sessions.values()) if (!x.stopWhy) signalGroups(groupsUnder(x.child.pid), 'SIGTERM');
736
+ for (const x of killing) signalGroups(x.groups, 'SIGKILL');
737
+ };
738
+ process.once('exit', killAll);
739
+
740
+ const loop = async () => {
741
+ if (!cfg) return 1;
742
+ fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
743
+ writeState();
744
+ say(`Glow runner ${VERSION} · ${printable(cfg.computer?.name) || 'this computer'} · ${printable(cfg.workspace?.name) || 'your workspace'}`);
745
+ say('Sessions run like your own claude --dangerously-skip-permissions, with your Claude Code model and effort, each in a git worktree of its own.');
746
+ say(allow.size ? `Only for ${[...allow.keys()].join(', ')}` : `Looking for linked repos in ${roots.map(tilde).join(', ')}`);
747
+ await refreshRepos();
748
+ say(cfg.phone?.fingerprint
749
+ ? `Only what your Glow phone signs runs here: ${printable(cfg.phone.name, 60)} (${fingerprintText(cfg.phone.fingerprint)}). Ctrl-C stops the runner and its sessions.`
750
+ : 'Only what your Glow phone signs runs here: make your phone your Glow phone in the app (Settings › Glow phone). Ctrl-C stops the runner and its sessions.');
751
+ try {
752
+ while (!ending) {
753
+ const now = readConfig();
754
+ if (!now || now.off) {
755
+ 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.');
756
+ await shutdown('runner_stopped');
757
+ return 1;
758
+ }
759
+ cfg = { ...now, repos: cfg.repos };
760
+ if (Date.now() - reposAt > REPOS_EVERY_MS) await refreshRepos();
761
+ const res = await api(cfg, 'POST', '/api/v1/agent/runner', { repos: [...repos.keys()], running: [...sessions.keys()], version: VERSION, ...(Object.keys(agentVersions).length ? { agent_versions: agentVersions } : {}) }, 15_000);
762
+ if (res.status === 401) {
763
+ say('This computer’s key no longer works (removed in Glow). Pair again: npx @winding-labs/glow');
764
+ await shutdown('revoked');
765
+ return 1;
766
+ }
767
+ if (res.status === 403) {
768
+ if (!paused) say(`Paused in Glow${sessions.size ? `: stopping ${sessions.size} session${sessions.size === 1 ? '' : 's'}` : ''}. Waiting to be resumed.`);
769
+ paused = true;
770
+ for (const x of sessions.values()) stop(x, 'paused');
771
+ await sleep(Math.max(POLL_MS, 30_000));
772
+ continue;
773
+ }
774
+ if (paused) say('Resumed in Glow.');
775
+ paused = false;
776
+ if (res.status !== 200) {
777
+ if (!offline) say(`Can’t reach Glow (${res.status || 'offline'}). Trying again…`);
778
+ offline = true;
779
+ await sleep(res.status === 429 ? 30_000 : Math.max(POLL_MS, 15_000));
780
+ continue;
781
+ }
782
+ if (offline) say('Glow is back.');
783
+ offline = false;
784
+ for (const id of Array.isArray(res.json?.stop) ? res.json.stop : []) {
785
+ const x = sessions.get(id);
786
+ if (x) {
787
+ say(`${clock()} ■ Stop from Glow: ${x.repo} · ${printable(x.title, 80)}`);
788
+ stop(x, 'stopped');
789
+ }
790
+ }
791
+ for (const s of Array.isArray(res.json?.sessions) ? res.json.sessions : []) {
792
+ if (sessions.size >= AT_ONCE) break;
793
+ if (!s || typeof s.id !== 'string' || typeof s.repo !== 'string' || typeof s.prompt !== 'string' || typeof s.title !== 'string') continue;
794
+ if (s.id.length > 64 || s.prompt.length > 100_000) continue;
795
+ try {
796
+ await launch(s);
797
+ } catch (e) {
798
+ sessions.delete(s.id);
799
+ await fail(s, 'claude_failed', `couldn’t start it (${printable(e?.message, 120)})`);
800
+ }
801
+ }
802
+ // Quiet for 10 minutes: ask half as often (a session started then waits a few seconds more).
803
+ await sleep(sessions.size || Date.now() - activeAt < IDLE_AFTER_MS ? POLL_MS : POLL_MS * 2);
804
+ }
805
+ await shutdown(ending);
806
+ return 0;
807
+ } finally {
808
+ try {
809
+ if (JSON.parse(fs.readFileSync(statePath(), 'utf8')).pid === process.pid) fs.rmSync(statePath(), { force: true });
810
+ } catch {
811
+ // someone else's, or gone
812
+ }
813
+ }
814
+ };
815
+
816
+ /** Stop every session (and say so to Glow), waiting up to 8 s for them to end. */
817
+ async function shutdown(why) {
818
+ for (const x of sessions.values()) stop(x, why);
819
+ // Until Claude Code and everything it started are gone (or the SIGKILL is due): then whatever is left gets it now.
820
+ const alive = (g) => {
821
+ try {
822
+ process.kill(-g, 0);
823
+ return true;
824
+ } catch (e) {
825
+ return e?.code === 'EPERM';
826
+ }
827
+ };
828
+ const until = Date.now() + KILL_AFTER_MS + 3000;
829
+ while ((sessions.size || [...killing].some((x) => [...x.groups].some(alive))) && Date.now() < until) await new Promise((r) => setTimeout(r, 100));
830
+ for (const x of killing) {
831
+ clearTimeout(x.killer);
832
+ signalGroups(x.groups, 'SIGKILL');
833
+ }
834
+ killing.clear();
835
+ }
836
+
837
+ const done = loop();
838
+ return {
839
+ done,
840
+ /** Ctrl-C: end the loop; it stops its sessions (and tells Glow) before `done` resolves. */
841
+ stop() {
842
+ ending ??= 'runner_stopped';
843
+ wake?.();
844
+ return done;
845
+ },
846
+ };
847
+ }
848
+
849
+ const isDir = (p) => {
850
+ try {
851
+ return fs.lstatSync(p).isDirectory();
852
+ } catch {
853
+ return false;
854
+ }
855
+ };
856
+
857
+ const tilde = (p) => {
858
+ const h = os.homedir();
859
+ return p.startsWith(h) ? `~${p.slice(h.length)}` : p;
860
+ };