glassbox-trace 0.4.1 → 0.6.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 +43 -20
- package/dist/glassbox.html +250 -45
- package/package.json +57 -57
- package/src/cli.mjs +202 -22
- package/src/tail.mjs +5 -5
- package/src/trace-core.js +247 -44
- package/src/viewer.html +3 -1
package/src/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
6
7
|
import { createRequire } from 'node:module';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
@@ -60,14 +61,50 @@ export function noSessionsMessage(home) {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
// Session file + its subagent transcripts, as the {name, text} list the core expects.
|
|
64
|
+
// Subagent transcripts: <id>/subagents/*.jsonl, and Workflow agents one level deeper in
|
|
65
|
+
// <id>/subagents/workflows/<runId>/. journal.jsonl there is the workflow's own log, not a transcript.
|
|
66
|
+
export function subagentFiles(s) {
|
|
67
|
+
const out = [];
|
|
68
|
+
const walk = (dir, rel) => {
|
|
69
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
70
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), `${rel}/${e.name}`);
|
|
71
|
+
else if (/\.(jsonl|json)$/.test(e.name) && e.name !== 'journal.jsonl') out.push({ name: `${rel}/${e.name}`, path: path.join(dir, e.name) });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const sub = path.join(s.projectDir, s.id, 'subagents');
|
|
75
|
+
if (fs.existsSync(sub)) walk(sub, `${s.id}/subagents`);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
export function loadSessionFiles(s) {
|
|
64
80
|
const files = [{ name: s.id + '.jsonl', text: fs.readFileSync(s.file, 'utf8') }];
|
|
65
|
-
const
|
|
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') });
|
|
81
|
+
for (const f of subagentFiles(s)) files.push({ name: f.name, text: fs.readFileSync(f.path, 'utf8') });
|
|
67
82
|
return files;
|
|
68
83
|
}
|
|
69
84
|
|
|
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 }; }
|
|
85
|
+
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 }; }
|
|
86
|
+
|
|
87
|
+
// A team rate card: --rates FILE or GLASSBOX_RATES. JSON keyed by model-id prefix, USD per million tokens:
|
|
88
|
+
// { "claude-opus-5": { "in": 4, "out": 20, "read": 0.4, "w5m": 5, "w1h": 8 } }
|
|
89
|
+
// Entries override or extend the built-in card; cache-write rates default to 1.25× / 2× input.
|
|
90
|
+
export function loadRates(file) {
|
|
91
|
+
if (!file) return null;
|
|
92
|
+
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'}`); }
|
|
93
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) throw new Error(`--rates ${file}: expected an object keyed by model id`);
|
|
94
|
+
const out = Object.assign({}, core.RATES);
|
|
95
|
+
for (const [model, r] of Object.entries(json)) {
|
|
96
|
+
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)`);
|
|
97
|
+
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 };
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// "90m", "1h", "2d" → milliseconds.
|
|
103
|
+
export function parseSince(s) {
|
|
104
|
+
const m = String(s).trim().match(/^(\d+(?:\.\d+)?)\s*(m|min|h|d)$/i);
|
|
105
|
+
if (!m) throw new Error(`--since must look like 30m, 1h or 2d (got "${s}")`);
|
|
106
|
+
return +m[1] * ({ m: 60e3, min: 60e3, h: 3600e3, d: 86400e3 })[m[2].toLowerCase()];
|
|
107
|
+
}
|
|
71
108
|
|
|
72
109
|
const safe = (s) => s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--');
|
|
73
110
|
export function embed(files, opts = {}) {
|
|
@@ -89,7 +126,43 @@ export function openInBrowser(file, env = process.env) {
|
|
|
89
126
|
child.on('error', () => { }); child.unref();
|
|
90
127
|
}
|
|
91
128
|
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Legend: file paths → stable keys ("file:1a2b3c4d") for redacted output. The key is the first
|
|
131
|
+
// 8 hex chars of HMAC-SHA256(salt, normalised path); the salt is random, made once, and lives
|
|
132
|
+
// only in the legend file with the key→path map. Re-using the file keeps keys stable across runs.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
export const FILE_KEY_RE = /\bfile:[0-9a-f]{8,16}\b/g;
|
|
135
|
+
export class Legend {
|
|
136
|
+
constructor(data) { this.salt = data && data.salt || crypto.randomBytes(32).toString('hex'); this.files = data && data.files || {}; this.created = data && data.created || new Date().toISOString(); this.byPath = new Map(Object.entries(this.files).map(([k, v]) => [core.normalisePath(v), k])); this.dirty = !data; }
|
|
137
|
+
static load(file) {
|
|
138
|
+
if (!file || !fs.existsSync(file)) return new Legend(null);
|
|
139
|
+
let json; try { json = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`--legend ${file}: not valid JSON (delete it to start a new legend)`); }
|
|
140
|
+
if (!json || typeof json.salt !== 'string' || typeof json.files !== 'object') throw new Error(`--legend ${file}: not a Glassbox legend`);
|
|
141
|
+
return new Legend(json);
|
|
142
|
+
}
|
|
143
|
+
keyFor(p) {
|
|
144
|
+
const norm = core.normalisePath(p);
|
|
145
|
+
const have = this.byPath.get(norm); if (have) return have;
|
|
146
|
+
const hex = crypto.createHmac('sha256', Buffer.from(this.salt, 'hex')).update(norm).digest('hex');
|
|
147
|
+
let len = 8; let key = 'file:' + hex.slice(0, len);
|
|
148
|
+
while (this.files[key] && core.normalisePath(this.files[key]) !== norm && len < 16) { len += 2; key = 'file:' + hex.slice(0, len); } // collision: lengthen
|
|
149
|
+
this.files[key] = String(p); this.byPath.set(norm, key); this.dirty = true;
|
|
150
|
+
return key;
|
|
151
|
+
}
|
|
152
|
+
pathFor(key) { return this.files[key] || null; }
|
|
153
|
+
save(file) { if (!file) return null; fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ glassbox: core.VERSION, note: 'Glassbox legend: maps file keys in a redacted report back to paths on this machine. Keep it here; never send it with the report.', created: this.created, updated: new Date().toISOString(), salt: this.salt, files: this.files }, null, 2) + '\n'); this.dirty = false; return path.resolve(file); }
|
|
154
|
+
reveal(text) { let unknown = 0; const out = String(text).replace(FILE_KEY_RE, (k) => { const p = this.pathFor(k); if (p == null) unknown++; return p == null ? k : p; }); return { text: out, unknown }; }
|
|
155
|
+
}
|
|
156
|
+
|
|
92
157
|
const SEV = { error: 0, warn: 1, info: 2 };
|
|
158
|
+
// Text output only: identical findings (same severity, rule and title) print once with a ×N count.
|
|
159
|
+
export function collapseFindings(findings) {
|
|
160
|
+
const groups = new Map();
|
|
161
|
+
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 }); }
|
|
162
|
+
return Array.from(groups.values());
|
|
163
|
+
}
|
|
164
|
+
const findingLine = ({ f, n }) => `${f.severity.toUpperCase().padEnd(5)} ${f.id.padEnd(16)} ${f.title}${n > 1 ? ` ×${n}` : ''}`;
|
|
165
|
+
|
|
93
166
|
export function checkReport({ trace, findings, cost }, opts = {}) {
|
|
94
167
|
const failOn = opts.failOn || 'error';
|
|
95
168
|
const failing = findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
|
|
@@ -97,15 +170,29 @@ export function checkReport({ trace, findings, cost }, opts = {}) {
|
|
|
97
170
|
const fmt = core.fmtDur, fi = core.fmtInt;
|
|
98
171
|
const usd = (v) => v == null ? '—' : '$' + (v < 1 ? v.toFixed(3) : v.toFixed(2));
|
|
99
172
|
const redact = !!opts.redact;
|
|
173
|
+
const legend = opts.legend || null;
|
|
174
|
+
const keyed = (p) => legend ? legend.keyFor(p) : p;
|
|
100
175
|
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 };
|
|
101
|
-
|
|
176
|
+
// Per-file use, keyed when a legend is given; with --redact and no legend the paths would leak, so it is omitted.
|
|
177
|
+
if (legend || !redact) summary.files = core.fileStats(trace).map((r) => ({ key: legend ? legend.keyFor(r.path) : r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agents, chars: r.chars }));
|
|
178
|
+
const findingJson = (f) => {
|
|
179
|
+
const ev = f.evidence || {};
|
|
180
|
+
const paths = Array.from(new Set((ev.toolCallIds || []).map((id) => { const c = trace.toolCalls.find((x) => x.id === id); return c ? core.filePathOf(c) : null; }).filter(Boolean)));
|
|
181
|
+
let detail = redact ? core.redactDetail(f) : f.detail;
|
|
182
|
+
// duplicate-subagent-read quotes the path and nothing else: with a legend, keep the sentence and key the path.
|
|
183
|
+
if (redact && legend && f.id === 'duplicate-subagent-read') { detail = f.detail; for (const p of paths) detail = detail.split(p).join(legend.keyFor(p)); }
|
|
184
|
+
const evidence = Object.assign({}, ev);
|
|
185
|
+
if (paths.length && (legend || !redact)) evidence.files = Array.from(new Set(paths.map(keyed)));
|
|
186
|
+
return { id: f.id, severity: f.severity, title: f.title, detail, metric: f.metric, evidence };
|
|
187
|
+
};
|
|
188
|
+
const json = { glassbox: core.VERSION, schema: 2, redacted: redact, legend: !!legend, summary, findings: findings.map(findingJson), failOn, failed: failing.length > 0 };
|
|
102
189
|
const lines = [];
|
|
103
190
|
lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
|
|
104
191
|
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' : ''})`);
|
|
105
192
|
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
193
|
lines.push('');
|
|
107
194
|
if (!findings.length) lines.push(' no findings');
|
|
108
|
-
for (const
|
|
195
|
+
for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
|
|
109
196
|
if (redact) lines.push('', ' (redacted: prompt text, tool inputs and result text blanked)');
|
|
110
197
|
lines.push('');
|
|
111
198
|
lines.push(failing.length ? ` FAIL: ${failing.length} finding${failing.length > 1 ? 's' : ''} at or above "${failOn}"` : ` OK: nothing at or above "${failOn}"`);
|
|
@@ -126,9 +213,46 @@ export function sessionFromTranscriptPath(p) {
|
|
|
126
213
|
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
214
|
}
|
|
128
215
|
|
|
216
|
+
// Persisted feedback (--context): the Stop hook leaves <project>/.glassbox/last-session.md behind and
|
|
217
|
+
// the SessionStart hook hands it to the next session, so feedback reaches the session that can use it.
|
|
218
|
+
export const NOTES_FILE = path.join('.glassbox', 'last-session.md');
|
|
219
|
+
const NOTES_MAX = 6000;
|
|
220
|
+
function notesPath(cwd) { return cwd && fs.existsSync(cwd) && fs.statSync(cwd).isDirectory() ? path.join(cwd, NOTES_FILE) : null; }
|
|
221
|
+
function writeNotes(cwd, text) {
|
|
222
|
+
const file = notesPath(cwd); if (!file) return null;
|
|
223
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
224
|
+
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');
|
|
225
|
+
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);
|
|
226
|
+
return file;
|
|
227
|
+
}
|
|
228
|
+
export function sessionStartResponse(input) {
|
|
229
|
+
const file = notesPath(input && input.cwd);
|
|
230
|
+
if (!file || !fs.existsSync(file) || (input.source && !['startup', 'clear', 'compact'].includes(input.source))) return { suppressOutput: true };
|
|
231
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
232
|
+
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}` } };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Findings already handed back in this session, keyed by rule + title, kept in the temp dir.
|
|
236
|
+
function undeliveredFindings(sessionId, top, stateDir) {
|
|
237
|
+
const dir = stateDir || process.env.GLASSBOX_STATE_DIR || path.join(os.tmpdir(), 'glassbox-hook');
|
|
238
|
+
const file = path.join(dir, String(sessionId).replace(/[^\w.-]/g, '_') + '.json');
|
|
239
|
+
let seen = []; try { seen = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { }
|
|
240
|
+
const key = (f) => f.id + '|' + f.title;
|
|
241
|
+
const fresh = top.filter((f) => !seen.includes(key(f)));
|
|
242
|
+
if (fresh.length) { try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify([...seen, ...fresh.map(key)])); } catch (e) { } }
|
|
243
|
+
return fresh;
|
|
244
|
+
}
|
|
245
|
+
|
|
129
246
|
export function hookResponse(input, opts = {}) {
|
|
130
247
|
const failOn = opts.failOn || 'warn';
|
|
131
248
|
const feedback = !!opts.feedback;
|
|
249
|
+
if (input && input.hook_event_name === 'SessionStart') return opts.context ? sessionStartResponse(input) : { suppressOutput: true };
|
|
250
|
+
// Second Stop after a feedback block: record what the agent said it would do differently, then let it stop.
|
|
251
|
+
if (opts.context && input && input.hook_event_name === 'Stop' && input.stop_hook_active) {
|
|
252
|
+
const file = notesPath(input.cwd);
|
|
253
|
+
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`);
|
|
254
|
+
return { suppressOutput: true };
|
|
255
|
+
}
|
|
132
256
|
if (!input || !input.transcript_path || !fs.existsSync(input.transcript_path)) return { systemMessage: 'Glassbox: no transcript_path in hook input.', suppressOutput: true };
|
|
133
257
|
const s = sessionFromTranscriptPath(input.transcript_path);
|
|
134
258
|
const res = analyse(loadSessionFiles(s), opts.rates);
|
|
@@ -137,12 +261,25 @@ export function hookResponse(input, opts = {}) {
|
|
|
137
261
|
const usd = (v) => v == null ? '' : ' · ' + (v < 1 ? '$' + v.toFixed(3) : '$' + v.toFixed(2));
|
|
138
262
|
const top = res.findings.filter((f) => SEV[f.severity] <= SEV[failOn]);
|
|
139
263
|
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
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
264
|
+
const topGroups = collapseFindings(top);
|
|
265
|
+
const list = topGroups.slice(0, 6).map(({ f, n }) => `${f.severity.toUpperCase()} ${f.id}: ${f.title}${n > 1 ? ` ×${n}` : ''}`);
|
|
266
|
+
const rest = topGroups.slice(6).reduce((s, g) => s + g.n, 0);
|
|
267
|
+
const out = { systemMessage: head + (list.length ? '\n' + list.join('\n') + (rest ? `\n… ${rest} more (glassbox check)` : '') : '\nno findings at or above ' + failOn), suppressOutput: true };
|
|
268
|
+
if (opts.context && input.hook_event_name === 'Stop') {
|
|
269
|
+
const file = notesPath(input.cwd);
|
|
270
|
+
if (top.length) {
|
|
271
|
+
const when = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
272
|
+
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?$/, '');
|
|
273
|
+
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`);
|
|
274
|
+
} else if (file && fs.existsSync(file)) fs.unlinkSync(file); // a clean session: don't keep old advice around
|
|
275
|
+
}
|
|
276
|
+
// Feedback loop: only on Stop, never while continuing from our own block (stop_hook_active), and each
|
|
277
|
+
// finding only once per session. stop_hook_active resets every turn, so without the delivered set a
|
|
278
|
+
// session would be blocked again at the end of every later turn with the same findings.
|
|
279
|
+
const fresh = feedback && input.hook_event_name === 'Stop' && !input.stop_hook_active ? undeliveredFindings(input.session_id || s.id, top, opts.stateDir) : [];
|
|
280
|
+
if (fresh.length) {
|
|
144
281
|
out.decision = 'block';
|
|
145
|
-
out.reason = `Glassbox read this session's flight recorder (${head}).\n\n` + core.reportMarkdown(res.trace,
|
|
282
|
+
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
283
|
}
|
|
147
284
|
return out;
|
|
148
285
|
}
|
|
@@ -158,8 +295,8 @@ export function installHook(opts = {}) {
|
|
|
158
295
|
let settings = {};
|
|
159
296
|
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
297
|
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']) {
|
|
298
|
+
const cmd = `${opts.command || 'npx -y glassbox-trace'} hook${opts.feedback ? ' --feedback' : ''}${opts.context ? ' --context' : ''}${opts.failOn ? ' --fail-on ' + opts.failOn : ''}`;
|
|
299
|
+
for (const ev of opts.events || (opts.context ? ['Stop', 'SessionStart'] : ['Stop'])) {
|
|
163
300
|
const list = Array.isArray(settings.hooks[ev]) ? settings.hooks[ev] : [];
|
|
164
301
|
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
302
|
kept.push({ hooks: [{ type: 'command', command: cmd, timeout: 60 }] });
|
|
@@ -187,7 +324,7 @@ export function parseArgs(argv) {
|
|
|
187
324
|
const args = { _: [], flags: {} };
|
|
188
325
|
for (let i = 0; i < argv.length; i++) {
|
|
189
326
|
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; }
|
|
327
|
+
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', 'legend'].includes(k)) args.flags[k] = argv[++i]; else args.flags[k] = true; }
|
|
191
328
|
else args._.push(a);
|
|
192
329
|
}
|
|
193
330
|
return args;
|
|
@@ -204,17 +341,26 @@ export const HELP = `glassbox — other tools show you what happened in a Claude
|
|
|
204
341
|
print findings; exit 1 when any finding is at/above --fail-on (default: error), 2 on a usage error
|
|
205
342
|
--format md is written to be pasted back to the agent: evidence + advice per finding
|
|
206
343
|
--redact blanks prompt text, tool inputs and result text so the report can be shared
|
|
344
|
+
glassbox check --all [--since 1h] [--project PATH]
|
|
345
|
+
one line per session (all, or written in the last 30m/1h/2d); exit 1 if any fails
|
|
346
|
+
glassbox check --redact --legend FILE
|
|
347
|
+
keep the shape of file use in redacted output: every path becomes a key (file:1a2b3c4d)
|
|
348
|
+
and FILE gets the key→path map — it stays with you and never goes with the report
|
|
349
|
+
glassbox reveal FILE --legend LEGEND
|
|
350
|
+
print FILE (a report, JSON, any text) with the keys turned back into paths
|
|
207
351
|
glassbox compare A B [--format text|json|md] [--out FILE.html] [--label-a NAME --label-b NAME]
|
|
208
352
|
same task, two sessions: what changed in time, tokens, cost, tools and findings
|
|
209
353
|
glassbox watch [ID|FILE] [--port N] [--no-open]
|
|
210
354
|
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]
|
|
355
|
+
glassbox hook install [--feedback] [--context] [--fail-on warn]
|
|
212
356
|
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
|
|
357
|
+
--feedback also hands the findings back to the agent once, so it can learn from them;
|
|
358
|
+
--context keeps them in <project>/.glassbox/last-session.md and gives them to the next session
|
|
214
359
|
glassbox hook uninstall remove it (a .glassbox-backup of settings.json is kept)
|
|
215
360
|
glassbox hook (what Claude Code runs: reads the hook JSON on stdin, replies on stdout)
|
|
216
361
|
|
|
217
362
|
Options --home DIR use DIR instead of ~/.claude (or set GLASSBOX_HOME)
|
|
363
|
+
--rates FILE a JSON rate card for check, compare and hook (or set GLASSBOX_RATES)
|
|
218
364
|
--version --help
|
|
219
365
|
Browser set GLASSBOX_BROWSER to a command to open HTML files with
|
|
220
366
|
|
|
@@ -236,7 +382,7 @@ export function compareReport(A, B, opts = {}) {
|
|
|
236
382
|
lines.push(' ' + 'metric'.padEnd(22) + la.padStart(w) + ' ' + lb.padStart(w) + ' change');
|
|
237
383
|
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
384
|
lines.push('');
|
|
239
|
-
const fl = (list, label) => { if (!list.length) return; lines.push(` only in ${label}:`); for (const
|
|
385
|
+
const fl = (list, label) => { if (!list.length) return; lines.push(` only in ${label}:`); for (const g of collapseFindings(list)) lines.push(' ' + findingLine(g)); };
|
|
240
386
|
fl(c.findings.onlyA, la); fl(c.findings.onlyB, lb);
|
|
241
387
|
if (c.findings.both.length) lines.push(` in both: ${c.findings.both.map((f) => f.id).join(', ')}`);
|
|
242
388
|
if (!c.findings.onlyA.length && !c.findings.onlyB.length && !c.findings.both.length) lines.push(' no findings in either session');
|
|
@@ -252,15 +398,46 @@ export async function main(argv, io = {}) {
|
|
|
252
398
|
if (args.flags.help || cmd === 'help') { out(HELP); return 0; }
|
|
253
399
|
if (args.flags.version) { out(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version); return 0; }
|
|
254
400
|
try {
|
|
401
|
+
const rates = loadRates(args.flags.rates || (io.env || process.env).GLASSBOX_RATES);
|
|
255
402
|
if (cmd === 'list') {
|
|
256
403
|
const list = findSessions({ home, last: args.flags.last ? +args.flags.last : 20, grep: args.flags.grep, project: args.flags.project });
|
|
257
404
|
if (!list.length) { out(noSessionsMessage(home)); return 0; }
|
|
258
405
|
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
406
|
return 0;
|
|
260
407
|
}
|
|
408
|
+
const legendFile = args.flags.legend ? String(args.flags.legend) : null;
|
|
409
|
+
if (legendFile && !args.flags.redact && cmd === 'check') throw new Error('--legend only makes sense with --redact (without --redact the paths are in the output anyway)');
|
|
410
|
+
const legend = legendFile ? Legend.load(legendFile) : null;
|
|
411
|
+
const saveLegend = () => { if (legend && legend.dirty) err(`legend: ${legend.save(legendFile)} (${Object.keys(legend.files).length} files) — keep it; do not send it with the report`); };
|
|
412
|
+
if (cmd === 'reveal') {
|
|
413
|
+
if (!args._[1]) throw new Error('reveal needs a file: glassbox reveal report.md --legend audit.legend.json');
|
|
414
|
+
if (!legendFile || !fs.existsSync(legendFile)) throw new Error('reveal needs --legend FILE (the legend written by check --redact --legend)');
|
|
415
|
+
const r = legend.reveal(fs.readFileSync(args._[1], 'utf8'));
|
|
416
|
+
out(r.text.replace(/\n$/, ''));
|
|
417
|
+
if (r.unknown) err(`reveal: ${r.unknown} key${r.unknown === 1 ? '' : 's'} not in this legend (left as they are)`);
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
if (cmd === 'check' && args.flags.all) {
|
|
421
|
+
// Every session (optionally only those written in the last --since), one line each; exit 1 if any fails.
|
|
422
|
+
const since = args.flags.since ? parseSince(args.flags.since) : null;
|
|
423
|
+
const list = findSessions({ home, project: args.flags.project }).filter((s) => since == null || s.mtime >= Date.now() - since);
|
|
424
|
+
const fmtOut = outputFormat(args.flags);
|
|
425
|
+
const reps = list.map((s) => ({ s, rep: checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend }) }));
|
|
426
|
+
saveLegend();
|
|
427
|
+
const failed = reps.filter((r) => r.rep.failed).length;
|
|
428
|
+
if (fmtOut === 'json') out(JSON.stringify({ glassbox: core.VERSION, schema: 2, redacted: !!args.flags.redact, legend: !!legend, failOn: args.flags['fail-on'] || 'error', failed: failed > 0, sessions: reps.map((r) => r.rep.json) }, null, 2));
|
|
429
|
+
else if (fmtOut === 'md') out(reps.map((r) => r.rep.markdown).join('\n---\n\n'));
|
|
430
|
+
else {
|
|
431
|
+
if (!reps.length) out(since != null ? `No sessions written in the last ${args.flags.since}.` : noSessionsMessage(home));
|
|
432
|
+
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)}`); }
|
|
433
|
+
if (reps.length) out(`\n${failed} of ${reps.length} session${reps.length === 1 ? '' : 's'} at or above "${args.flags['fail-on'] || 'error'}"`);
|
|
434
|
+
}
|
|
435
|
+
return failed ? 1 : 0;
|
|
436
|
+
}
|
|
261
437
|
if (cmd === 'check') {
|
|
262
438
|
const s = resolveTarget(args._[1], { home });
|
|
263
|
-
const res = checkReport(analyse(loadSessionFiles(s)), { failOn: args.flags['fail-on'], redact: !!args.flags.redact });
|
|
439
|
+
const res = checkReport(analyse(loadSessionFiles(s), rates), { failOn: args.flags['fail-on'], redact: !!args.flags.redact, legend });
|
|
440
|
+
saveLegend();
|
|
264
441
|
const fmtOut = outputFormat(args.flags);
|
|
265
442
|
if (fmtOut === 'json') out(JSON.stringify(res.json, null, 2)); else if (fmtOut === 'md') out(res.markdown); else out(res.text);
|
|
266
443
|
return res.failed ? 1 : 0;
|
|
@@ -269,7 +446,7 @@ export async function main(argv, io = {}) {
|
|
|
269
446
|
if (!args._[1] || !args._[2]) throw new Error('compare needs two sessions: glassbox compare A B');
|
|
270
447
|
const sa = resolveTarget(args._[1], { home }), sb = resolveTarget(args._[2], { home });
|
|
271
448
|
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'] });
|
|
449
|
+
const rep = compareReport(analyse(fa, rates), analyse(fb, rates), { labelA: args.flags['label-a'], labelB: args.flags['label-b'] });
|
|
273
450
|
if (args.flags.out) {
|
|
274
451
|
const html = embed({ files: fa, compare: fb }, { template: io.template });
|
|
275
452
|
const outFile = path.resolve(args.flags.out); fs.writeFileSync(outFile, html);
|
|
@@ -281,7 +458,7 @@ export async function main(argv, io = {}) {
|
|
|
281
458
|
if (fmtOut === 'json') out(JSON.stringify(rep.json, null, 2)); else if (fmtOut === 'md') out(rep.markdown); else out(rep.text);
|
|
282
459
|
return 0;
|
|
283
460
|
}
|
|
284
|
-
if (cmd === 'open') {
|
|
461
|
+
if (cmd === 'open' && !args.flags.watch) {
|
|
285
462
|
const s = resolveTarget(args._[1], { home });
|
|
286
463
|
const files = loadSessionFiles(s);
|
|
287
464
|
const html = embed(files, { template: io.template });
|
|
@@ -303,10 +480,13 @@ export async function main(argv, io = {}) {
|
|
|
303
480
|
}
|
|
304
481
|
if (cmd === 'hook') {
|
|
305
482
|
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; }
|
|
483
|
+
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
484
|
if (sub === 'uninstall') { const r = uninstallHook({ home }); out(`Removed ${r.removed} Glassbox hook${r.removed === 1 ? '' : 's'} from ${r.file}`); return 0; }
|
|
308
|
-
|
|
309
|
-
|
|
485
|
+
// A hook must never fail loudly: exit 2 from a Stop hook blocks Claude with the error as the reason.
|
|
486
|
+
let reply;
|
|
487
|
+
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 }); }
|
|
488
|
+
catch (e) { reply = { systemMessage: 'Glassbox: ' + e.message, suppressOutput: true }; }
|
|
489
|
+
out(JSON.stringify(reply));
|
|
310
490
|
return 0;
|
|
311
491
|
}
|
|
312
492
|
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;
|
|
27
|
-
|
|
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).
|