@winding-labs/glow 0.1.12 → 0.1.13

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/bin/glow.mjs CHANGED
@@ -156,6 +156,12 @@ async function setup(o) {
156
156
  console.log(`Found: ${agents.map((a) => AGENTS[a].label).join(', ')}`);
157
157
 
158
158
  let cfg = readConfig();
159
+ // What this computer chose itself outlives a new pairing with the same Glow: the phone it trusts (so a pairing Glow
160
+ // forces with a 401 can't swap it; only `glow phone trust` at this terminal can), the repos it ignores, and its
161
+ // message and recipe choices. --fresh starts from nothing.
162
+ const kept = cfg && !o.fresh && apiBase(cfg.api) === apiBase(o.api)
163
+ ? Object.fromEntries(['phone', 'ignored', 'messages', 'recipes'].filter((k) => cfg[k] !== undefined).map((k) => [k, cfg[k]]))
164
+ : {};
159
165
  if (cfg && (o.fresh || apiBase(cfg.api) !== apiBase(o.api))) {
160
166
  await api(cfg, 'DELETE', '/api/v1/agent/computer', undefined, 5_000);
161
167
  cfg = null;
@@ -175,7 +181,7 @@ async function setup(o) {
175
181
 
176
182
  if (!cfg) {
177
183
  const paired = await pair(o, agents);
178
- cfg = { api: apiBase(o.api), ...(o.dev ? { dev: true } : {}), key: paired.key, computer: paired.computer, workspace: paired.workspace, repos: paired.repos, repos_at: Date.now(), agents, version: VERSION };
184
+ cfg = { ...kept, api: apiBase(o.api), ...(o.dev ? { dev: true } : {}), key: paired.key, computer: paired.computer, workspace: paired.workspace, repos: paired.repos, repos_at: Date.now(), agents, version: VERSION };
179
185
  writeConfig(cfg);
180
186
  info = { computer: { ...paired.computer, paused: false }, workspace: paired.workspace, repos: paired.repos };
181
187
  console.log('');
package/lib/core.mjs CHANGED
@@ -17,7 +17,7 @@ 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.12';
20
+ export const VERSION = '0.1.13';
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');
@@ -181,15 +181,18 @@ const KEY_SHAPES = [
181
181
  /\beyJ[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}/g,
182
182
  /\bglowc?_[0-9A-Za-z_-]{40,}/g,
183
183
  ];
184
- const LABELLED = /(\b(?:api[_ -]?key|secret|token|password|passwd|pwd|access[_ -]?key|client[_ -]?secret|private[_ -]?key|auth(?:orization)?)["']?\s*[:=]\s*["']?(?:bearer\s+|basic\s+)?)([^\s"',;&#[\]<>]{8,})/gi;
184
+ /** A label may follow `_` or `-` too (refresh_token, DB_PASSWORD, x-auth-token: `\b` never matches there). */
185
+ const LABELLED = /((?<![A-Za-z0-9])(?:api[_ -]?key|secret|token|password|passwd|pwd|access[_ -]?key|client[_ -]?secret|private[_ -]?key|auth(?:orization)?)["']?\s*[:=]\s*["']?(?:bearer\s+|basic\s+)?)([^\s"',;&#[\]<>]{8,})/gi;
185
186
  const BEARER = /\b(bearer\s+)([0-9A-Za-z._~+/-]{16,}=*)/gi;
186
- const URL_CREDS = /\b(https?:\/\/)[^\s/@:]+:[^\s/@]+@/gi;
187
+ /** user:password@ in a URL of any scheme (https, postgres, redis, mongodb+srv…), and curl's -u user:password. */
188
+ const URL_CREDS = /\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s/@:]*:[^\s/@]+@/gi;
189
+ const USER_PASS = /((?:^|\s)(?:-u|--user)[=\s]+["']?[^\s"':]+:)[^\s"']+/g;
187
190
 
188
191
  /** Keys, tokens, passwords and credentials in URLs → [masked]. The server scrubs again before any model sees it. */
189
192
  export function mask(text) {
190
193
  let s = String(text);
191
194
  for (const re of KEY_SHAPES) s = s.replace(re, '[masked]');
192
- return s.replace(LABELLED, '$1[masked]').replace(BEARER, '$1[masked]').replace(URL_CREDS, '$1[masked]@');
195
+ return s.replace(LABELLED, '$1[masked]').replace(BEARER, '$1[masked]').replace(URL_CREDS, '$1[masked]@').replace(USER_PASS, '$1[masked]');
193
196
  }
194
197
 
195
198
  // ---------- the request a reply answers ----------
@@ -439,12 +442,15 @@ export function pinPhone(cfg, glowPhone, say = () => undefined) {
439
442
  return cfg;
440
443
  }
441
444
 
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) {
445
+ /** Each signed thing once: a file named after what was signed (the payload, which names the session or message) in
446
+ * ~/.glow/seen, made only if it isn't there. Not after the signature's text: base64 has many spellings of one signature
447
+ * (padding, whitespace, the URL alphabet) and ECDSA a second valid one, so that let a resent copy through. Old ones
448
+ * are cleared. */
449
+ function once(signedText) {
444
450
  const seen = path.join(dir(), 'seen');
445
451
  try {
446
452
  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 });
453
+ fs.writeFileSync(path.join(seen, createHash('sha256').update(signedText).digest('hex').slice(0, 40)), '', { flag: 'wx', mode: 0o600 });
448
454
  } catch (e) {
449
455
  if (e?.code === 'EEXIST') return false;
450
456
  return true; // a read-only home: the runner's own memory of ids still holds
@@ -463,6 +469,9 @@ function once(sig) {
463
469
  return true;
464
470
  }
465
471
 
472
+ /** A phone's signature as Glow takes it from the phone (core SIGNATURE_RE): plain base64 of a DER ECDSA signature. */
473
+ const SIGNATURE = /^[A-Za-z0-9+/]{80,100}={0,2}$/;
474
+
466
475
  /**
467
476
  * Did this computer's Glow phone sign exactly this, recently, for the first time? null when it did, else why not:
468
477
  * `untrusted_phone` (none pinned, or another phone), `not_signed` (no signature, or too old or too new), `bad_signature`
@@ -476,12 +485,13 @@ export function checkSigned(cfg, signed, payloadAt, ttlS, now = Date.now() / 100
476
485
  if (!signed || typeof signed !== 'object' || typeof signed.sig !== 'string' || !Number.isSafeInteger(signed.at)) return 'not_signed';
477
486
  if (signed.phone !== pinned.fingerprint) return 'untrusted_phone';
478
487
  if (signed.at > now + SIGN_SKEW_S || signed.at < now - ttlS - SIGN_SKEW_S) return 'not_signed';
488
+ const payload = payloadAt(signed.at);
479
489
  let ok = false;
480
490
  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'));
491
+ ok = SIGNATURE.test(signed.sig) && verify('sha256', Buffer.from(payload, 'utf8'), { key: createPublicKey({ key, format: 'der', type: 'spki' }), dsaEncoding: 'der' }, Buffer.from(signed.sig, 'base64'));
482
492
  } catch {
483
493
  ok = false;
484
494
  }
485
495
  if (!ok) return 'bad_signature';
486
- return once(signed.sig) ? null : 'bad_signature';
496
+ return once(payload) ? null : 'bad_signature';
487
497
  }
package/lib/runner.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // (--disallowedTools DENY, which holds even then: it can't turn Glow off, stop the runner or read the computer's
23
23
  // key). It's told it runs unattended, in zsh, with no Grep tool, and to finish the job live: your Run is the yes, so
24
24
  // it merges, deploys and changes campaigns itself, inside the limits it's given (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).
25
+ // and how much of your Claude plan is used go to Glow, and its turns and tool calls are counted (how many failed).
26
26
  // - Inside a session `glow` refuses to change anything (GLOW_RUN_SESSION in its environment, a session's Claude Code
27
27
  // among its parents, or a session's process group: inRunnerSession).
28
28
  // - Its stream says what it's doing: the last tool or command goes to Glow, short and masked (core activityOf), at
@@ -48,7 +48,8 @@ const POLL_MS = Math.max(50, Number(process.env.GLOW_RUN_POLL_MS) || 5000);
48
48
  const UPDATE_MS = Math.min(POLL_MS, 4000);
49
49
  const REPOS_EVERY_MS = 5 * 60_000;
50
50
  const MAX_MS = 3 * 3600_000;
51
- const AT_ONCE = 3;
51
+ /** Sessions at once (core SESSIONS_AT_ONCE): it tells Glow how many more it has room for. */
52
+ const AT_ONCE = 5;
52
53
  const KILL_AFTER_MS = 5000;
53
54
  const MESSAGE_MAX = 20_000;
54
55
  const IDLE_AFTER_MS = 10 * 60_000;
@@ -307,7 +308,7 @@ export const UNATTENDED = [
307
308
  STANDING_ORDER,
308
309
  "The Bash tool runs zsh, where an unmatched glob is an error: quote globs in arguments (grep -rn x --include='*.ts' .).",
309
310
  'There is no Grep or Glob tool here: search with rg or grep, and find files with rg --files or find, in Bash.',
310
- "Glow's own CLI and ~/.glow are off limits.",
311
+ "Glow's own CLI and ~/.glow (this computer's Glow key) are off limits. Glow reads the product's numbers itself after you finish, so check them at their source (PostHog, GA4, Stripe, the database), and don't call \"not checked in Glow\" a blocker.",
311
312
  ].join(' ');
312
313
 
313
314
  /** What a session gets on stdin: the prompt, checked against the phone's signature first, with the standing order on the
@@ -406,6 +407,9 @@ export function planOf(e) {
406
407
  return window ? { used: Math.min(1, Math.max(0, w.utilization)), window } : null;
407
408
  }
408
409
 
410
+ /** A session in runner.log, where several run at once: its repo's name and the end of its id (mechy·5x6c). */
411
+ const tagOf = (s) => `${printable(s.repo.split('/').pop(), 30)}·${String(s.id).slice(-4).toLowerCase()}`;
412
+
409
413
  const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
410
414
 
411
415
  /**
@@ -443,17 +447,18 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
443
447
  });
444
448
 
445
449
  let phoneAt = 0;
446
- /** Glow's word on the Glow phone: pinned here the first time, else only reported (core pinPhone). */
450
+ /** Glow's config for this computer, with its word on the Glow phone: pinned here the first time, else only reported
451
+ * (core pinPhone). */
447
452
  const refreshPhone = async () => {
448
453
  phoneAt = Date.now();
449
454
  const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
450
455
  if (res.status === 200) cfg = { ...pinPhone(readConfig() ?? cfg, res.json?.phone, say), repos: cfg.repos };
456
+ return res;
451
457
  };
452
458
  const agentVersions = {};
453
459
  const refreshRepos = async () => {
454
460
  readAgentVersions(agentVersions);
455
- const res = await api(cfg, 'GET', '/api/v1/agent/config', undefined, 10_000);
456
- if (res.status === 200) cfg = { ...pinPhone(readConfig() ?? cfg, res.json?.phone, say), repos: cfg.repos };
461
+ const res = await refreshPhone();
457
462
  if (res.status === 200 && Array.isArray(res.json?.repos)) {
458
463
  try {
459
464
  writeConfig({ ...(readConfig() ?? cfg), repos: res.json.repos.filter((r) => typeof r === 'string').slice(0, 200), repos_at: Date.now() });
@@ -525,7 +530,7 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
525
530
  const window = x.window ?? windows.get(x.model);
526
531
  return {
527
532
  body: {
528
- status, ...(x.sessionId ? { session_id: x.sessionId } : {}), ...(x.model ? { model: x.model } : {}), ...(x.activity ? { activity: x.activity } : {}), turns: x.result?.turns ?? x.turns,
533
+ status, ...(x.sessionId ? { session_id: x.sessionId } : {}), ...(x.model ? { model: x.model } : {}), ...(x.activity ? { activity: x.activity } : {}), turns: x.turns,
529
534
  ...(x.context ? { context_tokens: x.context } : {}), ...(window ? { context_window: window } : {}),
530
535
  ...(x.toolCalls ? { tool_calls: x.toolCalls, tool_errors: x.toolErrors } : {}),
531
536
  ...(batch.length ? { log: batch, log_from: x.logSent } : {}),
@@ -602,12 +607,12 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
602
607
  fs.writeFileSync(marker, s.id, { mode: 0o600 });
603
608
  const child = spawn(claude, argsFor(flags, sessionId, s.title), { cwd: workdir, env: childEnv(shims), stdio: ['pipe', 'pipe', 'pipe'], detached: true });
604
609
  const x = {
605
- ...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: '',
610
+ ...s, cwd, workdir, child, sessionId: flags.sessionId ? sessionId : null, marker, model: '', activity: '', turns: 0, tag: tagOf(s), dirty: false, sending: false, done: false, stopWhy: null, result: null, err: '',
606
611
  log: [], logSent: 0, context: 0, window: undefined, tools: new Map(), toolCalls: 0, toolErrors: 0,
607
612
  };
608
613
  sessions.set(s.id, x);
609
614
  writeState();
610
- say(`${clock()} ▶ ${s.repo} · ${printable(s.title, 80)}${s.auto ? ' (from your queue)' : ''}`);
615
+ say(`${clock()} ▶ ${x.tag} · ${printable(s.title, 80)}${s.auto ? ' (from your queue)' : ''}`);
611
616
  child.on('error', () => undefined);
612
617
  child.stdin.on('error', () => undefined);
613
618
  child.stdin.end(promptFor(s.prompt));
@@ -644,10 +649,14 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
644
649
  x.dirty = true;
645
650
  progress(x);
646
651
  } else if (e.type === 'assistant' && Array.isArray(e.message?.content)) {
647
- // One event per content block; a turn is one message (its id), or each event when there's no id.
652
+ // One event per content block; a turn is one of its own messages (its id), or each event when there's no id. Counted
653
+ // here, not taken from a result's num_turns: a session that waits on a monitor or a wakeup ends several queries,
654
+ // each with its own result and count (2026-11-05: "11 turns" for 224 tool calls).
648
655
  const mid = typeof e.message.id === 'string' ? e.message.id : null;
649
- if (!mid || mid !== x.lastMessage) x.turns += 1;
650
- x.lastMessage = mid;
656
+ if (!e.parent_tool_use_id && (!mid || mid !== x.lastMessage)) {
657
+ x.turns += 1;
658
+ x.lastMessage = mid;
659
+ }
651
660
  const u = e.message.usage;
652
661
  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);
653
662
  for (const c of e.message.content) {
@@ -658,7 +667,7 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
658
667
  // The transcript shows a command whole (masked); the status line only its program.
659
668
  note(x, 'tool', c.name === 'Bash' && typeof c.input?.command === 'string' ? `$ ${c.input.command}` : x.activity);
660
669
  if (typeof c.id === 'string') x.tools.set(c.id, c.name);
661
- say(`${clock()} ${x.activity}`);
670
+ say(`${clock()} ${x.tag} ${x.activity}`);
662
671
  }
663
672
  x.dirty = true;
664
673
  } else if (e.type === 'user' && Array.isArray(e.message?.content)) {
@@ -685,7 +694,6 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
685
694
  x.result = {
686
695
  error: e.is_error === true || (typeof e.subtype === 'string' && e.subtype.startsWith('error')),
687
696
  text: typeof e.result === 'string' ? e.result : '',
688
- turns: Number.isInteger(e.num_turns) ? e.num_turns : x.turns,
689
697
  cost: Number.isFinite(e.total_cost_usd) ? e.total_cost_usd : undefined,
690
698
  denied: Array.isArray(e.permission_denials) ? e.permission_denials.length : 0,
691
699
  tokens: { in: n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens), out: n(u.output_tokens) },
@@ -737,7 +745,7 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
737
745
  });
738
746
  }
739
747
  const tail = status === 'failed' && !r && x.err.trim() ? ` (${printable(mask(x.err.trim().split('\n').pop()), 160)})` : '';
740
- say(`${clock()} ${status === 'done' ? '✓' : status === 'stopped' ? '■' : '✗'} ${x.repo} · ${printable(x.title, 80)}: ${status}${error && error !== status ? ` (${error})` : ''}${r ? ` · ${r.turns} turns` : ''}${r?.cost !== undefined ? ` · $${r.cost.toFixed(2)}` : ''}${r?.denied ? ` · ${r.denied} turned down` : ''}${tail}${code !== null && code !== 0 && !x.stopWhy ? ` · exit ${code}` : ''}`);
748
+ say(`${clock()} ${status === 'done' ? '✓' : status === 'stopped' ? '■' : '✗'} ${x.tag} · ${printable(x.title, 80)}: ${status}${error && error !== status ? ` (${error})` : ''}${x.turns ? ` · ${x.turns} turns` : ''}${r?.cost !== undefined ? ` · $${r.cost.toFixed(2)}` : ''}${r?.denied ? ` · ${r.denied} turned down` : ''}${tail}${code !== null && code !== 0 && !x.stopWhy ? ` · exit ${code}` : ''}`);
741
749
  // The reply becomes the session's post in Glow, masked, like a hooked session's.
742
750
  if (!quiet && r?.text?.trim() && x.sessionId) {
743
751
  const now = readConfig() ?? cfg;
@@ -782,7 +790,8 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
782
790
  }
783
791
  cfg = { ...now, repos: cfg.repos };
784
792
  if (Date.now() - reposAt > REPOS_EVERY_MS) await refreshRepos();
785
- 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);
793
+ // room: how many more it takes now (Glow hands over at most that many; one before 0.1.13 got 3 minus what ran).
794
+ const res = await api(cfg, 'POST', '/api/v1/agent/runner', { repos: [...repos.keys()], running: [...sessions.keys()], room: Math.max(0, AT_ONCE - sessions.size), version: VERSION, ...(Object.keys(agentVersions).length ? { agent_versions: agentVersions } : {}) }, 15_000);
786
795
  if (res.status === 401) {
787
796
  say('This computer’s key no longer works (removed in Glow). Pair again: npx @winding-labs/glow');
788
797
  await shutdown('revoked');
@@ -808,7 +817,7 @@ export function startRunner({ roots, say = console.log, allow = new Map() }) {
808
817
  for (const id of Array.isArray(res.json?.stop) ? res.json.stop : []) {
809
818
  const x = sessions.get(id);
810
819
  if (x) {
811
- say(`${clock()} ■ Stop from Glow: ${x.repo} · ${printable(x.title, 80)}`);
820
+ say(`${clock()} ■ Stop from Glow: ${x.tag} · ${printable(x.title, 80)}`);
812
821
  stop(x, 'stopped');
813
822
  }
814
823
  }
@@ -7,7 +7,7 @@
7
7
  "name": "glow",
8
8
  "source": "./glow",
9
9
  "description": "See what Glow sends from this computer, stop or start a repo sending to Glow, and let Glow start Claude Code sessions here",
10
- "version": "0.1.12"
10
+ "version": "0.1.13"
11
11
  }
12
12
  ]
13
13
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glow",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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.12",
3
+ "version": "0.1.13",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@winding-labs/glow",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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" },