glassbox-trace 0.4.1 → 0.5.0

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/package.json CHANGED
@@ -1,57 +1,57 @@
1
- {
2
- "name": "glassbox-trace",
3
- "version": "0.4.1",
4
- "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
- "main": "src/trace-core.js",
6
- "scripts": {
7
- "test": "node --test test/*.test.mjs",
8
- "build": "node scripts/build.mjs",
9
- "sanitize": "node scripts/sanitize.mjs",
10
- "e2e": "node test/browser.e2e.mjs",
11
- "check": "npm test && npm run build && npm run e2e",
12
- "audit": "node test/audit.mjs",
13
- "prepublishOnly": "npm test && npm run build"
14
- },
15
- "license": "MIT",
16
- "author": "Aldo Hushi",
17
- "bin": {
18
- "glassbox": "bin/glassbox.mjs"
19
- },
20
- "files": [
21
- "bin",
22
- "src",
23
- "dist/glassbox.html",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "engines": {
28
- "node": ">=18"
29
- },
30
- "keywords": [
31
- "claude-code",
32
- "claude-code-log",
33
- "compare",
34
- "live-tail",
35
- "claude",
36
- "agent",
37
- "agent-sdk",
38
- "trace",
39
- "jsonl",
40
- "viewer",
41
- "observability",
42
- "tokens",
43
- "cost",
44
- "accessibility"
45
- ],
46
- "devDependencies": {
47
- "playwright": "^1.49.0"
48
- },
49
- "homepage": "https://github.com/aldohushi1-stack/glassbox#readme",
50
- "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/aldohushi1-stack/glassbox.git"
53
- },
54
- "bugs": {
55
- "url": "https://github.com/aldohushi1-stack/glassbox/issues"
56
- }
57
- }
1
+ {
2
+ "name": "glassbox-trace",
3
+ "version": "0.5.0",
4
+ "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
+ "main": "src/trace-core.js",
6
+ "scripts": {
7
+ "test": "node --test test/*.test.mjs",
8
+ "build": "node scripts/build.mjs",
9
+ "sanitize": "node scripts/sanitize.mjs",
10
+ "e2e": "node test/browser.e2e.mjs",
11
+ "check": "npm test && npm run build && npm run e2e",
12
+ "audit": "node test/audit.mjs",
13
+ "prepublishOnly": "npm test && npm run build"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Aldo Hushi",
17
+ "bin": {
18
+ "glassbox": "bin/glassbox.mjs"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "dist/glassbox.html",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "claude-code",
32
+ "claude-code-log",
33
+ "compare",
34
+ "live-tail",
35
+ "claude",
36
+ "agent",
37
+ "agent-sdk",
38
+ "trace",
39
+ "jsonl",
40
+ "viewer",
41
+ "observability",
42
+ "tokens",
43
+ "cost",
44
+ "accessibility"
45
+ ],
46
+ "devDependencies": {
47
+ "playwright": "^1.49.0"
48
+ },
49
+ "homepage": "https://github.com/aldohushi1-stack/glassbox#readme",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/aldohushi1-stack/glassbox.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/aldohushi1-stack/glassbox/issues"
56
+ }
57
+ }
package/src/cli.mjs CHANGED
@@ -60,14 +60,50 @@ export function noSessionsMessage(home) {
60
60
  }
61
61
 
62
62
  // Session file + its subagent transcripts, as the {name, text} list the core expects.
63
+ // Subagent transcripts: <id>/subagents/*.jsonl, and Workflow agents one level deeper in
64
+ // <id>/subagents/workflows/<runId>/. journal.jsonl there is the workflow's own log, not a transcript.
65
+ export function subagentFiles(s) {
66
+ const out = [];
67
+ const walk = (dir, rel) => {
68
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
69
+ if (e.isDirectory()) walk(path.join(dir, e.name), `${rel}/${e.name}`);
70
+ else if (/\.(jsonl|json)$/.test(e.name) && e.name !== 'journal.jsonl') out.push({ name: `${rel}/${e.name}`, path: path.join(dir, e.name) });
71
+ }
72
+ };
73
+ const sub = path.join(s.projectDir, s.id, 'subagents');
74
+ if (fs.existsSync(sub)) walk(sub, `${s.id}/subagents`);
75
+ return out;
76
+ }
77
+
63
78
  export function loadSessionFiles(s) {
64
79
  const files = [{ name: s.id + '.jsonl', text: fs.readFileSync(s.file, 'utf8') }];
65
- const sub = path.join(s.projectDir, s.id, 'subagents');
66
- if (fs.existsSync(sub)) for (const f of fs.readdirSync(sub)) if (/\.(jsonl|json)$/.test(f)) files.push({ name: `${s.id}/subagents/${f}`, text: fs.readFileSync(path.join(sub, f), 'utf8') });
80
+ for (const f of subagentFiles(s)) files.push({ name: f.name, text: fs.readFileSync(f.path, 'utf8') });
67
81
  return files;
68
82
  }
69
83
 
70
- export function analyse(files, rates) { const trace = core.parseTrace(files); const findings = core.diagnose(trace); const cost = core.estimateCost(trace, rates); return { trace, findings, cost }; }
84
+ export function analyse(files, rates) { const trace = core.parseTrace(files); const findings = core.diagnose(trace, rates ? { rates } : undefined); const cost = core.estimateCost(trace, rates); return { trace, findings, cost }; }
85
+
86
+ // A team rate card: --rates FILE or GLASSBOX_RATES. JSON keyed by model-id prefix, USD per million tokens:
87
+ // { "claude-opus-5": { "in": 4, "out": 20, "read": 0.4, "w5m": 5, "w1h": 8 } }
88
+ // Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input.
89
+ export function loadRates(file) {
90
+ if (!file) return null;
91
+ let json; try { json = JSON.parse(fs.readFileSync(String(file), 'utf8')); } catch (e) { throw new Error(`--rates ${file}: ${e.code === 'ENOENT' ? 'no such file' : 'not valid JSON'}`); }
92
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`--rates ${file}: expected an object keyed by model id`);
93
+ const out = Object.assign({}, core.RATES);
94
+ for (const [model, r] of Object.entries(json)) {
95
+ if (!r || !['in', 'out', 'read'].every((k) => typeof r[k] === 'number' && r[k] >= 0)) throw new Error(`--rates ${file}: "${model}" needs numeric in, out and read (USD per million tokens)`);
96
+ out[model] = { in: r.in, out: r.out, read: r.read, w5m: typeof r.w5m === 'number' ? r.w5m : r.in * 1.25, w1h: typeof r.w1h === 'number' ? r.w1h : r.in * 2 };
97
+ }
98
+ return out;
99
+ }
100
+
101
+ // "90m", "1h", "2d" → milliseconds.
102
+ export function parseSince(s) {
103
+ const m = String(s).trim().match(/^(\d+(?:\.\d+)?)\s*(m|min|h|d)$/i);
104
+ if (!m) throw new Error(`--since must look like 30m, 1h or 2d (got "${s}")`);
105
+ return +m[1] * ({ m: 60e3, min: 60e3, h: 3600e3, d: 86400e3 })[m[2].toLowerCase()];
106
+ }
71
107
 
72
108
  const safe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');
73
109
  export function embed(files, opts = {}) {
@@ -90,6 +126,14 @@ export function openInBrowser(file, env = process.env) {
90
126
  }
91
127
 
92
128
  const SEV = { error: 0, warn: 1, info: 2 };
129
+ // Text output only: identical findings (same severity, rule and title) print once with a ×N count.
130
+ export function collapseFindings(findings) {
131
+ const groups = new Map();
132
+ for (const f of findings) { const k = f.severity + '|' + f.id + '|' + f.title; if (groups.has(k)) groups.get(k).n++; else groups.set(k, { f, n: 1 }); }
133
+ return Array.from(groups.values());
134
+ }
135
+ const findingLine = ({ f, n }) => `${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(16)} ${f.title}${n > 1 ? ` ×${n}` : ''}`;
136
+
93
137
  export function checkReport({ trace, findings, cost }, opts = {}) {
94
138
  const failOn = opts.failOn || 'error';
95
139
  const failing = findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
@@ -105,7 +149,7 @@ export function checkReport({ trace, findings, cost }, opts = {}) {
105
149
  lines.push(` context served ${fi(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} tokens (${T.cacheHitRatio != null ? Math.round(T.cacheHitRatio * 100) + '% cached' : 'no usage'}) · output ${fi(T.usage.output)} (${fi(T.usage.thinking)} thinking) · est. cost ${usd(summary.cost)}`);
106
150
  lines.push('');
107
151
  if (!findings.length) lines.push(' no findings');
108
- for (const f of findings) lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(16)} ${f.title}`);
152
+ for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
109
153
  if (redact) lines.push('', ' (redacted: prompt text, tool inputs and result text blanked)');
110
154
  lines.push('');
111
155
  lines.push(failing.length ? ` FAIL: ${failing.length} finding${failing.length > 1 ? 's' : ''} at or above "${failOn}"` : ` OK: nothing at or above "${failOn}"`);
@@ -126,9 +170,46 @@ export function sessionFromTranscriptPath(p) {
126
170
  return { id: path.basename(file).replace(/\.jsonl$/, ''), file, project: null, projectDir: path.dirname(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, mtime: fs.existsSync(file) ? fs.statSync(file).mtimeMs : 0 };
127
171
  }
128
172
 
173
+ // Persisted feedback (--context): the Stop hook leaves <project>/.glassbox/last-session.md behind and
174
+ // the SessionStart hook hands it to the next session, so feedback reaches the session that can use it.
175
+ export const NOTES_FILE = path.join('.glassbox', 'last-session.md');
176
+ const NOTES_MAX = 6000;
177
+ function notesPath(cwd) { return cwd && fs.existsSync(cwd) && fs.statSync(cwd).isDirectory() ? path.join(cwd, NOTES_FILE) : null; }
178
+ function writeNotes(cwd, text) {
179
+ const file = notesPath(cwd); if (!file) return null;
180
+ fs.mkdirSync(path.dirname(file), { recursive: true });
181
+ const ignore = path.join(path.dirname(file), '.gitignore'); if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '# written by glassbox hook --context; local notes, never committed\n*\n');
182
+ fs.writeFileSync(file, text.length > NOTES_MAX ? text.slice(0, NOTES_MAX) + '\n\n… (truncated; run `glassbox check --format md` for the full report)\n' : text);
183
+ return file;
184
+ }
185
+ export function sessionStartResponse(input) {
186
+ const file = notesPath(input && input.cwd);
187
+ if (!file || !fs.existsSync(file) || (input.source && !['startup', 'clear', 'compact'].includes(input.source))) return { suppressOutput: true };
188
+ const text = fs.readFileSync(file, 'utf8');
189
+ return { suppressOutput: true, hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: `Notes from Glassbox on the previous session in this project (.glassbox/last-session.md). Keep them in mind; don't redo that work.\n\n${text}` } };
190
+ }
191
+
192
+ // Findings already handed back in this session, keyed by rule + title, kept in the temp dir.
193
+ function undeliveredFindings(sessionId, top, stateDir) {
194
+ const dir = stateDir || process.env.GLASSBOX_STATE_DIR || path.join(os.tmpdir(), 'glassbox-hook');
195
+ const file = path.join(dir, String(sessionId).replace(/[^\w.-]/g, '_') + '.json');
196
+ let seen = []; try { seen = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { }
197
+ const key = (f) => f.id + '|' + f.title;
198
+ const fresh = top.filter((f) => !seen.includes(key(f)));
199
+ if (fresh.length) { try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify([...seen, ...fresh.map(key)])); } catch (e) { } }
200
+ return fresh;
201
+ }
202
+
129
203
  export function hookResponse(input, opts = {}) {
130
204
  const failOn = opts.failOn || 'warn';
131
205
  const feedback = !!opts.feedback;
206
+ if (input && input.hook_event_name === 'SessionStart') return opts.context ? sessionStartResponse(input) : { suppressOutput: true };
207
+ // Second Stop after a feedback block: record what the agent said it would do differently, then let it stop.
208
+ if (opts.context && input && input.hook_event_name === 'Stop' && input.stop_hook_active) {
209
+ const file = notesPath(input.cwd);
210
+ if (file && fs.existsSync(file) && input.last_assistant_message && fs.readFileSync(file, 'utf8').includes(`session ${String(input.session_id || '').slice(0, 8)}`)) fs.appendFileSync(file, `\n## What the agent said it would do differently\n\n${String(input.last_assistant_message).trim().slice(0, 1500)}\n`);
211
+ return { suppressOutput: true };
212
+ }
132
213
  if (!input || !input.transcript_path || !fs.existsSync(input.transcript_path)) return { systemMessage: 'Glassbox: no transcript_path in hook input.', suppressOutput: true };
133
214
  const s = sessionFromTranscriptPath(input.transcript_path);
134
215
  const res = analyse(loadSessionFiles(s), opts.rates);
@@ -137,12 +218,25 @@ export function hookResponse(input, opts = {}) {
137
218
  const usd = (v) => v == null ? '' : ' · ' + (v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2));
138
219
  const top = res.findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
139
220
  const head = `Glassbox · ${T.turns} turns · ${T.toolCalls} tool calls (${T.toolErrors} failed) · ctx peak ${core.fmtInt(Math.max(0, ...res.trace.requests.map((r) => r.contextTokens)))} tokens${usd(rep.json.summary.cost)}`;
140
- const list = top.slice(0, 6).map((f) => `${f.severity.toUpperCase()} ${f.id}: ${f.title}`);
141
- const out = { systemMessage: head + (list.length ? '\n' + list.join('\n') + (top.length > 6 ? `\n… ${top.length - 6} more (glassbox check)` : '') : '\nno findings at or above ' + failOn), suppressOutput: true };
142
- // Feedback loop: only on Stop, only once (stop_hook_active guards against loops), only when something is worth saying.
143
- if (feedback && input.hook_event_name === 'Stop' && !input.stop_hook_active && top.length) {
221
+ const topGroups = collapseFindings(top);
222
+ const list = topGroups.slice(0, 6).map(({ f, n }) => `${f.severity.toUpperCase()} ${f.id}: ${f.title}${n > 1 ? ` ×${n}` : ''}`);
223
+ const rest = topGroups.slice(6).reduce((s, g) => s + g.n, 0);
224
+ const out = { systemMessage: head + (list.length ? '\n' + list.join('\n') + (rest ? `\n… ${rest} more (glassbox check)` : '') : '\nno findings at or above ' + failOn), suppressOutput: true };
225
+ if (opts.context && input.hook_event_name === 'Stop') {
226
+ const file = notesPath(input.cwd);
227
+ if (top.length) {
228
+ const when = new Date().toISOString().slice(0, 16).replace('T', ' ');
229
+ const body = core.reportMarkdown(res.trace, top.slice(0, 8), null, { maxEvidence: 2, instruction: false, title: 'last session' }).replace(/^# [^\n]*\n\n/, '').replace(/\n## Tools[\s\S]*?(?=\n_Generated)/, '\n').replace(/\n_Generated[^\n]*\n?$/, '');
230
+ writeNotes(input.cwd, `# Glassbox: last session in this project\n\n${when} UTC · session ${String(input.session_id || s.id).slice(0, 8)} · ${head.replace(/^Glassbox · /, '')}\n\n${body.trim()}\n`);
231
+ } else if (file && fs.existsSync(file)) fs.unlinkSync(file); // a clean session: don't keep old advice around
232
+ }
233
+ // Feedback loop: only on Stop, never while continuing from our own block (stop_hook_active), and each
234
+ // finding only once per session. stop_hook_active resets every turn, so without the delivered set a
235
+ // session would be blocked again at the end of every later turn with the same findings.
236
+ const fresh = feedback && input.hook_event_name === 'Stop' && !input.stop_hook_active ? undeliveredFindings(input.session_id || s.id, top, opts.stateDir) : [];
237
+ if (fresh.length) {
144
238
  out.decision = 'block';
145
- out.reason = `Glassbox read this session's flight recorder (${head}).\n\n` + core.reportMarkdown(res.trace, top, null, { maxEvidence: 3, instruction: false, title: 'this session' }).replace(/^# [^\n]*\n\n/, '').replace(/\n## Tools[\s\S]*?(?=\n_Generated)/, '\n').replace(/\n_Generated[^\n]*\n?$/, '') + `\nIn one or two sentences, tell the user what you would do differently next session (no need to redo work), then stop.`;
239
+ out.reason = `Glassbox read this session's flight recorder (${head}).\n\n` + core.reportMarkdown(res.trace, fresh, null, { maxEvidence: 3, instruction: false, title: 'this session' }).replace(/^# [^\n]*\n\n/, '').replace(/\n## Tools[\s\S]*?(?=\n_Generated)/, '\n').replace(/\n_Generated[^\n]*\n?$/, '') + `\nIn one or two sentences, tell the user what you would do differently next session (no need to redo work), then stop.`;
146
240
  }
147
241
  return out;
148
242
  }
@@ -158,8 +252,8 @@ export function installHook(opts = {}) {
158
252
  let settings = {};
159
253
  if (fs.existsSync(file)) { try { settings = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`${file} is not valid JSON — fix it first (nothing was changed)`); } fs.copyFileSync(file, file + '.glassbox-backup'); }
160
254
  settings.hooks = settings.hooks || {};
161
- const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
162
- for (const ev of opts.events || ['Stop']) {
255
+ const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
256
+ for (const ev of opts.events || (opts.context ? ['Stop', 'SessionStart'] : ['Stop'])) {
163
257
  const list = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
164
258
  const kept = list.filter((entry) => !(entry && Array.isArray(entry.hooks) && entry.hooks.some((h) => h && typeof h.command === 'string' && HOOK_RE.test(h.command))));
165
259
  kept.push({ hooks: [{ type: 'command', command: cmd, timeout: 60 }] });
@@ -187,7 +281,7 @@ export function parseArgs(argv) {
187
281
  const args = { _: [], flags: {} };
188
282
  for (let i = 0; i < argv.length; i++) {
189
283
  const a = argv[i];
190
- if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
284
+ if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); if (v !== undefined) args.flags[k] = v; else if (i + 1 < argv.length && !argv[i + 1].startsWith('-') && ['last', 'grep', 'project', 'out', 'fail-on', 'home', 'command', 'events', 'format', 'label-a', 'label-b', 'port', 'rates', 'since'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
191
285
  else args._.push(a);
192
286
  }
193
287
  return args;
@@ -204,17 +298,21 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
204
298
  print findings; exit 1 when any finding is at/above --fail-on (default: error), 2 on a usage error
205
299
  --format md is written to be pasted back to the agent: evidence + advice per finding
206
300
  --redact blanks prompt text, tool inputs and result text so the report can be shared
301
+ glassbox check --all [--since 1h] [--project PATH]
302
+ one line per session (all, or written in the last 30m/1h/2d); exit 1 if any fails
207
303
  glassbox compare A B [--format text|json|md] [--out FILE.html] [--label-a NAME --label-b NAME]
208
304
  same task, two sessions: what changed in time, tokens, cost, tools and findings
209
305
  glassbox watch [ID|FILE] [--port N] [--no-open]
210
306
  live tail: serves the viewer on 127.0.0.1 and pushes the transcript as it grows
211
- glassbox hook install [--feedback] [--fail-on warn]
307
+ glassbox hook install [--feedback] [--context] [--fail-on warn]
212
308
  add a Claude Code Stop hook so every session ends with a Glassbox summary;
213
- --feedback also hands the findings back to the agent once, so it can learn from them
309
+ --feedback also hands the findings back to the agent once, so it can learn from them;
310
+ --context keeps them in <project>/.glassbox/last-session.md and gives them to the next session
214
311
  glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
215
312
  glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
216
313
 
217
314
  Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
315
+ --rates FILE a JSON rate card for check, compare and hook (or set GLASSBOX_RATES)
218
316
  --version --help
219
317
  Browser set GLASSBOX_BROWSER to a command to open HTML files with
220
318
 
@@ -236,7 +334,7 @@ export function compareReport(A, B, opts = {}) {
236
334
  lines.push(' ' + 'metric'.padEnd(22) + la.padStart(w) + ' ' + lb.padStart(w) + ' change');
237
335
  for (const m of c.metrics) { const ch = core.fmtChange(m); lines.push(' ' + m.label.padEnd(22) + m.aText.padStart(w) + ' ' + m.bText.padStart(w) + ' ' + ch + (m.better ? ' (' + who(m.better) + ')' : '')); }
238
336
  lines.push('');
239
- const fl = (list, label) => { if (!list.length) return; lines.push(` only in ${label}:`); for (const f of list) lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(16)} ${f.title}`); };
337
+ const fl = (list, label) => { if (!list.length) return; lines.push(` only in ${label}:`); for (const g of collapseFindings(list)) lines.push(' ' + findingLine(g)); };
240
338
  fl(c.findings.onlyA, la); fl(c.findings.onlyB, lb);
241
339
  if (c.findings.both.length) lines.push(` in both: ${c.findings.both.map((f) => f.id).join(', ')}`);
242
340
  if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) lines.push(' no findings in either session');
@@ -252,15 +350,32 @@ export async function main(argv, io = {}) {
252
350
  if (args.flags.help || cmd === 'help') { out(HELP); return 0; }
253
351
  if (args.flags.version) { out(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version); return 0; }
254
352
  try {
353
+ const rates = loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES);
255
354
  if (cmd === 'list') {
256
355
  const list = findSessions({ home, last: args.flags.last ? +args.flags.last : 20, grep: args.flags.grep, project: args.flags.project });
257
356
  if (!list.length) { out(noSessionsMessage(home)); return 0; }
258
357
  for (const s of list) out(`${s.id.slice(0, 8)} ${new Date(s.mtime).toISOString().slice(0, 16).replace('T', ' ')} ${String(Math.round(s.size / 1024)).padStart(6)} KB ${s.project.padEnd(28).slice(0, 28)} ${sessionTitle(s.file)}`);
259
358
  return 0;
260
359
  }
360
+ if (cmd === 'check' && args.flags.all) {
361
+ // Every session (optionally only those written in the last --since), one line each; exit 1 if any fails.
362
+ const since = args.flags.since ? parseSince(args.flags.since) : null;
363
+ const list = findSessions({ home, project: args.flags.project }).filter((s) => since == null || s.mtime >= Date.now() - since);
364
+ const fmtOut = outputFormat(args.flags);
365
+ const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact }) }));
366
+ const failed = reps.filter((r) => r.rep.failed).length;
367
+ if (fmtOut === 'json') out(JSON.stringify({ glassbox: core.VERSION, schema: 1, failOn: args.flags['fail-on'] || 'error', failed: failed > 0, sessions: reps.map((r) => r.rep.json) }, null, 2));
368
+ else if (fmtOut === 'md') out(reps.map((r) => r.rep.markdown).join('\n---\n\n'));
369
+ else {
370
+ if (!reps.length) out(since != null ? `No sessions written in the last ${args.flags.since}.` : noSessionsMessage(home));
371
+ for (const { s, rep } of reps) { const f = rep.json.findings, n = (sev) => f.filter((x) => x.severity === sev).length, c = rep.json.summary.cost; out(`${rep.failed ? 'FAIL' : 'ok '} ${s.id.slice(0, 8)} ${String(n('error')).padStart(2)} error ${String(n('warn')).padStart(3)} warn ${String(n('info')).padStart(3)} info ${c == null ? ' —' : ('$' + c.toFixed(2)).padStart(8)} ${args.flags.redact ? '' : sessionTitle(s.file)}`); }
372
+ if (reps.length) out(`\n${failed} of ${reps.length} session${reps.length === 1 ? '' : 's'} at or above "${args.flags['fail-on'] || 'error'}"`);
373
+ }
374
+ return failed ? 1 : 0;
375
+ }
261
376
  if (cmd === 'check') {
262
377
  const s = resolveTarget(args._[1], { home });
263
- const res = checkReport(analyse(loadSessionFiles(s)), { failOn: args.flags['fail-on'], redact: !!args.flags.redact });
378
+ const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact });
264
379
  const fmtOut = outputFormat(args.flags);
265
380
  if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
266
381
  return res.failed ? 1 : 0;
@@ -269,7 +384,7 @@ export async function main(argv, io = {}) {
269
384
  if (!args._[1] || !args._[2]) throw new Error('compare needs two sessions: glassbox compare A B');
270
385
  const sa = resolveTarget(args._[1], { home }), sb = resolveTarget(args._[2], { home });
271
386
  const fa = loadSessionFiles(sa), fb = loadSessionFiles(sb);
272
- const rep = compareReport(analyse(fa), analyse(fb), { labelA: args.flags['label-a'], labelB: args.flags['label-b'] });
387
+ const rep = compareReport(analyse(fa, rates), analyse(fb, rates), { labelA: args.flags['label-a'], labelB: args.flags['label-b'] });
273
388
  if (args.flags.out) {
274
389
  const html = embed({ files: fa, compare: fb }, { template: io.template });
275
390
  const outFile = path.resolve(args.flags.out); fs.writeFileSync(outFile, html);
@@ -281,7 +396,7 @@ export async function main(argv, io = {}) {
281
396
  if (fmtOut === 'json') out(JSON.stringify(rep.json, null, 2)); else if (fmtOut === 'md') out(rep.markdown); else out(rep.text);
282
397
  return 0;
283
398
  }
284
- if (cmd === 'open') {
399
+ if (cmd === 'open' && !args.flags.watch) {
285
400
  const s = resolveTarget(args._[1], { home });
286
401
  const files = loadSessionFiles(s);
287
402
  const html = embed(files, { template: io.template });
@@ -303,10 +418,13 @@ export async function main(argv, io = {}) {
303
418
  }
304
419
  if (cmd === 'hook') {
305
420
  const sub = args._[1];
306
- if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, failOn: args.flags['fail-on'], command: args.flags.command, events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed Stop hook in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}. Restart Claude Code to pick it up.`); return 0; }
421
+ if (sub === 'install') { const r = installHook({ home, feedback: !!args.flags.feedback, context: !!args.flags.context, failOn: args.flags['fail-on'], command: args.flags.command, events: args.flags.events ? String(args.flags.events).split(',') : undefined }); out(`Installed ${args.flags.context ? 'Stop and SessionStart hooks' : 'Stop hook'} in ${r.file}\n ${r.command}\nEvery session now ends with a Glassbox summary${args.flags.feedback ? ', and findings are handed back to the agent once' : ''}${args.flags.context ? `; findings are kept in <project>/${NOTES_FILE.replace(/\\/g, '/')} and handed to the next session there` : ''}. Restart Claude Code to pick it up.`); return 0; }
307
422
  if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
308
- const input = io.stdin !== undefined ? io.stdin : await readStdinJson();
309
- out(JSON.stringify(hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback })));
423
+ // A hook must never fail loudly: exit 2 from a Stop hook blocks Claude with the error as the reason.
424
+ let reply;
425
+ try { const input = io.stdin !== undefined ? io.stdin : await readStdinJson(); reply = hookResponse(input, { failOn: args.flags['fail-on'], feedback: !!args.flags.feedback, context: !!args.flags.context, rates, stateDir: io.stateDir }); }
426
+ catch (e) { reply = { systemMessage: 'Glassbox: ' + e.message, suppressOutput: true }; }
427
+ out(JSON.stringify(reply));
310
428
  return 0;
311
429
  }
312
430
  err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
package/src/tail.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import http from 'node:http';
6
+ import { subagentFiles } from './cli.mjs';
6
7
 
7
8
  // Follow one session's files by byte offset. Emits:
8
9
  // { type: 'snapshot', files: [{name, text}] } once, at start()
@@ -21,12 +22,11 @@ export class Tailer {
21
22
  on(fn) { this.listeners.push(fn); return this; }
22
23
  emit(ev) { for (const fn of this.listeners) fn(ev); }
23
24
 
24
- // All files that belong to the session right now: main + subagents dir.
25
+ // All files that belong to the session right now: main + subagents dir (Workflow agents included;
26
+ // fs.watch below is not recursive, so their folders are picked up by the poll).
25
27
  discover() {
26
- const s = this.session; const out = [{ name: s.id + '.jsonl', path: s.file }];
27
- const sub = path.join(s.projectDir, s.id, 'subagents');
28
- if (fs.existsSync(sub)) for (const f of fs.readdirSync(sub).sort()) if (/\.(jsonl|json)$/.test(f)) out.push({ name: `${s.id}/subagents/${f}`, path: path.join(sub, f) });
29
- return out;
28
+ const s = this.session;
29
+ return [{ name: s.id + '.jsonl', path: s.file }, ...subagentFiles(s)];
30
30
  }
31
31
 
32
32
  // Read everything new. Returns the events it emitted (also emits them).