glassbox-trace 0.10.0 → 0.10.1
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 +2 -1
- package/dist/glassbox.html +55 -6
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/collect.mjs +6 -2
- package/src/trace-core.js +53 -4
- package/src/viewer.html +2 -2
package/README.md
CHANGED
|
@@ -181,6 +181,7 @@ Six kinds of claim (tests pass, shipped, verified, fixed, nothing changed, file
|
|
|
181
181
|
| `low-cache-hit` | < 50% of input served from cache over ≥ 5 requests |
|
|
182
182
|
| `slow-model` | ≥ 60 s for a response under 1,500 tokens (latency / rate limit / stall) |
|
|
183
183
|
| `long-generation` | ≥ 60 s for a big response streamed below 15 tok/s |
|
|
184
|
+
| `missing-final-usage` | subagent responses whose final usage record never reached the transcript — the cost shown is a floor, with an estimate of the gap (info; a Claude Code bug, [#93620](https://github.com/anthropics/claude-code/issues/93620), not the agent's doing) |
|
|
184
185
|
| `slow-tool` | a tool call ≥ 60 s (warn at 5 min); one line per tool when repeated; blocking `TaskOutput` waits roll up per background task as info; time spent on the human (questions, permission prompts) is excluded |
|
|
185
186
|
| `permission-denied` | tool calls the human rejected, a permission rule or auto mode blocked, or an interrupt stopped (info) |
|
|
186
187
|
| `max-tokens`, `api-error`, `hook-error`, `compaction`, `thinking-heavy`, `subagent-share`, `long-turn` | what they say (`long-turn`: ≥ 30 tool calls after one prompt in the main conversation) |
|
|
@@ -190,7 +191,7 @@ Identical findings print once with a `×N` count in `check`. All mechanical, no
|
|
|
190
191
|
|
|
191
192
|
## Token and cost accounting
|
|
192
193
|
|
|
193
|
-
- Usage is counted once per API request (assistant records sharing a `requestId`), not once per record. Subagent transcripts rewrite `output_tokens` while a response streams, so the largest value across the request's records is used.
|
|
194
|
+
- Usage is counted once per API request (assistant records sharing a `requestId`), not once per record. Subagent transcripts rewrite `output_tokens` while a response streams, so the largest value across the request's records is used. Since Claude Code ~2.1.278 many subagent responses never get their final record written at all (every record has `stop_reason: null` and a partial count — [anthropics/claude-code#93620](https://github.com/anthropics/claude-code/issues/93620)); those output tokens are not on disk anywhere, so Glassbox counts the responses, shows the cost as **≥ $X** (a floor) and estimates the gap from the median of the session's own complete subagent tool-use responses (needs five). Compare with Claude Code's own total (`/cost`, the status line) before budgeting from a floor.
|
|
194
195
|
- Context size = uncached input + cache read + cache write — the prompt the model actually saw.
|
|
195
196
|
- Thinking tokens are part of output tokens; shown as a share, never added twice.
|
|
196
197
|
- Cost is estimated from an editable rate card (defaults from the Claude pricing page, 2026-09-05; prefix-matched so dated model ids resolve). A `stream-json` `result` record with `total_cost_usd` overrides the estimate. Unknown models show "—" rather than a wrong number.
|
package/dist/glassbox.html
CHANGED
|
@@ -408,7 +408,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
408
408
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
409
409
|
'use strict';
|
|
410
410
|
|
|
411
|
-
const VERSION = '0.10.
|
|
411
|
+
const VERSION = '0.10.1';
|
|
412
412
|
|
|
413
413
|
// ---------------------------------------------------------------------------
|
|
414
414
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|
|
@@ -816,7 +816,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
816
816
|
let turn = currentTurn(a.id);
|
|
817
817
|
if (!turn) turn = ensureTurn(rec, 'implicit', '');
|
|
818
818
|
req = {
|
|
819
|
-
id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t,
|
|
819
|
+
id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t, version: rec.version || null,
|
|
820
820
|
usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
|
|
821
821
|
isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
|
|
822
822
|
};
|
|
@@ -831,6 +831,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
831
831
|
maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
|
|
832
832
|
req.records++;
|
|
833
833
|
if (msg.stop_reason) req.stopReason = msg.stop_reason;
|
|
834
|
+
if (!req.version && rec.version) req.version = rec.version;
|
|
834
835
|
if (rec.isApiErrorMessage) req.isApiError = true;
|
|
835
836
|
const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
|
|
836
837
|
const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
|
|
@@ -868,6 +869,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
868
869
|
req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
|
|
869
870
|
addUsage(turns[req.turnIndex].usage, req.usage);
|
|
870
871
|
addUsage(agents.get(req.agent).usage, req.usage);
|
|
872
|
+
// A response whose records all have stop_reason null never got its final record written, so its
|
|
873
|
+
// output_tokens is a partial running count. Only subagent transcripts are judged: in the main
|
|
874
|
+
// transcript that shape is the live, unfinished last response (anthropics/claude-code#93620).
|
|
875
|
+
req.usageFinal = !(req.agent !== 'main' && !req.stopReason && !req.isApiError && req.model !== '<synthetic>' && !isEmptyUsage(req.usage));
|
|
871
876
|
}
|
|
872
877
|
for (const turn of turns) if (turn.end == null) turn.end = meta.end;
|
|
873
878
|
// Response timing. Assistant record timestamps are written when a block finishes streaming,
|
|
@@ -927,12 +932,28 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
927
932
|
toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
|
|
928
933
|
modelTimeMs: agents.get('main').modelTimeMs || 0,
|
|
929
934
|
cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
|
|
930
|
-
attachments,
|
|
935
|
+
attachments, usagePartial: usagePartial(requests),
|
|
931
936
|
};
|
|
932
937
|
|
|
933
938
|
return { version: VERSION, meta, requests, toolCalls, turns, agents: agentList, events, problems, totals };
|
|
934
939
|
}
|
|
935
940
|
|
|
941
|
+
// Subagent responses with no final usage record: how many, what was seen, and an estimate of the gap from the
|
|
942
|
+
// session's own complete subagent tool_use responses (median; needs at least five to go on).
|
|
943
|
+
function usagePartial(requests) {
|
|
944
|
+
const sub = requests.filter((r) => r.agent !== 'main' && r.model !== '<synthetic>' && !r.isApiError && !isEmptyUsage(r.usage));
|
|
945
|
+
const partial = sub.filter((r) => r.usageFinal === false);
|
|
946
|
+
const out = { responses: partial.length, of: sub.length, outputSeen: partial.reduce((n, r) => n + r.usage.output, 0), versions: Array.from(new Set(partial.map((r) => r.version).filter(Boolean))).sort(), agents: new Set(partial.map((r) => r.agent)).size, estimate: null };
|
|
947
|
+
if (!partial.length) return out;
|
|
948
|
+
const basis = sub.filter((r) => r.usageFinal && r.stopReason === 'tool_use').map((r) => r.usage.output).sort((a, b) => a - b);
|
|
949
|
+
if (basis.length >= 5) {
|
|
950
|
+
const mid = basis.length >> 1;
|
|
951
|
+
const median = basis.length % 2 ? basis[mid] : (basis[mid - 1] + basis[mid]) / 2;
|
|
952
|
+
out.estimate = { basis: basis.length, median, missingOutput: partial.reduce((n, r) => n + Math.max(0, median - r.usage.output), 0) };
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
|
|
936
957
|
function pushModel(meta, m) { if (m && !meta.models.includes(m)) meta.models.push(m); }
|
|
937
958
|
|
|
938
959
|
function mkOrphanResult(tr, rec, agentId, text, turnIndex) {
|
|
@@ -1218,6 +1239,21 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1218
1239
|
if (e.kind === 'compact') push({ id: 'compaction', severity: 'info', title: 'Context was compacted', detail: e.detail, evidence: { at: e.at } });
|
|
1219
1240
|
}
|
|
1220
1241
|
|
|
1242
|
+
// missing-final-usage: the transcript under-counts subagent output (Claude Code bug, not the agent's doing)
|
|
1243
|
+
const P = trace.totals.usagePartial;
|
|
1244
|
+
if (P && P.responses > 0) {
|
|
1245
|
+
const c = estimateCost(trace, o.rates);
|
|
1246
|
+
const est = P.estimate
|
|
1247
|
+
? ` From the median of ${P.estimate.basis} complete subagent tool-use responses in this session (${fmtInt(P.estimate.median)} output tokens each), about ${fmtInt(P.estimate.missingOutput)} output tokens${c.missing != null ? ` (~${usd(c.missing)})` : ''} are not counted.`
|
|
1248
|
+
: ' Too few complete subagent responses in this session (fewer than 5) to estimate the gap.';
|
|
1249
|
+
push({
|
|
1250
|
+
id: 'missing-final-usage', severity: 'info',
|
|
1251
|
+
title: `Cost is a floor: ${P.responses} of ${P.of} subagent responses never recorded their final output tokens`,
|
|
1252
|
+
detail: `These responses were written to the subagent transcript with stop_reason null on every record and only a partial running output_tokens count (${fmtInt(P.outputSeen)} tokens across all of them), so their real output is not on disk.${est} Claude Code ${P.versions.join(', ') || '(version not recorded)'}; known transcript issue anthropics/claude-code#93620.`,
|
|
1253
|
+
evidence: { requestIds: trace.requests.filter((r) => r.usageFinal === false).slice(0, 20).map((r) => r.id) }, metric: P.responses,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1221
1257
|
// thinking-heavy
|
|
1222
1258
|
const u = trace.totals.usage;
|
|
1223
1259
|
if (u.output >= o.thinkingHeavyMinOutput && u.thinking / u.output > o.thinkingHeavyShare) push({
|
|
@@ -1301,7 +1337,17 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1301
1337
|
byAgent[r.agent] = (byAgent[r.agent] || 0) + c;
|
|
1302
1338
|
}
|
|
1303
1339
|
const complete = known === trace.requests.length;
|
|
1340
|
+
// Floor: some subagent responses never recorded their final output (see usagePartial). A run-reported cost
|
|
1341
|
+
// is the real bill and stays as it is.
|
|
1342
|
+
const P = (trace.totals && trace.totals.usagePartial) || { responses: 0, estimate: null };
|
|
1343
|
+
const lowerBound = P.responses > 0 && trace.meta.reportedCost == null;
|
|
1344
|
+
let missing = null;
|
|
1345
|
+
if (lowerBound && P.estimate) {
|
|
1346
|
+
missing = 0;
|
|
1347
|
+
for (const r of trace.requests) { if (r.usageFinal !== false) continue; const rate = rateFor(r.model, rates); if (rate) missing += Math.max(0, P.estimate.median - r.usage.output) * rate.out / 1e6; }
|
|
1348
|
+
}
|
|
1304
1349
|
return {
|
|
1350
|
+
lowerBound, partialResponses: P.responses, missing,
|
|
1305
1351
|
total: known ? total : null, complete, byRequest, byModel, byAgent, unknownModels: Array.from(unknownModels),
|
|
1306
1352
|
reported: trace.meta.reportedCost, source: trace.meta.reportedCost != null ? 'reported' : (known ? 'estimated' : 'unknown'),
|
|
1307
1353
|
};
|
|
@@ -1329,6 +1375,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1329
1375
|
'compaction': 'Context was compacted; state that matters later (decisions, file paths, test status) should be written to a file or note before it is summarised away.',
|
|
1330
1376
|
'thinking-heavy': 'Most output tokens were thinking; fine for hard problems, but for routine tool loops a lower effort setting is cheaper and just as good.',
|
|
1331
1377
|
'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
|
|
1378
|
+
'missing-final-usage': "Not the agent's doing: Claude Code did not write these subagent responses' final usage to disk. Treat the transcript cost as a floor and check it against Claude Code's own total (/cost or the status line) before budgeting from it.",
|
|
1332
1379
|
'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
|
|
1333
1380
|
'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
|
|
1334
1381
|
'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
|
|
@@ -1435,7 +1482,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1435
1482
|
out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
|
|
1436
1483
|
out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
|
|
1437
1484
|
out.push(`- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turn${T.turns === 1 ? '' : 's'} · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
|
|
1438
|
-
out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}
|
|
1485
|
+
out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${cost && cost.lowerBound ? 'at least ' : ''}${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`);
|
|
1486
|
+
if (cost && cost.lowerBound) out.push(`- cost is a floor: ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} never recorded their final output tokens (anthropics/claude-code#93620)${cost.missing != null ? `; estimated ~${fmtInt(T.usagePartial.estimate.missingOutput)} output tokens, ~${usd(cost.missing)} more` : ''}.`);
|
|
1487
|
+
out.push('');
|
|
1439
1488
|
if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
|
|
1440
1489
|
else {
|
|
1441
1490
|
out.push(`## Findings (${findings.length})`, '');
|
|
@@ -1672,8 +1721,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1672
1721
|
const t = S.trace, T = t.totals, u = T.usage, cost = S.cost;
|
|
1673
1722
|
const ctx = u.input + u.cacheRead + u.cacheWrite;
|
|
1674
1723
|
const errs = S.findings.filter((f) => f.severity === 'error').length, warns = S.findings.filter((f) => f.severity === 'warn').length, infos = S.findings.filter((f) => f.severity === 'info').length;
|
|
1675
|
-
const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? fmtUSD(cost.total) : '—';
|
|
1676
|
-
const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
|
|
1724
|
+
const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? (cost.lowerBound ? '≥ ' : '') + fmtUSD(cost.total) : '—';
|
|
1725
|
+
const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.lowerBound ? `a floor — ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} missing final usage${cost.missing != null ? ` · ~${fmtUSD(cost.missing)} more` : ''}` : cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
|
|
1677
1726
|
const tiles = [
|
|
1678
1727
|
['Wall time', fmtDur(T.wallMs), T.humanIdleMs ? `${fmtDur(T.activeMs)} active · ${fmtDur(T.humanIdleMs)} waiting on you` : (t.meta.hasTimestamps ? 'no human idle' : 'no timestamps in file')],
|
|
1679
1728
|
['Turns', fmtInt(T.turns), `${fmtInt(T.requests)} API requests`],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glassbox-trace",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
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 fleet view, a secrets scanner for transcripts (fence), a CLAUDE.md compliance report (adhere), a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
|
|
5
5
|
"main": "src/trace-core.js",
|
|
6
6
|
"scripts": {
|
package/src/cli.mjs
CHANGED
|
@@ -186,7 +186,7 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
|
|
|
186
186
|
const redact = !!opts.redact;
|
|
187
187
|
const legend = opts.legend || null;
|
|
188
188
|
const keyed = (p) => legend ? legend.keyFor(p) : p;
|
|
189
|
-
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, start: m.start != null ? new Date(m.start).toISOString() : null, end: m.end != null ? new Date(m.end).toISOString() : null };
|
|
189
|
+
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, costLowerBound: !!cost.lowerBound, usagePartial: { responses: T.usagePartial.responses, of: T.usagePartial.of, outputSeen: T.usagePartial.outputSeen, versions: T.usagePartial.versions, estimatedMissingOutput: T.usagePartial.estimate ? T.usagePartial.estimate.missingOutput : null, estimatedMissingCost: cost.missing }, start: m.start != null ? new Date(m.start).toISOString() : null, end: m.end != null ? new Date(m.end).toISOString() : null };
|
|
190
190
|
// Per-file use, keyed when a legend is given; with --redact and no legend the paths would leak, so it is omitted.
|
|
191
191
|
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 }));
|
|
192
192
|
if (claims) summary.claims = claims.summary; // said vs did, in one line of numbers (additive; schema stays 2)
|
|
@@ -204,7 +204,7 @@ export function checkReport({ trace, findings, cost, claims }, opts = {}) {
|
|
|
204
204
|
const lines = [];
|
|
205
205
|
lines.push(`Glassbox · ${m.sessionId ? m.sessionId.slice(0, 8) : 'session'} · ${m.models.join(', ') || 'unknown model'}`);
|
|
206
206
|
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' : ''})`);
|
|
207
|
-
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)}`);
|
|
207
|
+
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 ${summary.costLowerBound ? '≥ ' : ''}${usd(summary.cost)}${summary.costLowerBound ? ' (floor)' : ''}`);
|
|
208
208
|
lines.push('');
|
|
209
209
|
if (!findings.length) lines.push(' no findings');
|
|
210
210
|
for (const g of collapseFindings(findings)) lines.push(' ' + findingLine(g));
|
package/src/collect.mjs
CHANGED
|
@@ -101,6 +101,9 @@ export function collect(sources, opts = {}) {
|
|
|
101
101
|
findings: sum(sessions, (s) => s.findings.length), errors: sum(sessions, (s) => s.findings.filter((f) => f.severity === 'error').length), warns: sum(sessions, (s) => s.findings.filter((f) => f.severity === 'warn').length),
|
|
102
102
|
failed: sessions.filter((s) => s.failed).length,
|
|
103
103
|
estimated: sessions.some((s) => s.summary.costSource !== 'reported'),
|
|
104
|
+
// Sessions whose transcript cost is a floor (subagent responses with no final usage, anthropics/claude-code#93620).
|
|
105
|
+
lowerBoundSessions: sessions.filter((s) => s.summary.costLowerBound).length,
|
|
106
|
+
partialResponses: sum(sessions, (s) => s.summary.usagePartial && s.summary.usagePartial.responses),
|
|
104
107
|
};
|
|
105
108
|
const top = topN(opts.top || 5).map((s) => ({ source: s.source, session: s.summary.session, cost: s.summary.cost || 0, share: cost ? (s.summary.cost || 0) / cost : 0, wallMs: s.summary.wallMs, turns: s.summary.turns, requests: s.summary.requests, findings: s.findings.length, worst: s.findings.slice().sort((a, b) => SEV[a.severity] - SEV[b.severity])[0] || null, model: s.summary.model }));
|
|
106
109
|
return {
|
|
@@ -121,7 +124,8 @@ export function collectMarkdown(r) {
|
|
|
121
124
|
L.push(`_${T.sources} source${T.sources === 1 ? '' : 's'} · ${T.sessions} session${T.sessions === 1 ? '' : 's'}${r.since ? ` since ${r.since.slice(0, 10)}` : ''} · generated ${r.generated.slice(0, 16).replace('T', ' ')} UTC by Glassbox ${r.glassbox}. Counts, keys and findings only — no session text._`, '');
|
|
122
125
|
L.push(`## Totals`, '');
|
|
123
126
|
L.push(`| | |`, `|---|---|`);
|
|
124
|
-
L.push(`| Cost${T.estimated ? ' (estimated from the rate card)' : ''} | **${usd(T.cost)}** |`);
|
|
127
|
+
L.push(`| Cost${T.estimated ? ' (estimated from the rate card)' : ''} | ${T.lowerBoundSessions ? 'at least ' : ''}**${usd(T.cost)}** |`);
|
|
128
|
+
if (T.lowerBoundSessions) L.push(`| Cost floor | ${T.lowerBoundSessions} session${T.lowerBoundSessions === 1 ? '' : 's'} a floor — ${fi(T.partialResponses)} subagent response${T.partialResponses === 1 ? '' : 's'} never recorded final usage (anthropics/claude-code#93620) |`);
|
|
125
129
|
L.push(`| Wall / active time | ${fmt(T.wallMs)} / ${fmt(T.activeMs)} |`);
|
|
126
130
|
L.push(`| Requests / tool calls / tool errors | ${fi(T.requests)} / ${fi(T.toolCalls)} / ${fi(T.toolErrors)} |`);
|
|
127
131
|
L.push(`| Findings (error / warn) | ${fi(T.findings)} (${fi(T.errors)} / ${fi(T.warns)}) |`);
|
|
@@ -159,7 +163,7 @@ export function collectText(r) {
|
|
|
159
163
|
const T = r.totals, fmt = core.fmtDur, fi = core.fmtInt;
|
|
160
164
|
const L = [];
|
|
161
165
|
L.push(`Glassbox fleet · ${T.sources} source${T.sources === 1 ? '' : 's'} · ${T.sessions} session${T.sessions === 1 ? '' : 's'}${r.since ? ` since ${r.since.slice(0, 10)}` : ''}`);
|
|
162
|
-
L.push(` cost ${usd(T.cost)}${T.estimated ? ' (est.)' : ''} · wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${fi(T.requests)} requests · ${fi(T.toolCalls)} tool calls (${fi(T.toolErrors)} failed)`);
|
|
166
|
+
L.push(` cost ${T.lowerBoundSessions ? '≥ ' : ''}${usd(T.cost)}${T.estimated ? ' (est.)' : ''}${T.lowerBoundSessions ? ` (${T.lowerBoundSessions} session${T.lowerBoundSessions === 1 ? '' : 's'} a floor)` : ''} · wall ${fmt(T.wallMs)} (active ${fmt(T.activeMs)}) · ${fi(T.requests)} requests · ${fi(T.toolCalls)} tool calls (${fi(T.toolErrors)} failed)`);
|
|
163
167
|
L.push(` ${fi(T.findings)} findings (${fi(T.errors)} error, ${fi(T.warns)} warn) · ${fi(T.failed)} session${T.failed === 1 ? '' : 's'} failing`);
|
|
164
168
|
if (T.sessions) L.push(` ${r.concentration.halfOfSpendSessions} session${r.concentration.halfOfSpendSessions === 1 ? '' : 's'} = half the spend; top ${r.concentration.top.length} = ${pct(r.concentration.topShare)}`);
|
|
165
169
|
L.push('');
|
package/src/trace-core.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
|
-
const VERSION = '0.10.
|
|
12
|
+
const VERSION = '0.10.1';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Rate card (USD per million tokens). Prefix-matched against model ids so dated
|
|
@@ -417,7 +417,7 @@
|
|
|
417
417
|
let turn = currentTurn(a.id);
|
|
418
418
|
if (!turn) turn = ensureTurn(rec, 'implicit', '');
|
|
419
419
|
req = {
|
|
420
|
-
id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t,
|
|
420
|
+
id: key, agent: a.id, model: msg.model || null, stopReason: null, start: t, end: t, version: rec.version || null,
|
|
421
421
|
usage: emptyUsage(), blocks: [], turnIndex: turn.index, contextTokens: 0, records: 0,
|
|
422
422
|
isApiError: !!rec.isApiErrorMessage, uuid: rec.uuid || null,
|
|
423
423
|
};
|
|
@@ -432,6 +432,7 @@
|
|
|
432
432
|
maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
|
|
433
433
|
req.records++;
|
|
434
434
|
if (msg.stop_reason) req.stopReason = msg.stop_reason;
|
|
435
|
+
if (!req.version && rec.version) req.version = rec.version;
|
|
435
436
|
if (rec.isApiErrorMessage) req.isApiError = true;
|
|
436
437
|
const content = Array.isArray(msg.content) ? msg.content : (msg.content != null ? [{ type: 'text', text: String(msg.content) }] : []);
|
|
437
438
|
const blockIndex = typeof rec.apiBlockIndex === 'number' ? rec.apiBlockIndex : req.blocks.length;
|
|
@@ -469,6 +470,10 @@
|
|
|
469
470
|
req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
|
|
470
471
|
addUsage(turns[req.turnIndex].usage, req.usage);
|
|
471
472
|
addUsage(agents.get(req.agent).usage, req.usage);
|
|
473
|
+
// A response whose records all have stop_reason null never got its final record written, so its
|
|
474
|
+
// output_tokens is a partial running count. Only subagent transcripts are judged: in the main
|
|
475
|
+
// transcript that shape is the live, unfinished last response (anthropics/claude-code#93620).
|
|
476
|
+
req.usageFinal = !(req.agent !== 'main' && !req.stopReason && !req.isApiError && req.model !== '<synthetic>' && !isEmptyUsage(req.usage));
|
|
472
477
|
}
|
|
473
478
|
for (const turn of turns) if (turn.end == null) turn.end = meta.end;
|
|
474
479
|
// Response timing. Assistant record timestamps are written when a block finishes streaming,
|
|
@@ -528,12 +533,28 @@
|
|
|
528
533
|
toolTimeMs: toolCalls.filter((c) => c.agent === 'main').reduce((s, c) => s + (c.durationMs || 0), 0),
|
|
529
534
|
modelTimeMs: agents.get('main').modelTimeMs || 0,
|
|
530
535
|
cacheHitRatio: (usage.cacheRead + usage.cacheWrite + usage.input) ? usage.cacheRead / (usage.cacheRead + usage.cacheWrite + usage.input) : null,
|
|
531
|
-
attachments,
|
|
536
|
+
attachments, usagePartial: usagePartial(requests),
|
|
532
537
|
};
|
|
533
538
|
|
|
534
539
|
return { version: VERSION, meta, requests, toolCalls, turns, agents: agentList, events, problems, totals };
|
|
535
540
|
}
|
|
536
541
|
|
|
542
|
+
// Subagent responses with no final usage record: how many, what was seen, and an estimate of the gap from the
|
|
543
|
+
// session's own complete subagent tool_use responses (median; needs at least five to go on).
|
|
544
|
+
function usagePartial(requests) {
|
|
545
|
+
const sub = requests.filter((r) => r.agent !== 'main' && r.model !== '<synthetic>' && !r.isApiError && !isEmptyUsage(r.usage));
|
|
546
|
+
const partial = sub.filter((r) => r.usageFinal === false);
|
|
547
|
+
const out = { responses: partial.length, of: sub.length, outputSeen: partial.reduce((n, r) => n + r.usage.output, 0), versions: Array.from(new Set(partial.map((r) => r.version).filter(Boolean))).sort(), agents: new Set(partial.map((r) => r.agent)).size, estimate: null };
|
|
548
|
+
if (!partial.length) return out;
|
|
549
|
+
const basis = sub.filter((r) => r.usageFinal && r.stopReason === 'tool_use').map((r) => r.usage.output).sort((a, b) => a - b);
|
|
550
|
+
if (basis.length >= 5) {
|
|
551
|
+
const mid = basis.length >> 1;
|
|
552
|
+
const median = basis.length % 2 ? basis[mid] : (basis[mid - 1] + basis[mid]) / 2;
|
|
553
|
+
out.estimate = { basis: basis.length, median, missingOutput: partial.reduce((n, r) => n + Math.max(0, median - r.usage.output), 0) };
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
537
558
|
function pushModel(meta, m) { if (m && !meta.models.includes(m)) meta.models.push(m); }
|
|
538
559
|
|
|
539
560
|
function mkOrphanResult(tr, rec, agentId, text, turnIndex) {
|
|
@@ -819,6 +840,21 @@
|
|
|
819
840
|
if (e.kind === 'compact') push({ id: 'compaction', severity: 'info', title: 'Context was compacted', detail: e.detail, evidence: { at: e.at } });
|
|
820
841
|
}
|
|
821
842
|
|
|
843
|
+
// missing-final-usage: the transcript under-counts subagent output (Claude Code bug, not the agent's doing)
|
|
844
|
+
const P = trace.totals.usagePartial;
|
|
845
|
+
if (P && P.responses > 0) {
|
|
846
|
+
const c = estimateCost(trace, o.rates);
|
|
847
|
+
const est = P.estimate
|
|
848
|
+
? ` From the median of ${P.estimate.basis} complete subagent tool-use responses in this session (${fmtInt(P.estimate.median)} output tokens each), about ${fmtInt(P.estimate.missingOutput)} output tokens${c.missing != null ? ` (~${usd(c.missing)})` : ''} are not counted.`
|
|
849
|
+
: ' Too few complete subagent responses in this session (fewer than 5) to estimate the gap.';
|
|
850
|
+
push({
|
|
851
|
+
id: 'missing-final-usage', severity: 'info',
|
|
852
|
+
title: `Cost is a floor: ${P.responses} of ${P.of} subagent responses never recorded their final output tokens`,
|
|
853
|
+
detail: `These responses were written to the subagent transcript with stop_reason null on every record and only a partial running output_tokens count (${fmtInt(P.outputSeen)} tokens across all of them), so their real output is not on disk.${est} Claude Code ${P.versions.join(', ') || '(version not recorded)'}; known transcript issue anthropics/claude-code#93620.`,
|
|
854
|
+
evidence: { requestIds: trace.requests.filter((r) => r.usageFinal === false).slice(0, 20).map((r) => r.id) }, metric: P.responses,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
822
858
|
// thinking-heavy
|
|
823
859
|
const u = trace.totals.usage;
|
|
824
860
|
if (u.output >= o.thinkingHeavyMinOutput && u.thinking / u.output > o.thinkingHeavyShare) push({
|
|
@@ -902,7 +938,17 @@
|
|
|
902
938
|
byAgent[r.agent] = (byAgent[r.agent] || 0) + c;
|
|
903
939
|
}
|
|
904
940
|
const complete = known === trace.requests.length;
|
|
941
|
+
// Floor: some subagent responses never recorded their final output (see usagePartial). A run-reported cost
|
|
942
|
+
// is the real bill and stays as it is.
|
|
943
|
+
const P = (trace.totals && trace.totals.usagePartial) || { responses: 0, estimate: null };
|
|
944
|
+
const lowerBound = P.responses > 0 && trace.meta.reportedCost == null;
|
|
945
|
+
let missing = null;
|
|
946
|
+
if (lowerBound && P.estimate) {
|
|
947
|
+
missing = 0;
|
|
948
|
+
for (const r of trace.requests) { if (r.usageFinal !== false) continue; const rate = rateFor(r.model, rates); if (rate) missing += Math.max(0, P.estimate.median - r.usage.output) * rate.out / 1e6; }
|
|
949
|
+
}
|
|
905
950
|
return {
|
|
951
|
+
lowerBound, partialResponses: P.responses, missing,
|
|
906
952
|
total: known ? total : null, complete, byRequest, byModel, byAgent, unknownModels: Array.from(unknownModels),
|
|
907
953
|
reported: trace.meta.reportedCost, source: trace.meta.reportedCost != null ? 'reported' : (known ? 'estimated' : 'unknown'),
|
|
908
954
|
};
|
|
@@ -930,6 +976,7 @@
|
|
|
930
976
|
'compaction': 'Context was compacted; state that matters later (decisions, file paths, test status) should be written to a file or note before it is summarised away.',
|
|
931
977
|
'thinking-heavy': 'Most output tokens were thinking; fine for hard problems, but for routine tool loops a lower effort setting is cheaper and just as good.',
|
|
932
978
|
'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
|
|
979
|
+
'missing-final-usage': "Not the agent's doing: Claude Code did not write these subagent responses' final usage to disk. Treat the transcript cost as a floor and check it against Claude Code's own total (/cost or the status line) before budgeting from it.",
|
|
933
980
|
'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
|
|
934
981
|
'permission-denied': 'Before a call the user may not want (installs, network, deleting, publishing), say what it will do and ask; for calls they always allow, suggest a permissions allow rule so the prompt goes away.',
|
|
935
982
|
'duplicate-subagent-read': 'When several subagents need the same file, put the relevant excerpt in their briefs or have one agent summarise it once, instead of every agent reading it into its own context.',
|
|
@@ -1036,7 +1083,9 @@
|
|
|
1036
1083
|
out.push(`# Glassbox report — ${oneLine(R(m.title || opts.title || 'session'), 90)}`, '');
|
|
1037
1084
|
out.push(`- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'}${m.start ? ' · started: ' + new Date(m.start).toISOString() : ''}`);
|
|
1038
1085
|
out.push(`- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turn${T.turns === 1 ? '' : 's'} · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed${T.orphans ? ', ' + T.orphans + ' unanswered' : ''})`);
|
|
1039
|
-
out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}
|
|
1086
|
+
out.push(`- tokens: ${fmtInt(u.input + u.cacheRead + u.cacheWrite)} context served (${pctStr(T.cacheHitRatio)} cached) · peak prompt ${fmtInt(Math.max(0, ...trace.requests.map((r) => r.contextTokens)))} · ${fmtInt(u.output)} output (${fmtInt(u.thinking)} thinking) · est. cost ${cost && cost.lowerBound ? 'at least ' : ''}${usd(total)}${cost && cost.source === 'reported' ? ' (reported)' : ''}`);
|
|
1087
|
+
if (cost && cost.lowerBound) out.push(`- cost is a floor: ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} never recorded their final output tokens (anthropics/claude-code#93620)${cost.missing != null ? `; estimated ~${fmtInt(T.usagePartial.estimate.missingOutput)} output tokens, ~${usd(cost.missing)} more` : ''}.`);
|
|
1088
|
+
out.push('');
|
|
1040
1089
|
if (!findings.length) out.push('## Findings', '', 'Nothing flagged — a clean session, or a very short one.', '');
|
|
1041
1090
|
else {
|
|
1042
1091
|
out.push(`## Findings (${findings.length})`, '');
|
package/src/viewer.html
CHANGED
|
@@ -475,8 +475,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
475
475
|
const t = S.trace, T = t.totals, u = T.usage, cost = S.cost;
|
|
476
476
|
const ctx = u.input + u.cacheRead + u.cacheWrite;
|
|
477
477
|
const errs = S.findings.filter((f) => f.severity === 'error').length, warns = S.findings.filter((f) => f.severity === 'warn').length, infos = S.findings.filter((f) => f.severity === 'info').length;
|
|
478
|
-
const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? fmtUSD(cost.total) : '—';
|
|
479
|
-
const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
|
|
478
|
+
const costV = cost.reported != null ? fmtUSD(cost.reported) : cost.total != null ? (cost.lowerBound ? '≥ ' : '') + fmtUSD(cost.total) : '—';
|
|
479
|
+
const costS = cost.reported != null ? 'reported by the run' : cost.total != null ? (cost.lowerBound ? `a floor — ${cost.partialResponses} subagent response${cost.partialResponses === 1 ? '' : 's'} missing final usage${cost.missing != null ? ` · ~${fmtUSD(cost.missing)} more` : ''}` : cost.complete ? 'estimated from rate card' : 'partial — unknown model') : (cost.unknownModels.length ? 'unknown model: set a rate' : 'no usage data');
|
|
480
480
|
const tiles = [
|
|
481
481
|
['Wall time', fmtDur(T.wallMs), T.humanIdleMs ? `${fmtDur(T.activeMs)} active · ${fmtDur(T.humanIdleMs)} waiting on you` : (t.meta.hasTimestamps ? 'no human idle' : 'no timestamps in file')],
|
|
482
482
|
['Turns', fmtInt(T.turns), `${fmtInt(T.requests)} API requests`],
|