@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/core.mjs CHANGED
@@ -1,4 +1,5 @@
1
- // Shared by the installer (bin/glow.mjs) and the hook (~/.glow/hook.mjs). Plain Node 18+, no dependencies.
1
+ // Shared by the CLI (bin/glow.mjs), the hook (lib/hook.mjs) and the runner (lib/runner.mjs), all copied to ~/.glow.
2
+ // Plain Node 18+, no dependencies.
2
3
  //
3
4
  // Safety (docs/security/review-2026-11-02-agent-reports.md):
4
5
  // - The only host this ever talks to is Glow's API: https://app.meetglow.ai (or staging.meetglow.ai), read from
@@ -6,16 +7,17 @@
6
7
  // variable (the hook runs with an empty environment but HOME and PATH). A local API (http://localhost) only when
7
8
  // installed with --dev. Redirects are refused, so the key never follows one.
8
9
  // - The key (glowc_…) can only report sessions, read the linked repo names, and fetch messages for this computer.
9
- // - Nothing is sent from a repo that isn't linked to a product in Glow; the reply is masked for keys before it leaves.
10
+ // - Nothing is sent from a repo that isn't linked to a product in Glow, or that's ignored on this computer (`glow
11
+ // ignore`: checked before any request); the reply is masked for keys before it leaves.
10
12
  // - git runs without a shell, with hooks, fsmonitor and signature checks off and none of the caller's GIT_* variables,
11
13
  // so a repo (or its .git/config) can't make it run anything.
12
14
  import { execFileSync } from 'node:child_process';
13
- import { createHash, randomBytes } from 'node:crypto';
15
+ import { createHash, createPublicKey, randomBytes, verify } from 'node:crypto';
14
16
  import fs from 'node:fs';
15
17
  import os from 'node:os';
16
18
  import path from 'node:path';
17
19
 
18
- export const VERSION = '0.1.1';
20
+ export const VERSION = '0.1.11';
19
21
  export const DEFAULT_API = 'https://app.meetglow.ai';
20
22
  export const dir = () => path.join(os.homedir(), '.glow');
21
23
  export const configPath = () => path.join(dir(), 'config.json');
@@ -54,6 +56,9 @@ export function readConfig() {
54
56
  }
55
57
 
56
58
  /** Atomic, owner-only. */
59
+ /** The repos ignored on this computer (`glow ignore`): nothing is sent from them, linked or not. */
60
+ export const ignoredOf = (c) => (Array.isArray(c?.ignored) ? c.ignored.filter((r) => typeof r === 'string' && REPO_RE.test(r)) : []);
61
+
57
62
  export function writeConfig(c) {
58
63
  fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
59
64
  fs.chmodSync(dir(), 0o700);
@@ -99,10 +104,12 @@ export async function api(c, method, p, body, timeoutMs = 10_000) {
99
104
 
100
105
  // ---------- git ----------
101
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
+
102
110
  function git(cwd, args) {
103
111
  try {
104
- const opts = ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'log.showSignature=false', '-c', 'core.pager=cat'];
105
- return execFileSync('git', ['-C', cwd, ...opts, ...args], {
112
+ return execFileSync('git', ['-C', cwd, ...GIT_SAFE, ...args], {
106
113
  encoding: 'utf8',
107
114
  timeout: 3000,
108
115
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -185,6 +192,67 @@ export function mask(text) {
185
192
  return s.replace(LABELLED, '$1[masked]').replace(BEARER, '$1[masked]').replace(URL_CREDS, '$1[masked]@');
186
193
  }
187
194
 
195
+ // ---------- the request a reply answers ----------
196
+
197
+ const PROMPT_MAX = 4_000;
198
+ const TAIL_BYTES = 2 * 1024 * 1024;
199
+
200
+ /**
201
+ * The person's latest request in the agent's own transcript, masked, ≤ 4,000 characters; '' when there is none. Only a
202
+ * .jsonl under ~/.claude/projects (Claude Code) or ~/.codex/sessions (Codex) is read, and only its last 2 MB.
203
+ */
204
+ export function promptIn(file, home = os.homedir()) {
205
+ if (typeof file !== 'string' || !path.isAbsolute(file) || !file.endsWith('.jsonl')) return '';
206
+ const real = (p) => {
207
+ try {
208
+ return fs.realpathSync(p);
209
+ } catch {
210
+ return '';
211
+ }
212
+ };
213
+ const at = real(file);
214
+ const roots = [path.join(home, '.claude', 'projects'), path.join(home, '.codex', 'sessions')].map(real).filter(Boolean);
215
+ if (!at || !roots.some((r) => at.startsWith(r + path.sep))) return '';
216
+ let text = '';
217
+ try {
218
+ const fd = fs.openSync(at, 'r');
219
+ try {
220
+ const size = fs.fstatSync(fd).size;
221
+ const n = Math.min(size, TAIL_BYTES);
222
+ const buf = Buffer.alloc(n);
223
+ fs.readSync(fd, buf, 0, n, size - n);
224
+ text = buf.toString('utf8');
225
+ } finally {
226
+ fs.closeSync(fd);
227
+ }
228
+ } catch {
229
+ return '';
230
+ }
231
+ const lines = text.split('\n');
232
+ for (let k = lines.length - 1; k >= 0; k--) {
233
+ if (!lines[k].includes('"user')) continue;
234
+ let e;
235
+ try {
236
+ e = JSON.parse(lines[k]);
237
+ } catch {
238
+ continue;
239
+ }
240
+ const p = userText(e);
241
+ if (p) return mask(p).slice(0, PROMPT_MAX);
242
+ }
243
+ return '';
244
+ }
245
+
246
+ /** A typed request: Codex's user_message event, or a Claude Code user turn that isn't a tool result, meta or a command. */
247
+ function userText(e) {
248
+ if (e?.type === 'event_msg' && e.payload?.type === 'user_message') return typeof e.payload.message === 'string' ? e.payload.message.trim() : '';
249
+ if (e?.type !== 'user' || e.isMeta || e.isSidechain || e.message?.role !== 'user') return '';
250
+ const c = e.message.content;
251
+ const t = typeof c === 'string' ? c : Array.isArray(c) && !c.some((x) => x?.type === 'tool_result') ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('\n') : '';
252
+ const s = String(t ?? '').trim();
253
+ return /^<(command-|local-command|system-reminder|task-notification|bash-)/.test(s) ? '' : s;
254
+ }
255
+
188
256
  // ---------- this computer ----------
189
257
 
190
258
  /** "Ilya’s MacBook Pro": the computer's own name (macOS), else the host name. Letters, digits, spaces, ’'._- only. */
@@ -202,3 +270,218 @@ export function computerName() {
202
270
  }
203
271
 
204
272
  export const sha = (s) => createHash('sha256').update(s).digest('hex');
273
+
274
+ /**
275
+ * What else this computer can reach, for the green tags beside it in Glow: GitHub (gh signed in), Chrome, Docker, Xcode.
276
+ * File checks only (no process is started), so the hook can run it on every report. Never a token, an account or a path:
277
+ * only which of these four are there.
278
+ */
279
+ export function tools(home = os.homedir(), env = process.env, platform = process.platform) {
280
+ const dirs = String(env.PATH ?? '').split(path.delimiter).filter(Boolean);
281
+ const has = (p) => {
282
+ try {
283
+ return fs.existsSync(p);
284
+ } catch {
285
+ return false;
286
+ }
287
+ };
288
+ const onPath = (bin) => dirs.some((d) => has(path.join(d, bin)));
289
+ const ghConfig = env.GH_CONFIG_DIR || path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'gh');
290
+ const out = [];
291
+ if (onPath('gh') && has(path.join(ghConfig, 'hosts.yml'))) out.push('github');
292
+ if (platform === 'darwin' ? has('/Applications/Google Chrome.app') || has(path.join(home, 'Applications', 'Google Chrome.app')) : onPath('google-chrome') || onPath('chrome') || onPath('chromium')) out.push('chrome');
293
+ if (onPath('docker')) out.push('docker');
294
+ if (platform === 'darwin' && has('/Applications/Xcode.app')) out.push('xcode');
295
+ return out;
296
+ }
297
+
298
+ // ---------- sessions Glow starts here (`glow run`) ----------
299
+
300
+ /** The file that marks a session the runner started (by its Claude Code session id): the runner sends its reply to
301
+ * Glow itself, so the Stop hook leaves it alone. In ~/.glow/sessions, tidied after a day like the hook's locks. */
302
+ export const runMarker = (session) => path.join(dir(), 'sessions', `${sha(`run:${session}`).slice(0, 24)}.run`);
303
+
304
+ const clip = (s, n) => {
305
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
306
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
307
+ };
308
+
309
+ /**
310
+ * A command as a session's status shows it: the program and its subcommand only ("$ git status", "$ psql …"), never its
311
+ * arguments, options or environment, which is where passwords go. Leading `VAR=…`, `export`, `cd` and wrappers like
312
+ * `sudo` are skipped. (The same as @glow/core commandHead, which Glow applies again.)
313
+ */
314
+ export function commandHead(command) {
315
+ const parts = String(command ?? '').split(/&&|\|\||;|\||\n/).map((x) => x.trim().split(/\s+/).filter(Boolean));
316
+ const SKIP = new Set(['sudo', 'env', 'time', 'nohup', 'command', 'exec', 'nice']);
317
+ const SETUP = new Set(['export', 'cd', 'set', 'unset', 'source', '.', 'pushd', 'popd']);
318
+ let fallback = '';
319
+ for (const words of parts) {
320
+ let k = 0;
321
+ while (k < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k]) || SKIP.has(words[k]) || words[k].startsWith('-'))) k++; // VAR=x, sudo -E, env, …
322
+ const prog = (words[k] ?? '').split('/').pop().replace(/[^\w.+-]/g, '').slice(0, 40);
323
+ if (!prog) continue;
324
+ // A subcommand is a plain lower-case word (`git status`, `npm run`): never what echo or printf print, nor a word
325
+ // with digits or capitals (a password piped to `sudo -S` looks like one).
326
+ const sub = words[k + 1] && !['echo', 'printf'].includes(prog) && /^[a-z][a-z_-]{0,30}$/.test(words[k + 1]) ? words[k + 1] : '';
327
+ const more = words.length > k + (sub ? 2 : 1) || parts.length > 1;
328
+ const head = `$ ${prog}${sub ? ` ${sub}` : ''}${more ? ' …' : ''}`;
329
+ if (!SETUP.has(prog)) return head;
330
+ fallback ||= head;
331
+ }
332
+ return fallback || '$ (a command)';
333
+ }
334
+
335
+ /**
336
+ * What a session is doing, from the tool it just called, for its row in Glow: "$ bun test …", "Edit src/app.ts",
337
+ * "Grep useSession". Short, one line, paths inside the repo relative and the home folder as ~, keys masked before
338
+ * anything is cut. A command by its program only; never what's written or fetched; an MCP tool by its name only.
339
+ */
340
+ export function activityOf(name, input, cwd = '') {
341
+ const i = input && typeof input === 'object' ? input : {};
342
+ const home = os.homedir();
343
+ const where = (p) => {
344
+ const s = String(p ?? '');
345
+ if (cwd && s.startsWith(`${cwd}/`)) return s.slice(cwd.length + 1);
346
+ return home && s.startsWith(`${home}/`) ? `~/${s.slice(home.length + 1)}` : s;
347
+ };
348
+ // Masked whole, then cut: a key cut in half can't be told from text any more.
349
+ const safe = (v, n) => clip(mask(String(v ?? '').replace(/\s+/g, ' ')), n);
350
+ const tool = String(name ?? '').replace(/[^\w.:-]/g, '').slice(0, 60) || 'A tool';
351
+ let line;
352
+ if (tool === 'Bash') line = commandHead(i.command);
353
+ else if (['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) line = `${tool} ${safe(where(i.file_path ?? i.notebook_path), 140)}`;
354
+ else if (tool === 'Glob' || tool === 'Grep') line = `${tool} ${safe(i.pattern, 100)}`;
355
+ else if (tool === 'WebFetch') {
356
+ let host = '';
357
+ try {
358
+ host = new URL(String(i.url)).host;
359
+ } catch {
360
+ host = '';
361
+ }
362
+ line = `WebFetch ${host}`;
363
+ } else if (tool === 'WebSearch') line = `WebSearch ${safe(i.query, 100)}`;
364
+ else if (tool === 'Task' || tool === 'Agent') line = `Agent: ${safe(i.description, 100)}`;
365
+ else if (tool === 'TodoWrite') line = 'Updating its to-do list';
366
+ else line = tool;
367
+ return printable(line, 160).trim();
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
@@ -7,11 +7,19 @@
7
7
  // {"decision":"block","reason":…}, which Codex takes as the next prompt.
8
8
  //
9
9
  // `npx @winding-labs/glow messages off` stops both: Glow then holds no message for this computer's sessions.
10
+ // A repo ignored here (`glow ignore`, /glow:ignore) sends nothing at all: the hook stops before any request. A session
11
+ // Glow started here (`glow run`, lib/runner.mjs) is the runner's to report.
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.
10
15
  //
11
16
  // Quiet by design: whatever goes wrong, it exits 0 and the agent carries on. Nothing is written to a log.
12
17
  import fs from 'node:fs';
13
18
  import path from 'node:path';
14
- import { api, dir, gitContext, mask, prIn, printable, readConfig, repoOf, SESSION_RE, sha, writeConfig } from './core.mjs';
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';
15
23
 
16
24
  const LISTEN_MS = 25 * 60_000;
17
25
  // GLOW_POLL_MS only speeds up the tests (timing, never where anything is sent).
@@ -34,9 +42,11 @@ async function main() {
34
42
  if (!cfg || cfg.off) return 0;
35
43
  const session = typeof input.session_id === 'string' ? input.session_id : '';
36
44
  if (!SESSION_RE.test(session)) return 0;
45
+ // A session Glow started here (`glow run`): the runner sends its reply itself.
46
+ if (fs.existsSync(runMarker(session))) return 0;
37
47
  const cwd = typeof input.cwd === 'string' && path.isAbsolute(input.cwd) ? input.cwd : process.cwd();
38
48
  const repo = repoOf(cwd);
39
- if (!repo || !(await linked(cfg, repo))) return 0;
49
+ if (!repo || ignoredOf(cfg).includes(repo) || !(await linked(cfg, repo))) return 0;
40
50
 
41
51
  const reply = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : '';
42
52
  const inbox = cfg.messages !== false;
@@ -44,18 +54,34 @@ async function main() {
44
54
  if (reply.trim()) {
45
55
  const ctx = gitContext(cwd);
46
56
  const pr = prIn(reply, repo);
57
+ // The request this reply answers (masked), so Glow can name the recipe you ran; `recipes off` leaves it out.
58
+ const prompt = cfg.recipes === false ? '' : promptIn(input.transcript_path);
47
59
  const res = await api(cfg, 'POST', '/api/v1/agent/reports', {
48
- agent, session_id: session, repo, ...(ctx.branch ? { branch: ctx.branch } : {}), message: mask(reply).slice(0, MESSAGE_MAX), commits: ctx.commits, ...(pr ? { pr_url: pr } : {}), inbox,
60
+ agent, session_id: session, repo, ...(ctx.branch ? { branch: ctx.branch } : {}), message: mask(reply).slice(0, MESSAGE_MAX), ...(prompt ? { prompt } : {}), commits: ctx.commits, ...(pr ? { pr_url: pr } : {}), inbox, tools: tools(),
49
61
  }, agent === 'codex' ? 6_000 : 10_000);
50
62
  if (res.status === 401 || res.status === 403) return 0;
51
63
  if (res.json?.skipped === 'repo_not_linked' && Array.isArray(res.json.repos)) saveRepos(cfg, res.json.repos);
52
64
  messages = inbox && Array.isArray(res.json?.messages) ? res.json.messages : [];
53
65
  }
54
- if (messages.length) return deliver(messages);
55
- if (agent === 'claude_code' && inbox) return listen(cfg, session);
66
+ const signed = messages.length ? await signedOnly(cfg, session, messages) : [];
67
+ if (signed.length) return deliver(signed);
68
+ if (agent === 'claude_code' && inbox) return listen(cfg, session, repo);
56
69
  return 0;
57
70
  }
58
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
+
59
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). */
60
86
  async function linked(cfg, repo) {
61
87
  const repos = Array.isArray(cfg.repos) ? cfg.repos : [];
@@ -98,9 +124,10 @@ const timeOf = (iso) => {
98
124
 
99
125
  /**
100
126
  * Claude Code, idle: ask Glow for a message every 15 s for up to 25 minutes. One listener per session: a newer reply
101
- * writes its own token to the lock and this one stops. Stops too when Claude Code itself has gone.
127
+ * writes its own token to the lock and this one stops. Stops too when Claude Code itself has gone, or when sending,
128
+ * messages or this repo are turned off here.
102
129
  */
103
- async function listen(cfg, session) {
130
+ async function listen(cfg, session, repo) {
104
131
  const locks = path.join(dir(), 'sessions');
105
132
  fs.mkdirSync(locks, { recursive: true, mode: 0o700 });
106
133
  const lock = path.join(locks, `${sha(`${agent}:${session}`).slice(0, 24)}.lock`);
@@ -115,10 +142,10 @@ async function listen(cfg, session) {
115
142
  await new Promise((r) => setTimeout(r, POLL_MS));
116
143
  if (!mine(lock, token) || !alive(parent)) return 0;
117
144
  const now = readConfig();
118
- if (!now || now.off || now.messages === false) return 0;
145
+ if (!now || now.off || now.messages === false || ignoredOf(now).includes(repo)) return 0;
119
146
  const res = await api(now, 'GET', q, undefined, 10_000);
120
147
  if (res.status === 401 || res.status === 403 || res.status === 404) return 0;
121
- 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) : [];
122
149
  if (messages.length) return deliver(messages);
123
150
  }
124
151
  return 0;