@winding-labs/glow 0.1.5 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -20
- package/SECURITY.md +55 -27
- package/bin/glow.mjs +56 -16
- package/lib/core.mjs +125 -4
- package/lib/hook.mjs +23 -3
- package/lib/runner.mjs +254 -63
- package/marketplace/.claude-plugin/marketplace.json +1 -1
- package/marketplace/glow/.claude-plugin/plugin.json +1 -1
- package/marketplace/glow/.codex-plugin/plugin.json +1 -1
- package/marketplace/glow/skills/run/SKILL.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,30 +35,48 @@ Only the person who paired the computer can message its sessions, and Glow asks
|
|
|
35
35
|
first. Claude Code picks a message up while it's idle (for 25 minutes after its last reply); Codex picks it up when its
|
|
36
36
|
current turn ends. Don't want that? `npx @winding-labs/glow messages off` on the computer.
|
|
37
37
|
|
|
38
|
-
##
|
|
38
|
+
## Run your queue here
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
npx @winding-labs/glow run ~/code
|
|
41
|
+
npx @winding-labs/glow run ~/code --background
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Only if you want it, and only while it runs:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
44
|
+
Only if you want it, and only while it runs: press **Run** on your queue in the Glow app, look at your phone (Face ID),
|
|
45
|
+
and the tasks in it run here in Claude Code, a few at once, oldest first, with your own Claude Code model and effort
|
|
46
|
+
(whatever `claude` uses in a terminal). For the next 5 minutes, anything you add to the queue runs too. Nothing starts by
|
|
47
|
+
itself. Glow's Agents tab shows the
|
|
48
|
+
queue live: what's running and its last step, what's next, what finished and what came of it, with each run's time,
|
|
49
|
+
turns, cost and how full its context is. Tap one for its whole run: what it was asked, what it said and did, its reply.
|
|
50
|
+
**Run now** starts any task at once (Glow's suggestions and teammates' tasks run only when you press it); **New
|
|
51
|
+
session** starts one with a prompt you type. Each runs in your checkout of a repo that's linked to the task's product
|
|
52
|
+
and found under the folders you give it (the current folder by default). Its reply posts to the product's channel like
|
|
53
|
+
any session's, except for a task someone else wrote or one filed from a capture: then only you see it, on its run.
|
|
54
|
+
Pause the computer in Glow to hold the queue.
|
|
55
|
+
|
|
56
|
+
- **Only with your Glow phone's Face ID:** one phone on your account is your Glow phone (the app: Settings › Glow phone).
|
|
57
|
+
It holds a key that never leaves its Secure Enclave (or Android Keystore) and signs each session, and each message to
|
|
58
|
+
a session, after Face ID or your fingerprint: what runs, in which repo, on this computer. This computer checks that
|
|
59
|
+
signature against the phone it trusts before anything runs or reaches an agent, so Glow alone, or anyone who got into
|
|
60
|
+
your Glow account, can't run anything here. It trusts the first Glow phone Glow names; after you make a new phone
|
|
61
|
+
yours, run `npx @winding-labs/glow phone trust` here (it shows the fingerprint to check against the app).
|
|
62
|
+
`npx @winding-labs/glow phone` says which one it trusts.
|
|
63
|
+
- **Only yours:** only you (the person who paired the computer) start sessions on it. Glow's suggestions and teammates'
|
|
64
|
+
tasks run when you press Run on them, like yours.
|
|
65
|
+
- **Full access:** every session runs like your own `claude --dangerously-skip-permissions`: nothing asks before a
|
|
66
|
+
command, so start the runner only on a computer where you'd run that yourself. Each works in a git worktree of its own
|
|
67
|
+
(`.claude/worktrees/glow-…`, a new branch from your default branch), so your checkout stays as it is, and is told to
|
|
68
|
+
ship its work as a pull request: commit, push its branch, `gh pr create`, never your default branch. pnpm and yarn work
|
|
69
|
+
even if only Node's corepack is installed. Glow's own CLI and `~/.glow` stay denied (a session can't turn
|
|
70
|
+
Glow off, stop the runner or read the computer's key). Read a task someone else wrote, or one from a capture, before
|
|
71
|
+
you press Run: it runs with the same access.
|
|
72
|
+
- **This computer decides too**, whatever Glow sends: `--repo owner/name` (repeat it) runs only those repos,
|
|
58
73
|
and `--repo owner/name=~/code/zest` picks the checkout when a repo is checked out more than once (otherwise it doesn't
|
|
59
74
|
run; worktrees don't count: it runs in the main checkout). Only in a folder you've trusted in Claude Code (opened `claude` there and said yes), since its own `.claude`
|
|
60
75
|
settings and hooks apply. A session id is run once, never again.
|
|
61
|
-
- **
|
|
76
|
+
- **What Glow keeps of a run:** its usage (how many of its tool calls failed, and how much of your Claude plan was used
|
|
77
|
+
when it started), and for 2 days its transcript (Claude Code's words, each tool or command,
|
|
78
|
+
the first lines of each result, its reply), keys masked and cut short, at most 256 KB, that only you can read.
|
|
79
|
+
- **Up to 3 at once** on a computer, even in one repo (each in its own worktree), the rest wait their turn, 30 a day, 3 hours each at most. Stop from Glow ends the session and
|
|
62
80
|
the commands it started. Pausing the computer in Glow stops them all; Ctrl-C (or closing the terminal) stops the
|
|
63
81
|
runner and its sessions, and everything they started.
|
|
64
82
|
- `run --background` keeps it going after you close the terminal (log: `~/.glow/runner.log`); `run stop` stops it. A
|
|
@@ -71,7 +89,7 @@ then only you see it, under the session.
|
|
|
71
89
|
/glow:ignore this repo never sends to Glow from this computer (add a folder or owner/name for another)
|
|
72
90
|
/glow:unignore let it send again (only when you type it: an agent can't run it on its own in Claude Code)
|
|
73
91
|
/glow:run start (or stop) the runner in the background, for the linked repos under the session's folder
|
|
74
|
-
(only when you type it;
|
|
92
|
+
(only when you type it; --repo and other folders are for the terminal)
|
|
75
93
|
```
|
|
76
94
|
|
|
77
95
|
In Codex they're `$glow:status`, `$glow:ignore`, `$glow:unignore` and `$glow:run`. Setup adds them as a `glow` plugin, through each
|
|
@@ -87,8 +105,8 @@ npx @winding-labs/glow ignore [repo] this repo (or a folder, or owner/name) ne
|
|
|
87
105
|
npx @winding-labs/glow unignore [repo] let it send again
|
|
88
106
|
npx @winding-labs/glow off | on stop or start sending from this computer
|
|
89
107
|
npx @winding-labs/glow messages off messages from Glow no longer reach your agents here (messages on: again)
|
|
90
|
-
npx @winding-labs/glow run [folders]
|
|
91
|
-
[--
|
|
108
|
+
npx @winding-labs/glow run [folders] run your queue (and sessions you start in Glow) in the linked repos under these folders
|
|
109
|
+
[--repo owner/name[=path]…] [--background]
|
|
92
110
|
npx @winding-labs/glow run stop stop the runner (and its sessions)
|
|
93
111
|
npx @winding-labs/glow uninstall remove the hooks and the plugin, revoke the key, delete ~/.glow
|
|
94
112
|
```
|
package/SECURITY.md
CHANGED
|
@@ -13,7 +13,9 @@ Report a vulnerability to **security@meetglow.ai**. Please don't open a public i
|
|
|
13
13
|
transcripts.
|
|
14
14
|
A repo you ignore here (`/glow:ignore`) sends nothing, linked or not: the hook stops before any request.
|
|
15
15
|
- **Run git read-only** (the origin URL, the branch, commit titles), with no shell, hooks, fsmonitor, pager, signature
|
|
16
|
-
checks or `GIT_*` variables, so a repo's `.git/config` can't make it run a program.
|
|
16
|
+
checks or `GIT_*` variables, so a repo's `.git/config` can't make it run a program. The runner (below) also fetches a
|
|
17
|
+
linked repo's default branch and adds a worktree for each session: hooks, fsmonitor and pager still off and none of
|
|
18
|
+
your `GIT_*` variables, but with your git credentials, so the fetch works.
|
|
17
19
|
- **Edit two settings files:** add one `Stop` hook to `~/.claude/settings.json` and `~/.codex/hooks.json`, keep
|
|
18
20
|
everything else, back up the originals once (`*.before-glow`), and never overwrite a file that isn't valid JSON.
|
|
19
21
|
- **Add one plugin** (`glow`, from `~/.glow/marketplace`) with each agent's own `claude plugin` / `codex plugin`
|
|
@@ -22,33 +24,58 @@ Report a vulnerability to **security@meetglow.ai**. Please don't open a public i
|
|
|
22
24
|
|
|
23
25
|
- **Start Claude Code, only while `glow run` runs** (you start it; nothing installs it to start on its own). It runs
|
|
24
26
|
`claude` as an argument list (no shell), in your checkout of a linked repo found under the folders you gave it, with
|
|
25
|
-
the prompt on stdin (so a prompt can't become a flag),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
the prompt on stdin (so a prompt can't become a flag), with `--dangerously-skip-permissions`: like your own Claude
|
|
28
|
+
Code with that flag, nothing asks before a command, and your Claude Code settings pick its model and effort (Glow
|
|
29
|
+
passes neither). Starting the runner is how you say yes to that, on this computer; it says so when it starts. Each
|
|
30
|
+
session runs in a git worktree of its own that the runner makes (`.claude/worktrees/glow-<id>`, letters and digits
|
|
31
|
+
only, on a new branch of that name from the default branch as origin has it, kept afterwards), never in the checkout
|
|
32
|
+
you work in. It's told to ship its work as a pull request: commit on that branch, push it and open one with `gh`,
|
|
33
|
+
never pushing to the default branch or merging (an instruction, not a limit: with full access it could). Where pnpm or
|
|
34
|
+
yarn isn't installed, corepack's own shims for them (`~/.cache/glow/bin`) go last on its PATH. Nothing starts by
|
|
35
|
+
itself: each session runs only with your Glow phone's signature over it (below), up to 3 at once; a task someone else
|
|
36
|
+
wrote, or one from a capture, runs with the same access when you press Run, so read it first. Always `--disallowedTools` for
|
|
37
|
+
Glow's own CLI (`~/.glow/glow`, `npx @winding-labs/glow`, `node …/glow.mjs`) and files (`~/.glow`, the computer's
|
|
38
|
+
key), which holds with permission checks skipped and no allow rule (the plugin's included) can grant back. That list
|
|
39
|
+
matches the command's text, so a session could spell its way around it (`~/.gl?w/glow`); the CLI also refuses any
|
|
40
|
+
command run from inside a session. One command per call: `glow ignore x run …` is refused, so a skill allowed to run one
|
|
33
41
|
command can't reach another. Stop, a pause in Glow, the 3-hour limit, Ctrl-C or closing the
|
|
34
42
|
terminal ends a session and everything it started: Claude Code's Bash tool and hooks start each command in a process
|
|
35
43
|
group of its own, so the runner signals every group under that Claude Code (SIGTERM, then SIGKILL after 5 s; SIGTERM
|
|
36
44
|
at once if the runner itself exits).
|
|
37
|
-
- **
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
- **Run only what your Glow phone signed** (review-2026-11-03-signed-runs.md). Each session comes with a signature by
|
|
46
|
+
your Glow phone: ECDSA P-256 with a key made in its Secure Enclave or Android Keystore, usable only after Face ID or a
|
|
47
|
+
fingerprint (one check covers 5 minutes), over the session's id, this computer's id, the repo, the SHA-256 of the
|
|
48
|
+
prompt and the time. The runner checks it against the phone key this computer pinned (`~/.glow/config.json` `phone`:
|
|
49
|
+
the first phone Glow named, or the one you trusted with `glow phone trust` after checking its fingerprint at the
|
|
50
|
+
terminal; Glow naming another is only reported) before anything else, rebuilding what was signed from what would run,
|
|
51
|
+
within 6 hours (5 minutes of clock skew), and each signature once (`~/.glow/seen`). One that fails is reported failed
|
|
52
|
+
(`not_signed`, `bad_signature`, `untrusted_phone`) and never runs. Messages to a session are checked the same way by
|
|
53
|
+
the hook (over the message id, this computer, the session and the exact text, within a day) and dropped otherwise.
|
|
54
|
+
So a compromise of Glow's servers, or of your Glow account without your phone, can't run anything here.
|
|
55
|
+
- **Not take Glow's word for it.** The runner refuses, on your computer: a repo outside `--repo` when you gave it; a
|
|
56
|
+
folder Claude Code hasn't been trusted in (`claude -p` skips that question, and a repo's own `.claude` settings and
|
|
57
|
+
hooks would apply); a repo with more than one main checkout unless you pinned one (`--repo owner/name=path`; its
|
|
58
|
+
worktrees aren't copies: it runs in the main one); and a session id it has already been given. Glow sends only the
|
|
59
|
+
repo, the title and the prompt: no flag, model or effort of Glow's ever reaches `claude`. A command run inside one of
|
|
43
60
|
its sessions can't stop the runner, turn Glow off or change what it sends: besides the denied tools, `glow` refuses
|
|
44
61
|
when its environment has the session's marker (`GLOW_RUN_SESSION`, kept by `~/.glow/glow`), when a session's Claude
|
|
45
62
|
Code is among its parent processes, or when it runs in a session's process group.
|
|
46
63
|
Signed launch tokens (Glow signs each session, the computer checks it with a key it keeps) are a later step: today a
|
|
47
|
-
compromised Glow account or server could start a session within those limits
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
compromised Glow account or server could start a session within those limits, which, with full access, means a
|
|
65
|
+
command as you on this computer. Run the runner only where you'd run `claude --dangerously-skip-permissions`
|
|
66
|
+
yourself, and stop it (`glow run stop`) when you don't need it.
|
|
67
|
+
- **Say what runs here:** Glow's version on every call, and while the runner is on, Claude Code's and Codex's versions
|
|
68
|
+
(each one's `--version`), for the tags beside this computer in Glow.
|
|
69
|
+
- **Report how a session goes:** running, done, failed or stopped; the model Claude Code says it runs on; how much of
|
|
70
|
+
your Claude plan is used (a share of its fullest window, as Claude Code says once a session); turns, cost, tokens, how full its context is, how
|
|
71
|
+
many tool calls it made, failed or were turned down; and its last step as one line: a command by its program and subcommand only
|
|
72
|
+
(`$ psql …`, never its arguments), a file path relative to the repo, a search pattern or a web host. Keys and tokens
|
|
73
|
+
are masked before anything is cut, the home folder is `~`, and Glow cuts and scrubs it again.
|
|
74
|
+
- **Its transcript, for you only, for 2 days:** Claude Code's words, each tool call (a command whole, with secret-named
|
|
75
|
+
variables and keys masked), the first 600 characters of each result and its reply, each entry masked here and again
|
|
76
|
+
in Glow and cut short (4,000 characters), at most 2,000 entries and 256 KB a session. Glow keeps it in its storage
|
|
77
|
+
under the workspace, deletes it after 2 days (and with the workspace), and shows it only to the person who started
|
|
78
|
+
the session. Never file contents it wrote.
|
|
52
79
|
|
|
53
80
|
## What the key can do in Glow
|
|
54
81
|
|
|
@@ -59,15 +86,16 @@ The key (`glowc_…`, stored in Glow only as a hash) can send a session report,
|
|
|
59
86
|
messages you sent to this computer's sessions, and revoke itself. It can't read anything else in your workspace. It
|
|
60
87
|
stops working when you remove the computer, leave the workspace, or don't use it for 90 days.
|
|
61
88
|
|
|
62
|
-
Messages back: only the person who paired the computer can message its sessions, with a fresh second factor each time.
|
|
89
|
+
Messages back: only the person who paired the computer can message its sessions, with a fresh second factor each time and their Glow phone's signature, which the hook checks.
|
|
63
90
|
`npx @winding-labs/glow messages off` turns them off on the computer.
|
|
64
91
|
|
|
65
|
-
Sessions started from Glow: only the person who paired the computer can start one,
|
|
66
|
-
|
|
92
|
+
Sessions started from Glow: only the person who paired the computer can start one, in a repo linked to a product and
|
|
93
|
+
checked out on the computer, while `glow run` (0.1.10 or later) is on there, and only with their Glow phone's
|
|
94
|
+
signature (Face ID), which the computer checks. Nothing starts by itself. With the runner on, the key can
|
|
67
95
|
also say which linked repos are checked out, pick up a waiting session (Glow hands the prompt over once and then
|
|
68
|
-
deletes it) and report its status. Glow allows 3 at once per computer,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
deletes it) and report its status. Glow allows 3 at once per computer, the rest waiting their turn, and 30 a day; a session not signed in 2 minutes,
|
|
97
|
+
or signed but not picked up (in 6 hours, or 10 minutes with the runner off) fails (closed nightly at the latest, prompt deleted), and pausing or removing the computer stops
|
|
98
|
+
its sessions, as does Glow's own kill switch. Every session has full access (`--dangerously-skip-permissions`): nothing
|
|
99
|
+
asks before a command, and a task runs as you on this computer. When someone else wrote the task, or it came from a
|
|
72
100
|
capture, the session's reply goes to you only (under the session in the Agents tab), never to a channel. Only you see
|
|
73
101
|
which repos the runner found on your computer.
|
package/bin/glow.mjs
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// npx @winding-labs/glow recipes off stop sending the request each reply answers (Glow learns recipes from it)
|
|
12
12
|
// npx @winding-labs/glow run [folder…] let Glow start Claude Code sessions here, in the linked repos under these
|
|
13
13
|
// folders (default: this one), while it runs (--background; run stop)
|
|
14
|
+
// npx @winding-labs/glow phone the Glow phone this computer trusts (what it signs runs here), and Glow's word
|
|
15
|
+
// npx @winding-labs/glow phone trust trust the phone Glow names now instead (after you made a new one yours)
|
|
14
16
|
// npx @winding-labs/glow uninstall remove the hooks and plugins, revoke this computer's key, delete ~/.glow
|
|
15
17
|
//
|
|
16
18
|
// Setup copies this package to ~/.glow (bin, lib, marketplace) with two shims: ~/.glow/hook, which the agents' Stop
|
|
@@ -23,8 +25,8 @@ import os from 'node:os';
|
|
|
23
25
|
import path from 'node:path';
|
|
24
26
|
import { fileURLToPath } from 'node:url';
|
|
25
27
|
import { addPlugin, AGENTS, detect, install, installed, removePlugin, shimPath, shq, uninstall } from '../lib/agents.mjs';
|
|
26
|
-
import { api, apiBase, apiOk, computerName, DEFAULT_API, dir, ignoredOf, printable, readConfig, REPO_RE, repoOf, VERSION, writeConfig } from '../lib/core.mjs';
|
|
27
|
-
import { inRunnerSession, logPath,
|
|
28
|
+
import { api, apiBase, apiOk, computerName, DEFAULT_API, dir, fingerprintText, ignoredOf, phoneOf, pinPhone, printable, readConfig, REPO_RE, repoOf, VERSION, writeConfig } from '../lib/core.mjs';
|
|
29
|
+
import { inRunnerSession, logPath, runnerState, SESSION_ENV, startRunner } from '../lib/runner.mjs';
|
|
28
30
|
|
|
29
31
|
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
30
32
|
const tty = process.stdout.isTTY;
|
|
@@ -47,13 +49,15 @@ const HELP = `Glow ${VERSION}: send what your coding agents do to your Glow chan
|
|
|
47
49
|
npx @winding-labs/glow off | on stop or start sending from this computer
|
|
48
50
|
npx @winding-labs/glow messages off stop messages from Glow reaching your agents here (messages on: again)
|
|
49
51
|
npx @winding-labs/glow recipes off don't send your requests, so Glow learns no recipes from them (recipes on: again)
|
|
50
|
-
npx @winding-labs/glow run [folder…] let Glow start Claude Code sessions here (Agents tab), in the linked
|
|
51
|
-
checked out under these folders (default: this one), while it runs
|
|
52
|
+
npx @winding-labs/glow run [folder…] let Glow start Claude Code sessions here (Agents tab, your queue), in the linked
|
|
53
|
+
repos checked out under these folders (default: this one), while it runs. Each
|
|
54
|
+
runs like your own claude --dangerously-skip-permissions, with your Claude Code
|
|
55
|
+
model and effort, in a git worktree of its own
|
|
52
56
|
--background keep it running after this terminal closes (log: ~/.glow/runner.log)
|
|
53
|
-
--max-mode read|edit|auto the most a session may do on its own here, whatever Glow asks (default: read,
|
|
54
|
-
Look only; edit lets it edit files, auto runs Claude Code's auto mode)
|
|
55
57
|
--repo owner/name[=path] only this repo (repeat for more); =path picks its checkout
|
|
56
58
|
npx @winding-labs/glow run stop stop it, and the sessions it runs
|
|
59
|
+
npx @winding-labs/glow phone the Glow phone this computer trusts: only what it signs (after Face ID) runs here
|
|
60
|
+
npx @winding-labs/glow phone trust trust the phone Glow names now instead (once you've made a new phone yours)
|
|
57
61
|
npx @winding-labs/glow uninstall remove the hooks and revoke this computer's key
|
|
58
62
|
|
|
59
63
|
In Claude Code: /glow:status, /glow:ignore, /glow:unignore, /glow:run (in Codex: $glow:status, $glow:ignore, …)
|
|
@@ -66,7 +70,7 @@ Options for setup:
|
|
|
66
70
|
`;
|
|
67
71
|
|
|
68
72
|
function parse(argv) {
|
|
69
|
-
const o = { cmd: 'install', agents: [], api: DEFAULT_API, dev: false, open: true, fresh: false, name: '', messages: null, recipes: null, target: '', roots: [], background: false, runStop: false,
|
|
73
|
+
const o = { cmd: 'install', agents: [], api: DEFAULT_API, dev: false, open: true, fresh: false, name: '', messages: null, recipes: null, target: '', roots: [], background: false, runStop: false, allow: new Map() };
|
|
70
74
|
// One command per call: `glow ignore x run …` must not become `run` (a skill allowed to run `glow ignore` can't
|
|
71
75
|
// reach any other command).
|
|
72
76
|
let named = '', asked = '';
|
|
@@ -95,6 +99,9 @@ function parse(argv) {
|
|
|
95
99
|
} else if (a === 'ignore' || a === 'unignore') {
|
|
96
100
|
command(a);
|
|
97
101
|
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) o.target = argv[++i];
|
|
102
|
+
} else if (a === 'phone') {
|
|
103
|
+
command('phone');
|
|
104
|
+
if (argv[i + 1] === 'trust') o.trust = !!argv[++i];
|
|
98
105
|
} else if (a === 'run') {
|
|
99
106
|
command('run');
|
|
100
107
|
while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
|
|
@@ -103,10 +110,7 @@ function parse(argv) {
|
|
|
103
110
|
else o.roots.push(x);
|
|
104
111
|
}
|
|
105
112
|
} else if (a === '--background' && o.cmd === 'run') o.background = true;
|
|
106
|
-
else if (a === '--
|
|
107
|
-
o.maxMode = argv[++i] ?? '';
|
|
108
|
-
if (!MODES.includes(o.maxMode)) fail('--max-mode is read, edit or auto.');
|
|
109
|
-
} else if (a === '--repo' && o.cmd === 'run') {
|
|
113
|
+
else if (a === '--repo' && o.cmd === 'run') {
|
|
110
114
|
const [name, at] = String(argv[++i] ?? '').split(/=(.*)/s);
|
|
111
115
|
const repo = name.toLowerCase();
|
|
112
116
|
if (!REPO_RE.test(repo)) fail('--repo is owner/name, or owner/name=path to pick its checkout.');
|
|
@@ -128,6 +132,7 @@ async function main() {
|
|
|
128
132
|
if (o.cmd === 'help') return console.log(HELP);
|
|
129
133
|
if (o.cmd === 'version') return console.log(VERSION);
|
|
130
134
|
if (o.cmd === 'status') return status();
|
|
135
|
+
if (o.cmd === 'phone' && !o.trust) return phone(false);
|
|
131
136
|
// A session Glow started here can't change how Glow reaches this computer (stop the runner, turn sending off, …).
|
|
132
137
|
if (inRunnerSession()) fail('Not from a session Glow started: run it yourself, in a terminal.');
|
|
133
138
|
if (o.cmd === 'off' || o.cmd === 'on') return toggle(o.cmd === 'off');
|
|
@@ -135,6 +140,7 @@ async function main() {
|
|
|
135
140
|
if (o.cmd === 'recipes') return recipes(o.recipes);
|
|
136
141
|
if (o.cmd === 'ignore' || o.cmd === 'unignore') return ignore(o.target, o.cmd === 'ignore');
|
|
137
142
|
if (o.cmd === 'run') return run(o);
|
|
143
|
+
if (o.cmd === 'phone') return phone(true);
|
|
138
144
|
if (o.cmd === 'uninstall') return remove();
|
|
139
145
|
return setup(o);
|
|
140
146
|
}
|
|
@@ -157,8 +163,10 @@ async function setup(o) {
|
|
|
157
163
|
let info = null;
|
|
158
164
|
if (cfg) {
|
|
159
165
|
const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
|
|
160
|
-
if (res.status === 200)
|
|
161
|
-
|
|
166
|
+
if (res.status === 200) {
|
|
167
|
+
info = res.json;
|
|
168
|
+
cfg = pinPhone(cfg, res.json?.phone, ok);
|
|
169
|
+
} else if (res.status === 401) {
|
|
162
170
|
warn('This computer’s key no longer works (it was removed in Glow). Pairing again.');
|
|
163
171
|
cfg = null;
|
|
164
172
|
} else if (res.status === 403) warn('This computer is paused in Glow. Resume it there (Connections › Coding agents).');
|
|
@@ -332,6 +340,35 @@ const linkUrl = (cfg, repo) => `${apiBase(cfg.api)}/app/pair?repo=${encodeURICom
|
|
|
332
340
|
// ---------- status, off/on, uninstall ----------
|
|
333
341
|
|
|
334
342
|
/** What's set up here, what this folder sends (and why not), and the repos that report or are ignored. */
|
|
343
|
+
/** The Glow phone this computer trusts, and Glow's word on it; `trust`: pin the one Glow names now, once its owner,
|
|
344
|
+
* at this terminal, says it's theirs (its fingerprint, as the app shows it). */
|
|
345
|
+
async function phone(trust) {
|
|
346
|
+
const cfg = readConfig();
|
|
347
|
+
if (!cfg) fail('Glow isn’t set up on this computer. Set it up: npx @winding-labs/glow');
|
|
348
|
+
const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
|
|
349
|
+
if (res.status !== 200) fail(res.status === 401 ? 'This computer’s key no longer works. Pair again: npx @winding-labs/glow' : `Couldn’t ask Glow (${res.status || 'offline'}).`);
|
|
350
|
+
const named = phoneOf(res.json?.phone);
|
|
351
|
+
const line = (p) => `${bold(printable(p.name, 60))} ${dim(fingerprintText(p.fingerprint))}`;
|
|
352
|
+
if (!trust) {
|
|
353
|
+
console.log(cfg.phone?.fingerprint ? `Trusted here: ${line(cfg.phone)}. Only what it signs (after Face ID) runs on this computer.` : 'No Glow phone is trusted here yet: make your phone your Glow phone in the app (Settings › Glow phone).');
|
|
354
|
+
if (named && named.fingerprint !== cfg.phone?.fingerprint) console.log(`Glow names ${line(named)}. To trust it instead: npx @winding-labs/glow phone trust`);
|
|
355
|
+
if (!named) console.log(dim('Glow names no Glow phone for you right now.'));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!named) fail('Glow names no Glow phone for you. Make your phone your Glow phone in the app first (Settings › Glow phone).');
|
|
359
|
+
if (named.fingerprint === cfg.phone?.fingerprint) return ok(`Already trusted: ${line(named)}`);
|
|
360
|
+
// Glow's word alone never swaps the key: the person at this terminal checks the fingerprint against their phone.
|
|
361
|
+
if (!process.stdin.isTTY || !tty) fail('Run it yourself, in a terminal: it asks you to check the fingerprint against your phone.');
|
|
362
|
+
console.log(`Glow says your Glow phone is now ${line(named)}.`);
|
|
363
|
+
console.log(`Open the app on that phone, Settings › Glow phone: it shows ${bold(fingerprintText(named.fingerprint))}. Not the same, or not your doing? Answer no.`);
|
|
364
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
365
|
+
const answer = await new Promise((r) => rl.question('Trust it on this computer? (yes/no) ', r));
|
|
366
|
+
rl.close();
|
|
367
|
+
if (String(answer).trim().toLowerCase() !== 'yes') return warn('Nothing changed.');
|
|
368
|
+
writeConfig({ ...(readConfig() ?? cfg), phone: { ...named, pinned_at: new Date().toISOString() } });
|
|
369
|
+
ok(`Trusted: ${line(named)}. What it signs runs here; what ${cfg.phone?.name ? printable(cfg.phone.name, 60) : 'any other phone'} signs doesn’t.`);
|
|
370
|
+
}
|
|
371
|
+
|
|
335
372
|
async function status() {
|
|
336
373
|
const cfg = readConfig();
|
|
337
374
|
if (!cfg) return console.log('Glow isn’t set up on this computer, so nothing is sent from it. Set it up: npx @winding-labs/glow');
|
|
@@ -365,6 +402,9 @@ async function status() {
|
|
|
365
402
|
['Agents', agents],
|
|
366
403
|
['Reporting', reporting.length ? reporting.join(', ') : 'no repo yet'],
|
|
367
404
|
['Ignored here', ignored.length ? ignored.join(', ') : 'none'],
|
|
405
|
+
['Glow phone', cfg.phone?.fingerprint
|
|
406
|
+
? `${printable(cfg.phone.name, 60)} (${fingerprintText(cfg.phone.fingerprint)}): only what it signs runs here${phoneOf(res.json?.phone) && phoneOf(res.json.phone).fingerprint !== cfg.phone.fingerprint ? ` · Glow names another: npx @winding-labs/glow phone trust` : ''}`
|
|
407
|
+
: 'none trusted yet: make your phone your Glow phone in the app (Settings › Glow phone)'],
|
|
368
408
|
['Runner', rs
|
|
369
409
|
? `on since ${new Date(rs.started_at).toLocaleString()} (pid ${rs.pid}) in ${(rs.roots ?? []).map((r) => printable(r, 160)).join(', ')}: Glow can start Claude Code sessions here · stop: npx @winding-labs/glow run stop`
|
|
370
410
|
: 'off: Glow can’t start sessions here (to let it: npx @winding-labs/glow run, in your code folder)'],
|
|
@@ -510,17 +550,17 @@ async function run(o) {
|
|
|
510
550
|
}
|
|
511
551
|
const fd = fs.openSync(log, 'a', 0o600);
|
|
512
552
|
fs.chmodSync(log, 0o600);
|
|
513
|
-
const flags = [
|
|
553
|
+
const flags = [...o.allow].flatMap(([r, at]) => ['--repo', at ? `${r}=${at}` : r]);
|
|
514
554
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'run', ...roots, ...flags], { detached: true, stdio: ['ignore', fd, fd], env: process.env, cwd: roots[0] });
|
|
515
555
|
child.unref();
|
|
516
556
|
for (let n = 0; n < 80 && runnerState()?.pid !== child.pid; n++) await sleep(100);
|
|
517
557
|
if (runnerState()?.pid !== child.pid) fail(`The runner didn’t start. See ${tilde(log)}`);
|
|
518
|
-
ok(`Runner on in the background (pid ${child.pid}): Glow
|
|
558
|
+
ok(`Runner on in the background (pid ${child.pid}): it runs what your Glow phone signs, 3 at a time, in the linked repos under ${roots.map(tilde).join(', ')} (pause this computer in Glow to hold it).`);
|
|
519
559
|
console.log(dim(`Log: ${tilde(log)} · Stop: npx @winding-labs/glow run stop`));
|
|
520
560
|
return;
|
|
521
561
|
}
|
|
522
562
|
for (const [r, at] of o.allow) if (at && (!fs.existsSync(at) || !fs.statSync(at).isDirectory())) fail(`${printable(at, 160)} (for ${r}) isn’t a folder.`);
|
|
523
|
-
const r = startRunner({ roots, say: (line) => console.log(line),
|
|
563
|
+
const r = startRunner({ roots, say: (line) => console.log(line), allow: o.allow });
|
|
524
564
|
const quit = () => {
|
|
525
565
|
console.log(dim('\nStopping the runner and its sessions…'));
|
|
526
566
|
void r.stop();
|
package/lib/core.mjs
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
// - git runs without a shell, with hooks, fsmonitor and signature checks off and none of the caller's GIT_* variables,
|
|
13
13
|
// so a repo (or its .git/config) can't make it run anything.
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
15
|
+
import { createHash, createPublicKey, randomBytes, verify } from 'node:crypto';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
-
export const VERSION = '0.1.
|
|
20
|
+
export const VERSION = '0.1.11';
|
|
21
21
|
export const DEFAULT_API = 'https://app.meetglow.ai';
|
|
22
22
|
export const dir = () => path.join(os.homedir(), '.glow');
|
|
23
23
|
export const configPath = () => path.join(dir(), 'config.json');
|
|
@@ -104,10 +104,12 @@ export async function api(c, method, p, body, timeoutMs = 10_000) {
|
|
|
104
104
|
|
|
105
105
|
// ---------- git ----------
|
|
106
106
|
|
|
107
|
+
/** Every git run here: no hooks, fsmonitor, signature checks or pager, so nothing of the repo's own runs. */
|
|
108
|
+
export const GIT_SAFE = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'log.showSignature=false', '-c', 'core.pager=cat'];
|
|
109
|
+
|
|
107
110
|
function git(cwd, args) {
|
|
108
111
|
try {
|
|
109
|
-
|
|
110
|
-
return execFileSync('git', ['-C', cwd, ...opts, ...args], {
|
|
112
|
+
return execFileSync('git', ['-C', cwd, ...GIT_SAFE, ...args], {
|
|
111
113
|
encoding: 'utf8',
|
|
112
114
|
timeout: 3000,
|
|
113
115
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -364,3 +366,122 @@ export function activityOf(name, input, cwd = '') {
|
|
|
364
366
|
else line = tool;
|
|
365
367
|
return printable(line, 160).trim();
|
|
366
368
|
}
|
|
369
|
+
|
|
370
|
+
// ---------- your Glow phone (packages/core src/phone.ts; docs/security/review-2026-11-03-signed-runs.md) ----------
|
|
371
|
+
// Glow hands this computer a session or a message only with the signature of its starter's Glow phone: a P-256 key in
|
|
372
|
+
// that phone's Secure Enclave or Keystore, used only after Face ID (or a fingerprint), over exactly what will run. Here
|
|
373
|
+
// it's checked against the phone key this computer pinned (config.phone: the first one Glow named, or the one `glow
|
|
374
|
+
// phone trust` was told to trust) before anything runs or reaches an agent, so Glow's servers alone can neither start
|
|
375
|
+
// anything here nor swap the key. Each signature is used once (~/.glow/seen).
|
|
376
|
+
|
|
377
|
+
export const RUN_SIGN_V = 'glow-run-v1';
|
|
378
|
+
export const MESSAGE_SIGN_V = 'glow-message-v1';
|
|
379
|
+
/** How far a signature's time may be from this computer's clock, and how long a signed session may wait (core). */
|
|
380
|
+
export const SIGN_SKEW_S = 300;
|
|
381
|
+
export const SIGNED_RUN_TTL_S = 6 * 3600;
|
|
382
|
+
export const SIGNED_MESSAGE_TTL_S = 24 * 3600;
|
|
383
|
+
|
|
384
|
+
/** What the phone signed: one field a line, no trailing newline (pinned against core in test/cli.test.ts). */
|
|
385
|
+
export const runPayload = (r) => [RUN_SIGN_V, r.session, r.computer, r.repo, r.prompt_sha256, String(r.issued_at)].join('\n');
|
|
386
|
+
export const messagePayload = (m) => [MESSAGE_SIGN_V, m.message, m.computer, m.session_key, m.body_sha256, String(m.issued_at)].join('\n');
|
|
387
|
+
/** "3F9A 1C22 7B0E 44D1": what the app shows under Settings › Glow phone. */
|
|
388
|
+
export const fingerprintText = (hex) => String(hex ?? '').slice(0, 16).toUpperCase().replace(/(.{4})(?!$)/g, '$1 ');
|
|
389
|
+
|
|
390
|
+
/** An uncompressed P-256 point's SubjectPublicKeyInfo, up to the point. */
|
|
391
|
+
const P256_SPKI = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d03010703420004', 'hex');
|
|
392
|
+
|
|
393
|
+
/** A phone key (SPKI DER, base64) as bytes: exactly a P-256 key, else null. */
|
|
394
|
+
export function phoneKey(b64) {
|
|
395
|
+
if (typeof b64 !== 'string' || !/^[A-Za-z0-9+/]{122}={0,2}$/.test(b64)) return null;
|
|
396
|
+
const b = Buffer.from(b64, 'base64');
|
|
397
|
+
return b.length === P256_SPKI.length + 64 && b.subarray(0, P256_SPKI.length).equals(P256_SPKI) ? b : null;
|
|
398
|
+
}
|
|
399
|
+
/** SHA-256 of the key's DER, hex: what both ends pin and show. */
|
|
400
|
+
export const phoneFingerprint = (b64) => {
|
|
401
|
+
const k = phoneKey(b64);
|
|
402
|
+
return k ? createHash('sha256').update(k).digest('hex') : null;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** A phone Glow names (GET /api/v1/agent/config's `phone`), checked: its fingerprint is its key's. Else null. */
|
|
406
|
+
export function phoneOf(p) {
|
|
407
|
+
if (!p || typeof p !== 'object') return null;
|
|
408
|
+
const fingerprint = phoneFingerprint(p.key);
|
|
409
|
+
if (!fingerprint || fingerprint !== p.fingerprint) return null;
|
|
410
|
+
return { key: p.key, fingerprint, name: printable(p.name, 60) || 'your phone' };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Glow's word on the phone (the config answer): pinned here the first time (trust on first use: say so, with its
|
|
415
|
+
* fingerprint to compare with the app); a different one is only reported, never trusted, until `glow phone trust`.
|
|
416
|
+
* Returns the config as it now is.
|
|
417
|
+
*/
|
|
418
|
+
export function pinPhone(cfg, glowPhone, say = () => undefined) {
|
|
419
|
+
const p = phoneOf(glowPhone);
|
|
420
|
+
if (!p) return cfg;
|
|
421
|
+
if (!cfg.phone?.fingerprint) {
|
|
422
|
+
const next = { ...(readConfig() ?? cfg), phone: { ...p, pinned_at: new Date().toISOString() } };
|
|
423
|
+
try {
|
|
424
|
+
writeConfig(next);
|
|
425
|
+
} catch {
|
|
426
|
+
return cfg; // a read-only home: nothing pinned, nothing runs
|
|
427
|
+
}
|
|
428
|
+
say(`Trusting your Glow phone, ${p.name} (${fingerprintText(p.fingerprint)}): the app shows the same under Settings › Glow phone.`);
|
|
429
|
+
return next;
|
|
430
|
+
}
|
|
431
|
+
if (cfg.phone.fingerprint !== p.fingerprint && cfg.phone.offered !== p.fingerprint) {
|
|
432
|
+
try {
|
|
433
|
+
writeConfig({ ...(readConfig() ?? cfg), phone: { ...cfg.phone, offered: p.fingerprint } });
|
|
434
|
+
} catch {
|
|
435
|
+
// said again next time
|
|
436
|
+
}
|
|
437
|
+
say(`Glow says your Glow phone is now ${p.name} (${fingerprintText(p.fingerprint)}). This computer still trusts ${printable(cfg.phone.name, 60)} (${fingerprintText(cfg.phone.fingerprint)}) until you run here: npx @winding-labs/glow phone trust`);
|
|
438
|
+
}
|
|
439
|
+
return cfg;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Each signature once: a file named after it in ~/.glow/seen, made only if it isn't there. Old ones are cleared. */
|
|
443
|
+
function once(sig) {
|
|
444
|
+
const seen = path.join(dir(), 'seen');
|
|
445
|
+
try {
|
|
446
|
+
fs.mkdirSync(seen, { recursive: true, mode: 0o700 });
|
|
447
|
+
fs.writeFileSync(path.join(seen, createHash('sha256').update(sig).digest('hex').slice(0, 40)), '', { flag: 'wx', mode: 0o600 });
|
|
448
|
+
} catch (e) {
|
|
449
|
+
if (e?.code === 'EEXIST') return false;
|
|
450
|
+
return true; // a read-only home: the runner's own memory of ids still holds
|
|
451
|
+
}
|
|
452
|
+
if (Math.random() < 0.05) {
|
|
453
|
+
const old = Date.now() - (SIGNED_MESSAGE_TTL_S + SIGN_SKEW_S + 3600) * 1000;
|
|
454
|
+
try {
|
|
455
|
+
for (const f of fs.readdirSync(seen)) {
|
|
456
|
+
const at = path.join(seen, f);
|
|
457
|
+
if (fs.statSync(at).mtimeMs < old) fs.rmSync(at, { force: true });
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
// next time
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Did this computer's Glow phone sign exactly this, recently, for the first time? null when it did, else why not:
|
|
468
|
+
* `untrusted_phone` (none pinned, or another phone), `not_signed` (no signature, or too old or too new), `bad_signature`
|
|
469
|
+
* (it doesn't check out, or it was used before). `payload` is built from what will actually run, never from Glow's
|
|
470
|
+
* word on what was signed.
|
|
471
|
+
*/
|
|
472
|
+
export function checkSigned(cfg, signed, payloadAt, ttlS, now = Date.now() / 1000) {
|
|
473
|
+
const pinned = cfg?.phone;
|
|
474
|
+
const key = phoneKey(pinned?.key);
|
|
475
|
+
if (!key || phoneFingerprint(pinned.key) !== pinned.fingerprint) return 'untrusted_phone';
|
|
476
|
+
if (!signed || typeof signed !== 'object' || typeof signed.sig !== 'string' || !Number.isSafeInteger(signed.at)) return 'not_signed';
|
|
477
|
+
if (signed.phone !== pinned.fingerprint) return 'untrusted_phone';
|
|
478
|
+
if (signed.at > now + SIGN_SKEW_S || signed.at < now - ttlS - SIGN_SKEW_S) return 'not_signed';
|
|
479
|
+
let ok = false;
|
|
480
|
+
try {
|
|
481
|
+
ok = signed.sig.length <= 200 && verify('sha256', Buffer.from(payloadAt(signed.at), 'utf8'), { key: createPublicKey({ key, format: 'der', type: 'spki' }), dsaEncoding: 'der' }, Buffer.from(signed.sig, 'base64'));
|
|
482
|
+
} catch {
|
|
483
|
+
ok = false;
|
|
484
|
+
}
|
|
485
|
+
if (!ok) return 'bad_signature';
|
|
486
|
+
return once(signed.sig) ? null : 'bad_signature';
|
|
487
|
+
}
|
package/lib/hook.mjs
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
// A repo ignored here (`glow ignore`, /glow:ignore) sends nothing at all: the hook stops before any request. A session
|
|
11
11
|
// Glow started here (`glow run`, lib/runner.mjs) is the runner's to report.
|
|
12
12
|
//
|
|
13
|
+
// A message reaches the agent only if this computer's Glow phone signed it (core checkSigned: after Face ID, over its id,
|
|
14
|
+
// this computer, this session and its exact text, within a day, once); any other is dropped here, whatever Glow says.
|
|
15
|
+
//
|
|
13
16
|
// Quiet by design: whatever goes wrong, it exits 0 and the agent carries on. Nothing is written to a log.
|
|
14
17
|
import fs from 'node:fs';
|
|
15
18
|
import path from 'node:path';
|
|
16
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
api, checkSigned, dir, gitContext, ignoredOf, mask, messagePayload, pinPhone, prIn, printable, promptIn, readConfig, repoOf, runMarker, SESSION_RE, sha, SIGNED_MESSAGE_TTL_S, tools,
|
|
21
|
+
writeConfig,
|
|
22
|
+
} from './core.mjs';
|
|
17
23
|
|
|
18
24
|
const LISTEN_MS = 25 * 60_000;
|
|
19
25
|
// GLOW_POLL_MS only speeds up the tests (timing, never where anything is sent).
|
|
@@ -57,11 +63,25 @@ async function main() {
|
|
|
57
63
|
if (res.json?.skipped === 'repo_not_linked' && Array.isArray(res.json.repos)) saveRepos(cfg, res.json.repos);
|
|
58
64
|
messages = inbox && Array.isArray(res.json?.messages) ? res.json.messages : [];
|
|
59
65
|
}
|
|
60
|
-
|
|
66
|
+
const signed = messages.length ? await signedOnly(cfg, session, messages) : [];
|
|
67
|
+
if (signed.length) return deliver(signed);
|
|
61
68
|
if (agent === 'claude_code' && inbox) return listen(cfg, session, repo);
|
|
62
69
|
return 0;
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
/** The messages this computer's Glow phone signed (each checked over what the agent would read); a phone not pinned yet
|
|
73
|
+
* is looked up once (core pinPhone). */
|
|
74
|
+
async function signedOnly(cfg, session, messages) {
|
|
75
|
+
let c = cfg;
|
|
76
|
+
if (!c.phone?.fingerprint) {
|
|
77
|
+
const res = await api(c, 'GET', '/api/v1/agent/config', undefined, 5_000);
|
|
78
|
+
if (res.status === 200) c = pinPhone(c, res.json?.phone);
|
|
79
|
+
}
|
|
80
|
+
const key = `${c.computer?.id ?? ''}:${agent}:${session}`;
|
|
81
|
+
return messages.filter((m) => m && typeof m.id === 'string' && typeof m.body === 'string'
|
|
82
|
+
&& !checkSigned(c, m.signed, (at) => messagePayload({ message: m.id, computer: c.computer?.id ?? '', session_key: key, body_sha256: sha(m.body), issued_at: at }), SIGNED_MESSAGE_TTL_S));
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
/** 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). */
|
|
66
86
|
async function linked(cfg, repo) {
|
|
67
87
|
const repos = Array.isArray(cfg.repos) ? cfg.repos : [];
|
|
@@ -125,7 +145,7 @@ async function listen(cfg, session, repo) {
|
|
|
125
145
|
if (!now || now.off || now.messages === false || ignoredOf(now).includes(repo)) return 0;
|
|
126
146
|
const res = await api(now, 'GET', q, undefined, 10_000);
|
|
127
147
|
if (res.status === 401 || res.status === 403 || res.status === 404) return 0;
|
|
128
|
-
const messages = Array.isArray(res.json?.messages) ? res.json.messages : [];
|
|
148
|
+
const messages = Array.isArray(res.json?.messages) ? await signedOnly(now, session, res.json.messages) : [];
|
|
129
149
|
if (messages.length) return deliver(messages);
|
|
130
150
|
}
|
|
131
151
|
return 0;
|
package/lib/runner.mjs
CHANGED
|
@@ -1,32 +1,47 @@
|
|
|
1
|
-
// The runner: `npx @winding-labs/glow run` (docs/security/review-2026-11-02-computer-sessions.md
|
|
2
|
-
// runs can the person who paired this computer start Claude
|
|
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.
|
|
3
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).
|
|
4
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
|
|
5
|
-
// linked repos it can run in (owner/name only, never a path)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// owner/name=path) and a session id it has seen
|
|
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.
|
|
9
15
|
// - 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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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).
|
|
16
26
|
// - Inside a session `glow` refuses to change anything (GLOW_RUN_SESSION in its environment, a session's Claude Code
|
|
17
27
|
// among its parents, or a session's process group: inRunnerSession).
|
|
18
28
|
// - 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
|
|
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
|
|
20
32
|
// hook sends it).
|
|
21
33
|
// - Stop in Glow, pausing the computer, `glow off`, a revoked key or Ctrl-C end its sessions: SIGTERM to every process
|
|
22
34
|
// 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';
|
|
35
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
24
36
|
import { randomUUID } from 'node:crypto';
|
|
25
37
|
import fs from 'node:fs';
|
|
26
38
|
import os from 'node:os';
|
|
27
39
|
import path from 'node:path';
|
|
28
40
|
import readline from 'node:readline';
|
|
29
|
-
import {
|
|
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';
|
|
30
45
|
|
|
31
46
|
// GLOW_RUN_POLL_MS only speeds up the tests (timing, never where anything is sent or what runs).
|
|
32
47
|
const POLL_MS = Math.max(50, Number(process.env.GLOW_RUN_POLL_MS) || 5000);
|
|
@@ -37,15 +52,17 @@ const AT_ONCE = 3;
|
|
|
37
52
|
const KILL_AFTER_MS = 5000;
|
|
38
53
|
const MESSAGE_MAX = 20_000;
|
|
39
54
|
const IDLE_AFTER_MS = 10 * 60_000;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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;
|
|
45
62
|
/** In every session's environment (and passed through ~/.glow/glow): glow changes nothing from inside one. */
|
|
46
63
|
export const SESSION_ENV = 'GLOW_RUN_SESSION';
|
|
47
64
|
/**
|
|
48
|
-
* What a session may never use,
|
|
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
|
|
49
66
|
* CLI however it's spelled (~/.glow/glow, $HOME/.glow/…, npx/bunx @winding-labs/glow, node …/glow.mjs) and its files
|
|
50
67
|
* (the computer's key). Claude Code 2.1 matches `*` anywhere in a Bash rule, and each part of `a && b`.
|
|
51
68
|
*/
|
|
@@ -228,15 +245,12 @@ export function trusted(folder, env = process.env) {
|
|
|
228
245
|
return false;
|
|
229
246
|
}
|
|
230
247
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
233
|
-
const
|
|
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');
|
|
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]) {
|
|
237
251
|
try {
|
|
238
|
-
fs.accessSync(
|
|
239
|
-
return
|
|
252
|
+
fs.accessSync(path.join(d, name), fs.constants.X_OK);
|
|
253
|
+
return path.join(d, name);
|
|
240
254
|
} catch {
|
|
241
255
|
// not here
|
|
242
256
|
}
|
|
@@ -244,31 +258,103 @@ export function findClaude(env = process.env) {
|
|
|
244
258
|
return null;
|
|
245
259
|
}
|
|
246
260
|
|
|
247
|
-
/**
|
|
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). */
|
|
248
279
|
export function claudeFlags(help) {
|
|
249
280
|
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
|
-
};
|
|
281
|
+
return { prompts: h.includes('--permission-prompts'), sessionId: h.includes('--session-id'), name: /--name\b/.test(h), append: /--append-system-prompt\b/.test(h) };
|
|
254
282
|
}
|
|
255
283
|
|
|
256
|
-
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
export
|
|
260
|
-
|
|
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) {
|
|
261
303
|
const name = `Glow: ${String(title).replace(/[^\p{L}\p{N} ’'.,:_-]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)}`;
|
|
262
304
|
return [
|
|
263
|
-
'-p', '--output-format', 'stream-json', '--verbose', ...(f.sessionId ? ['--session-id', sessionId] : []), '--
|
|
305
|
+
'-p', '--output-format', 'stream-json', '--verbose', ...(f.sessionId ? ['--session-id', sessionId] : []), '--dangerously-skip-permissions',
|
|
264
306
|
...(f.prompts ? ['--permission-prompts', 'none'] : []), '--disallowedTools', ...DENY, ...(f.name ? ['--name', name] : []),
|
|
265
|
-
...(f.
|
|
307
|
+
...(f.append ? ['--append-system-prompt', UNATTENDED] : []),
|
|
266
308
|
];
|
|
267
309
|
}
|
|
268
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
|
+
|
|
269
354
|
/** 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
|
-
|
|
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) {
|
|
272
358
|
const env = { ...process.env };
|
|
273
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];
|
|
274
360
|
env[SESSION_ENV] = '1';
|
|
@@ -282,22 +368,34 @@ function childEnv() {
|
|
|
282
368
|
env.SHELL ??= u?.shell || '/bin/sh';
|
|
283
369
|
if (u?.username) env.USER ??= u.username;
|
|
284
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);
|
|
285
373
|
return env;
|
|
286
374
|
}
|
|
287
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
|
+
|
|
288
385
|
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
386
|
|
|
291
387
|
/**
|
|
292
388
|
* Run until told to stop. `say` prints a line (the terminal, or ~/.glow/runner.log in the background). Resolves with
|
|
293
389
|
* 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
390
|
* allow repo → pinned checkout (or null): only these repos run here; empty: any linked repo found under `roots`
|
|
296
391
|
*/
|
|
297
|
-
export function startRunner({ roots, say = console.log,
|
|
392
|
+
export function startRunner({ roots, say = console.log, allow = new Map() }) {
|
|
298
393
|
const sessions = new Map();
|
|
299
394
|
/** Every session id handed over while this runner runs: one sent again never runs twice. */
|
|
300
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();
|
|
301
399
|
let cfg = readConfig();
|
|
302
400
|
let repos = new Map();
|
|
303
401
|
/** Why a repo can't run here: repo → error code (not_trusted, repo_twice, no_checkout). */
|
|
@@ -306,6 +404,8 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
306
404
|
let activeAt = Date.now();
|
|
307
405
|
let claude = null;
|
|
308
406
|
let flags = null;
|
|
407
|
+
/** corepack's pnpm and yarn for its sessions (toolchain), once looked for. */
|
|
408
|
+
let shims;
|
|
309
409
|
let ending = null;
|
|
310
410
|
let paused = false;
|
|
311
411
|
let offline = false;
|
|
@@ -318,8 +418,18 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
318
418
|
};
|
|
319
419
|
});
|
|
320
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 = {};
|
|
321
429
|
const refreshRepos = async () => {
|
|
430
|
+
readAgentVersions(agentVersions);
|
|
322
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 };
|
|
323
433
|
if (res.status === 200 && Array.isArray(res.json?.repos)) {
|
|
324
434
|
try {
|
|
325
435
|
writeConfig({ ...(readConfig() ?? cfg), repos: res.json.repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
|
|
@@ -370,7 +480,7 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
370
480
|
/** runner.json: who runs (`run stop` signals only this pid) and its sessions' process groups (whose commands may not
|
|
371
481
|
* turn Glow off or stop the runner: lib/runner inRunnerSession). */
|
|
372
482
|
const writeState = () => {
|
|
373
|
-
const mine = { pid: process.pid, started_at: startedAt, roots: roots.map(tilde), version: VERSION,
|
|
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) };
|
|
374
484
|
try {
|
|
375
485
|
writeFileAtomic(statePath(), `${JSON.stringify(mine)}\n`, 0o600);
|
|
376
486
|
} catch {
|
|
@@ -385,14 +495,40 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
385
495
|
if (res.status === 200 && res.json?.stop === true && body.status === 'running') stop(x, 'stopped');
|
|
386
496
|
return res;
|
|
387
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
|
+
};
|
|
388
512
|
const progress = (x) => {
|
|
389
513
|
if (!x.dirty || x.sending || x.done) return;
|
|
390
514
|
x.dirty = false;
|
|
391
515
|
x.sending = true;
|
|
392
|
-
|
|
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(() => {
|
|
393
521
|
x.sending = false;
|
|
394
522
|
});
|
|
395
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
|
+
};
|
|
396
532
|
|
|
397
533
|
const fail = async (s, error, why) => {
|
|
398
534
|
say(`${clock()} ✗ ${s.repo} · ${printable(s.title, 80)}: ${why}`);
|
|
@@ -404,8 +540,17 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
404
540
|
if (seen.has(s.id)) return;
|
|
405
541
|
seen.add(s.id);
|
|
406
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
|
+
}
|
|
407
553
|
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
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');
|
|
410
555
|
let cwd = repos.get(s.repo);
|
|
411
556
|
try {
|
|
@@ -424,16 +569,21 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
424
569
|
flags = claudeFlags('');
|
|
425
570
|
}
|
|
426
571
|
}
|
|
427
|
-
if (
|
|
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)}`);
|
|
428
575
|
const sessionId = randomUUID();
|
|
429
576
|
const marker = runMarker(sessionId);
|
|
430
577
|
fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
|
|
431
578
|
fs.writeFileSync(marker, s.id, { mode: 0o600 });
|
|
432
|
-
const child = spawn(claude, argsFor(
|
|
433
|
-
const x = {
|
|
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
|
+
};
|
|
434
584
|
sessions.set(s.id, x);
|
|
435
585
|
writeState();
|
|
436
|
-
say(`${clock()} ▶ ${s.repo} · ${printable(s.title, 80)}
|
|
586
|
+
say(`${clock()} ▶ ${s.repo} · ${printable(s.title, 80)}${s.auto ? ' (from your queue)' : ''}`);
|
|
437
587
|
child.on('error', () => undefined);
|
|
438
588
|
child.stdin.on('error', () => undefined);
|
|
439
589
|
child.stdin.end(s.prompt);
|
|
@@ -465,6 +615,8 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
465
615
|
x.sessionId = e.session_id;
|
|
466
616
|
fs.writeFileSync(runMarker(e.session_id), x.id, { mode: 0o600 });
|
|
467
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;
|
|
468
620
|
x.dirty = true;
|
|
469
621
|
progress(x);
|
|
470
622
|
} else if (e.type === 'assistant' && Array.isArray(e.message?.content)) {
|
|
@@ -472,21 +624,49 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
472
624
|
const mid = typeof e.message.id === 'string' ? e.message.id : null;
|
|
473
625
|
if (!mid || mid !== x.lastMessage) x.turns += 1;
|
|
474
626
|
x.lastMessage = mid;
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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);
|
|
479
637
|
say(`${clock()} ${x.activity}`);
|
|
480
638
|
}
|
|
481
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 });
|
|
482
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);
|
|
483
661
|
x.result = {
|
|
484
662
|
error: e.is_error === true || (typeof e.subtype === 'string' && e.subtype.startsWith('error')),
|
|
485
663
|
text: typeof e.result === 'string' ? e.result : '',
|
|
486
664
|
turns: Number.isInteger(e.num_turns) ? e.num_turns : x.turns,
|
|
487
665
|
cost: Number.isFinite(e.total_cost_usd) ? e.total_cost_usd : undefined,
|
|
488
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) },
|
|
489
668
|
};
|
|
669
|
+
if (x.result.text.trim()) note(x, 'result', x.result.text, x.result.error);
|
|
490
670
|
}
|
|
491
671
|
};
|
|
492
672
|
|
|
@@ -520,9 +700,16 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
520
700
|
else if (!r || r.error) [status, error] = ['failed', 'claude_failed'];
|
|
521
701
|
const quiet = x.stopWhy === 'paused' || x.stopWhy === 'revoked'; // Glow already knows (and won't take a word)
|
|
522
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);
|
|
523
711
|
await send(x, {
|
|
524
|
-
|
|
525
|
-
turns: r?.turns ?? x.turns, ...(r?.cost !== undefined ? { cost_usd: r.cost } : {}), ...(r ? { denied: r.denied } : {}),
|
|
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 } : {}),
|
|
526
713
|
});
|
|
527
714
|
}
|
|
528
715
|
const tail = status === 'failed' && !r && x.err.trim() ? ` (${printable(mask(x.err.trim().split('\n').pop()), 160)})` : '';
|
|
@@ -530,7 +717,7 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
530
717
|
// The reply becomes the session's post in Glow, masked, like a hooked session's.
|
|
531
718
|
if (!quiet && r?.text?.trim() && x.sessionId) {
|
|
532
719
|
const now = readConfig() ?? cfg;
|
|
533
|
-
const ctx = gitContext(x.
|
|
720
|
+
const ctx = gitContext(x.workdir);
|
|
534
721
|
const pr = prIn(r.text, x.repo);
|
|
535
722
|
await api(cfg, 'POST', '/api/v1/agent/reports', {
|
|
536
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,
|
|
@@ -554,10 +741,13 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
554
741
|
if (!cfg) return 1;
|
|
555
742
|
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
556
743
|
writeState();
|
|
557
|
-
say(`Glow runner ${VERSION} · ${printable(cfg.computer?.name) || 'this computer'} · ${printable(cfg.workspace?.name) || 'your workspace'}
|
|
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.');
|
|
558
746
|
say(allow.size ? `Only for ${[...allow.keys()].join(', ')}` : `Looking for linked repos in ${roots.map(tilde).join(', ')}`);
|
|
559
747
|
await refreshRepos();
|
|
560
|
-
say(
|
|
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.');
|
|
561
751
|
try {
|
|
562
752
|
while (!ending) {
|
|
563
753
|
const now = readConfig();
|
|
@@ -568,7 +758,7 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
568
758
|
}
|
|
569
759
|
cfg = { ...now, repos: cfg.repos };
|
|
570
760
|
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,
|
|
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);
|
|
572
762
|
if (res.status === 401) {
|
|
573
763
|
say('This computer’s key no longer works (removed in Glow). Pair again: npx @winding-labs/glow');
|
|
574
764
|
await shutdown('revoked');
|
|
@@ -601,6 +791,7 @@ export function startRunner({ roots, say = console.log, maxMode = 'read', allow
|
|
|
601
791
|
for (const s of Array.isArray(res.json?.sessions) ? res.json.sessions : []) {
|
|
602
792
|
if (sessions.size >= AT_ONCE) break;
|
|
603
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;
|
|
604
795
|
try {
|
|
605
796
|
await launch(s);
|
|
606
797
|
} catch (e) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "See what Glow sends from this computer, stop or start a repo sending to Glow, and let Glow start Claude Code sessions here",
|
|
5
5
|
"author": { "name": "Glow", "url": "https://meetglow.ai" },
|
|
6
6
|
"homepage": "https://meetglow.ai",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "See what Glow sends from this computer, stop or start a repo sending to Glow, and let Glow start Claude Code sessions here",
|
|
5
5
|
"author": { "name": "Glow", "email": "hi@winding.ai", "url": "https://meetglow.ai" },
|
|
6
6
|
"homepage": "https://meetglow.ai",
|
|
@@ -9,7 +9,7 @@ metadata:
|
|
|
9
9
|
|
|
10
10
|
`/glow:run` starts it, `/glow:run stop` stops it. Run exactly one of these commands, nothing else, and add no folders or options to it (for those, the user runs `npx @winding-labs/glow run` in a terminal).
|
|
11
11
|
|
|
12
|
-
To start it in the background, for the linked repos checked out in the session's working folder and the folders under it. Its sessions
|
|
12
|
+
To start it in the background, for the linked repos checked out in the session's working folder and the folders under it. Its sessions run like the user's own `claude --dangerously-skip-permissions`, with their Claude Code model and effort, each in a git worktree of its own (`--repo` and other folders are for the terminal):
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
~/.glow/glow run --background
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@winding-labs/glow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Send what your coding agents (Claude Code, Codex) do to your Glow channels, message them back, and start Claude Code sessions from Glow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "glow": "bin/glow.mjs" },
|