glassbox-trace 0.3.0 → 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/README.md +59 -26
- package/dist/glassbox.html +592 -67
- package/package.json +57 -54
- package/src/cli.mjs +217 -31
- package/src/tail.mjs +141 -0
- package/src/trace-core.js +426 -50
- package/src/viewer.html +166 -17
package/package.json
CHANGED
|
@@ -1,54 +1,57 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "glassbox-trace",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"url": "https://github.com/aldohushi1-stack/glassbox
|
|
53
|
-
}
|
|
54
|
-
|
|
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
|
@@ -4,12 +4,16 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const core = require('./trace-core.js');
|
|
9
|
-
|
|
10
|
+
// fileURLToPath, not URL.pathname: on Windows the latter gives "/C:/…" which path.resolve turns into "\C:\…".
|
|
11
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
12
|
|
|
11
13
|
export const claudeHome = (env = process.env) => env.GLASSBOX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.claude');
|
|
12
|
-
|
|
14
|
+
// Claude Code encodes the project path with '-' for every separator: "/home/aldo/x" → "-home-aldo-x",
|
|
15
|
+
// and on Windows "C:\Users\aldo" → "C--Users-aldo".
|
|
16
|
+
export const decodeProject = (n) => { const m = n.match(/^([A-Za-z])--(.*)$/); if (m) return m[1] + ':/' + m[2].replace(/-/g, '/'); return n.replace(/^-/, '/').replace(/-/g, '/'); };
|
|
13
17
|
|
|
14
18
|
export function findSessions(opts = {}) {
|
|
15
19
|
const home = opts.home || claudeHome();
|
|
@@ -41,7 +45,7 @@ export function sessionTitle(file) {
|
|
|
41
45
|
|
|
42
46
|
// Resolve a user-supplied target: a path to a .jsonl, or a session-id prefix.
|
|
43
47
|
export function resolveTarget(target, opts = {}) {
|
|
44
|
-
if (!target) { const s = findSessions({ home: opts.home, last: 1 })[0]; if (!s) throw new Error(
|
|
48
|
+
if (!target) { const s = findSessions({ home: opts.home, last: 1 })[0]; if (!s) throw new Error(noSessionsMessage(opts.home)); return s; }
|
|
45
49
|
if (fs.existsSync(target) && fs.statSync(target).isFile()) { const file = path.resolve(target); return { id: path.basename(file).replace(/\.jsonl$/, ''), file, project: null, projectDir: path.dirname(file), size: fs.statSync(file).size, mtime: fs.statSync(file).mtimeMs }; }
|
|
46
50
|
const all = findSessions({ home: opts.home });
|
|
47
51
|
const hits = all.filter((s) => s.id.startsWith(target));
|
|
@@ -50,15 +54,56 @@ export function resolveTarget(target, opts = {}) {
|
|
|
50
54
|
throw new Error(`No session or file matches "${target}"`);
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
export function noSessionsMessage(home) {
|
|
58
|
+
const dir = path.join(home || claudeHome(), 'projects');
|
|
59
|
+
return `No sessions found under ${dir}\n Run a Claude Code session first — every session writes ${path.join(dir, '<project>', '<session-id>.jsonl')}.\n Or point at a file: glassbox open <file.jsonl> (also check / compare / watch)\n Or another home: GLASSBOX_HOME=/path/to/.claude glassbox (or --home)`;
|
|
60
|
+
}
|
|
61
|
+
|
|
53
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
|
+
|
|
54
78
|
export function loadSessionFiles(s) {
|
|
55
79
|
const files = [{ name: s.id + '.jsonl', text: fs.readFileSync(s.file, 'utf8') }];
|
|
56
|
-
const
|
|
57
|
-
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') });
|
|
58
81
|
return files;
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
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
|
+
}
|
|
62
107
|
|
|
63
108
|
const safe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');
|
|
64
109
|
export function embed(files, opts = {}) {
|
|
@@ -66,7 +111,8 @@ export function embed(files, opts = {}) {
|
|
|
66
111
|
if (!fs.existsSync(template)) throw new Error('dist/glassbox.html not found — run `npm run build` first');
|
|
67
112
|
const html = fs.readFileSync(template, 'utf8');
|
|
68
113
|
if (!html.includes('/*__EMBED__*/null')) throw new Error('template has no embed marker');
|
|
69
|
-
|
|
114
|
+
const payload = Array.isArray(files) ? files : { files: files.files, compare: files.compare || null, labels: files.labels || null };
|
|
115
|
+
return html.replace('/*__EMBED__*/null', () => safe(JSON.stringify(payload)));
|
|
70
116
|
}
|
|
71
117
|
|
|
72
118
|
export function openInBrowser(file, env = process.env) {
|
|
@@ -80,24 +126,34 @@ export function openInBrowser(file, env = process.env) {
|
|
|
80
126
|
}
|
|
81
127
|
|
|
82
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
|
+
|
|
83
137
|
export function checkReport({ trace, findings, cost }, opts = {}) {
|
|
84
138
|
const failOn = opts.failOn || 'error';
|
|
85
139
|
const failing = findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
|
|
86
140
|
const T = trace.totals, m = trace.meta;
|
|
87
141
|
const fmt = core.fmtDur, fi = core.fmtInt;
|
|
88
142
|
const usd = (v) => v == null ? '—' : '$' + (v < 1 ? v.toFixed(3) : v.toFixed(2));
|
|
89
|
-
const
|
|
90
|
-
const
|
|
143
|
+
const redact = !!opts.redact;
|
|
144
|
+
const summary = { session: m.sessionId, title: m.title ? (redact ? '«' + m.title.length + ' chars»' : m.title) : null, model: m.models, wallMs: T.wallMs, activeMs: T.activeMs, turns: T.turns, requests: T.requests, toolCalls: T.toolCalls, toolErrors: T.toolErrors, orphans: T.orphans, usage: T.usage, cacheHitRatio: T.cacheHitRatio, cost: cost.reported != null ? cost.reported : cost.total, costSource: cost.source };
|
|
145
|
+
const json = { glassbox: core.VERSION, schema: 1, redacted: redact, summary, findings: findings.map((f) => ({ id: f.id, severity: f.severity, title: f.title, detail: redact ? core.redactDetail(f) : f.detail, metric: f.metric, evidence: f.evidence })), failOn, failed: failing.length > 0 };
|
|
91
146
|
const lines = [];
|
|
92
147
|
lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
|
|
93
148
|
lines.push(` wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
|
|
94
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)}`);
|
|
95
150
|
lines.push('');
|
|
96
151
|
if (!findings.length) lines.push(' no findings');
|
|
97
|
-
for (const
|
|
152
|
+
for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
|
|
153
|
+
if (redact) lines.push('', ' (redacted: prompt text, tool inputs and result text blanked)');
|
|
98
154
|
lines.push('');
|
|
99
155
|
lines.push(failing.length ? ` FAIL: ${failing.length} finding${failing.length > 1 ? 's' : ''} at or above "${failOn}"` : ` OK: nothing at or above "${failOn}"`);
|
|
100
|
-
const md =
|
|
156
|
+
const md = core.reportMarkdown(trace, findings, cost, { redact, maxEvidence: opts.maxEvidence });
|
|
101
157
|
return { text: lines.join('\n'), json, markdown: md, failed: failing.length > 0 };
|
|
102
158
|
}
|
|
103
159
|
|
|
@@ -114,9 +170,46 @@ export function sessionFromTranscriptPath(p) {
|
|
|
114
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 };
|
|
115
171
|
}
|
|
116
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
|
+
|
|
117
203
|
export function hookResponse(input, opts = {}) {
|
|
118
204
|
const failOn = opts.failOn || 'warn';
|
|
119
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
|
+
}
|
|
120
213
|
if (!input || !input.transcript_path || !fs.existsSync(input.transcript_path)) return { systemMessage: 'Glassbox: no transcript_path in hook input.', suppressOutput: true };
|
|
121
214
|
const s = sessionFromTranscriptPath(input.transcript_path);
|
|
122
215
|
const res = analyse(loadSessionFiles(s), opts.rates);
|
|
@@ -125,12 +218,25 @@ export function hookResponse(input, opts = {}) {
|
|
|
125
218
|
const usd = (v) => v == null ? '' : ' · ' + (v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2));
|
|
126
219
|
const top = res.findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
|
|
127
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)}`;
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
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) {
|
|
132
238
|
out.decision = 'block';
|
|
133
|
-
out.reason = `Glassbox read this session's flight recorder
|
|
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.`;
|
|
134
240
|
}
|
|
135
241
|
return out;
|
|
136
242
|
}
|
|
@@ -146,8 +252,8 @@ export function installHook(opts = {}) {
|
|
|
146
252
|
let settings = {};
|
|
147
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'); }
|
|
148
254
|
settings.hooks = settings.hooks || {};
|
|
149
|
-
const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
|
|
150
|
-
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'])) {
|
|
151
257
|
const list = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
|
|
152
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))));
|
|
153
259
|
kept.push({ hooks: [{ type: 'command', command: cmd, timeout: 60 }] });
|
|
@@ -175,33 +281,66 @@ export function parseArgs(argv) {
|
|
|
175
281
|
const args = { _: [], flags: {} };
|
|
176
282
|
for (let i = 0; i < argv.length; i++) {
|
|
177
283
|
const a = argv[i];
|
|
178
|
-
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'].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; }
|
|
179
285
|
else args._.push(a);
|
|
180
286
|
}
|
|
181
287
|
return args;
|
|
182
288
|
}
|
|
183
289
|
|
|
184
|
-
export const HELP = `glassbox —
|
|
290
|
+
export const HELP = `glassbox — other tools show you what happened in a Claude Code session; Glassbox tells you what went wrong
|
|
185
291
|
|
|
186
292
|
glassbox open the newest session in your browser
|
|
187
293
|
glassbox list [--last N] [--grep TEXT] [--project PATH]
|
|
188
294
|
list sessions, newest first
|
|
189
295
|
glassbox open [ID|FILE] [--out FILE.html] [--no-open]
|
|
190
296
|
build a self-contained HTML for a session (id prefix or .jsonl path)
|
|
191
|
-
glassbox check [ID|FILE] [--fail-on error|warn|info] [--json] [--
|
|
192
|
-
print findings; exit 1 when any finding is at/above --fail-on (default: error)
|
|
193
|
-
|
|
297
|
+
glassbox check [ID|FILE] [--fail-on error|warn|info] [--format text|json|md] [--redact]
|
|
298
|
+
print findings; exit 1 when any finding is at/above --fail-on (default: error), 2 on a usage error
|
|
299
|
+
--format md is written to be pasted back to the agent: evidence + advice per finding
|
|
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
|
|
303
|
+
glassbox compare A B [--format text|json|md] [--out FILE.html] [--label-a NAME --label-b NAME]
|
|
304
|
+
same task, two sessions: what changed in time, tokens, cost, tools and findings
|
|
305
|
+
glassbox watch [ID|FILE] [--port N] [--no-open]
|
|
306
|
+
live tail: serves the viewer on 127.0.0.1 and pushes the transcript as it grows
|
|
307
|
+
glassbox hook install [--feedback] [--context] [--fail-on warn]
|
|
194
308
|
add a Claude Code Stop hook so every session ends with a Glassbox summary;
|
|
195
|
-
--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
|
|
196
311
|
glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
|
|
197
312
|
glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
|
|
198
313
|
|
|
199
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)
|
|
200
316
|
--version --help
|
|
201
317
|
Browser set GLASSBOX_BROWSER to a command to open HTML files with
|
|
202
318
|
|
|
203
319
|
Nothing leaves your machine. Subagent transcripts next to the session are included automatically.`;
|
|
204
320
|
|
|
321
|
+
export function outputFormat(flags) {
|
|
322
|
+
const f = flags.format ? String(flags.format).toLowerCase() : (flags.json ? 'json' : flags.markdown ? 'md' : 'text');
|
|
323
|
+
if (f === 'markdown') return 'md';
|
|
324
|
+
if (!['text', 'json', 'md'].includes(f)) throw new Error(`--format must be text, json or md (got "${f}")`);
|
|
325
|
+
return f;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function compareReport(A, B, opts = {}) {
|
|
329
|
+
const c = core.compare(A, B);
|
|
330
|
+
const la = opts.labelA || (A.trace.meta.sessionId || 'A').slice(0, 8), lb = opts.labelB || (B.trace.meta.sessionId || 'B').slice(0, 8);
|
|
331
|
+
const who = (x) => x === 'a' ? la : x === 'b' ? lb : 'tie';
|
|
332
|
+
const lines = [`Glassbox compare · ${la} vs ${lb}`, ` cheaper: ${who(c.verdict.cheaper)} · faster: ${who(c.verdict.faster)} · cleaner: ${who(c.verdict.cleaner)}`, ''];
|
|
333
|
+
const w = Math.max(la.length, lb.length, 10);
|
|
334
|
+
lines.push(' ' + 'metric'.padEnd(22) + la.padStart(w) + ' ' + lb.padStart(w) + ' change');
|
|
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) + ')' : '')); }
|
|
336
|
+
lines.push('');
|
|
337
|
+
const fl = (list, label) => { if (!list.length) return; lines.push(` only in ${label}:`); for (const g of collapseFindings(list)) lines.push(' ' + findingLine(g)); };
|
|
338
|
+
fl(c.findings.onlyA, la); fl(c.findings.onlyB, lb);
|
|
339
|
+
if (c.findings.both.length) lines.push(` in both: ${c.findings.both.map((f) => f.id).join(', ')}`);
|
|
340
|
+
if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) lines.push(' no findings in either session');
|
|
341
|
+
return { text: lines.join('\n'), json: c, markdown: core.compareMarkdown(c, { labelA: la, labelB: lb }), compare: c };
|
|
342
|
+
}
|
|
343
|
+
|
|
205
344
|
export async function main(argv, io = {}) {
|
|
206
345
|
const out = io.stdout || ((s) => process.stdout.write(s + '\n'));
|
|
207
346
|
const err = io.stderr || ((s) => process.stderr.write(s + '\n'));
|
|
@@ -211,19 +350,53 @@ export async function main(argv, io = {}) {
|
|
|
211
350
|
if (args.flags.help || cmd === 'help') { out(HELP); return 0; }
|
|
212
351
|
if (args.flags.version) { out(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version); return 0; }
|
|
213
352
|
try {
|
|
353
|
+
const rates = loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES);
|
|
214
354
|
if (cmd === 'list') {
|
|
215
355
|
const list = findSessions({ home, last: args.flags.last ? +args.flags.last : 20, grep: args.flags.grep, project: args.flags.project });
|
|
216
|
-
if (!list.length) { out(
|
|
356
|
+
if (!list.length) { out(noSessionsMessage(home)); return 0; }
|
|
217
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)}`);
|
|
218
358
|
return 0;
|
|
219
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
|
+
}
|
|
220
376
|
if (cmd === 'check') {
|
|
221
377
|
const s = resolveTarget(args._[1], { home });
|
|
222
|
-
const res = checkReport(analyse(loadSessionFiles(s)), { failOn: args.flags['fail-on'] });
|
|
223
|
-
|
|
378
|
+
const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact });
|
|
379
|
+
const fmtOut = outputFormat(args.flags);
|
|
380
|
+
if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
|
|
224
381
|
return res.failed ? 1 : 0;
|
|
225
382
|
}
|
|
226
|
-
if (cmd === '
|
|
383
|
+
if (cmd === 'compare') {
|
|
384
|
+
if (!args._[1] || !args._[2]) throw new Error('compare needs two sessions: glassbox compare A B');
|
|
385
|
+
const sa = resolveTarget(args._[1], { home }), sb = resolveTarget(args._[2], { home });
|
|
386
|
+
const fa = loadSessionFiles(sa), fb = loadSessionFiles(sb);
|
|
387
|
+
const rep = compareReport(analyse(fa, rates), analyse(fb, rates), { labelA: args.flags['label-a'], labelB: args.flags['label-b'] });
|
|
388
|
+
if (args.flags.out) {
|
|
389
|
+
const html = embed({ files: fa, compare: fb }, { template: io.template });
|
|
390
|
+
const outFile = path.resolve(args.flags.out); fs.writeFileSync(outFile, html);
|
|
391
|
+
out(`${outFile} (${fa.length + fb.length} files, ${Math.round(html.length / 1024)} KB)`);
|
|
392
|
+
if (!args.flags['no-open'] && !io.noOpen) openInBrowser(outFile);
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
const fmtOut = outputFormat(args.flags);
|
|
396
|
+
if (fmtOut === 'json') out(JSON.stringify(rep.json, null, 2)); else if (fmtOut === 'md') out(rep.markdown); else out(rep.text);
|
|
397
|
+
return 0;
|
|
398
|
+
}
|
|
399
|
+
if (cmd === 'open' && !args.flags.watch) {
|
|
227
400
|
const s = resolveTarget(args._[1], { home });
|
|
228
401
|
const files = loadSessionFiles(s);
|
|
229
402
|
const html = embed(files, { template: io.template });
|
|
@@ -233,12 +406,25 @@ export async function main(argv, io = {}) {
|
|
|
233
406
|
if (!args.flags['no-open'] && !io.noOpen) openInBrowser(outFile);
|
|
234
407
|
return 0;
|
|
235
408
|
}
|
|
409
|
+
if (cmd === 'watch' || (cmd === 'open' && args.flags.watch)) {
|
|
410
|
+
const { serveLive } = await import('./tail.mjs');
|
|
411
|
+
const s = resolveTarget(args._[1], { home });
|
|
412
|
+
const live = await serveLive(s, { port: args.flags.port ? +args.flags.port : 0, embed: (files) => embed(files, { template: io.template }) });
|
|
413
|
+
out(`Glassbox live · ${s.id.slice(0, 8)} · ${live.url}\n following ${s.file}\n Ctrl+C to stop`);
|
|
414
|
+
if (!args.flags['no-open'] && !io.noOpen) openInBrowser(live.url);
|
|
415
|
+
if (io.onLive) { io.onLive(live); return 0; }
|
|
416
|
+
await new Promise((resolve) => { const stop = () => { live.close().then(resolve); }; process.once('SIGINT', stop); process.once('SIGTERM', stop); });
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
236
419
|
if (cmd === 'hook') {
|
|
237
420
|
const sub = args._[1];
|
|
238
|
-
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; }
|
|
239
422
|
if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
|
|
240
|
-
|
|
241
|
-
|
|
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));
|
|
242
428
|
return 0;
|
|
243
429
|
}
|
|
244
430
|
err('Unknown command: ' + cmd + '\n'); out(HELP); return 2;
|