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 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.
@@ -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.0';
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.0",
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.0';
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`],