@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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
443
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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()} ▶ ${
|
|
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
|
|
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)
|
|
650
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
820
|
+
say(`${clock()} ■ Stop from Glow: ${x.tag} · ${printable(x.title, 80)}`);
|
|
812
821
|
stop(x, 'stopped');
|
|
813
822
|
}
|
|
814
823
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glow",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
|
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" },
|