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.
@@ -402,7 +402,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
402
402
  })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
403
403
  'use strict';
404
404
 
405
- const VERSION = '0.4.1';
405
+ const VERSION = '0.6.0';
406
406
 
407
407
  // ---------------------------------------------------------------------------
408
408
  // Rate card (USD per million tokens). Prefix-matched against model ids so dated
@@ -450,6 +450,13 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
450
450
  longTurnTools: 30,
451
451
  failedToolRate: 0.5,
452
452
  failedToolMinCalls: 3,
453
+ failedToolWarnMinErrors: 2,
454
+ failedToolWarnRate: 0.2,
455
+ cacheMissMinShare: 0.05,
456
+ longGenerationMaxTokPerSec: 15,
457
+ duplicateReadAgents: 3,
458
+ duplicateReadWarnChars: 100000,
459
+ groupRepeatsMin: 3, // slow-tool / oversized-result: this many from one tool become one finding
453
460
  };
454
461
 
455
462
  // ---------------------------------------------------------------------------
@@ -463,6 +470,12 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
463
470
  // Cowork: Artifact publishes a page (a write), Skill loads instructions (a read); Task* are bookkeeping (other).
464
471
  const COWORK_READ = new Set(['Skill', 'ListSkills', 'SearchSkills', 'TaskList', 'TaskGet']);
465
472
  const COWORK_WRITE = new Set(['Artifact']);
473
+ // Blocking reads of a background task's output: the duration is the task's run time, and re-issuing the
474
+ // same call after a "still running" timeout is polling, not a retry.
475
+ const WAIT_TOOLS = new Set(['TaskOutput', 'AgentOutputTool']);
476
+ const isWait = (c) => WAIT_TOOLS.has(c.name) && !(c.input && c.input.block === false);
477
+ const waitTarget = (c) => { const i = c.input || {}; return String(i.task_id || i.agentId || i.bash_id || ''); };
478
+ const hasRange = (c) => !!(c.input && (c.input.limit != null || c.input.offset != null || c.input.head_limit != null));
466
479
  // MCP leaf names are verb_noun or noun_verb ("memory_read", "device_list_dir", "send_message"): match the verb as a word.
467
480
  const MCP_READ = /(^|_)(get|list|search|read|find|fetch|query|describe|show|stage|screenshot|context|status|info|check)(_|$)/i;
468
481
  const MCP_WRITE = /(^|_)(write|set|put|create|update|delete|remove|commit|send|reply|forward|upload|str_replace|append|label|move|rename|save)(_|$)/i;
@@ -543,6 +556,26 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
543
556
  return !u.input && !u.cacheRead && !u.cacheWrite && !u.output;
544
557
  }
545
558
 
559
+ // A tool call that never ran because the human or a permission rule stopped it. Claude Code
560
+ // records toolDenialKind (user-rejected, permission-rule, automode-blocked, interrupted); older
561
+ // transcripts only have the result text. A shell's own "Permission denied" is a real failure.
562
+ function denialKind(rec, text) {
563
+ if (rec && rec.toolDenialKind) return String(rec.toolDenialKind);
564
+ const t = String(text || '').trim();
565
+ if (/^\[Request interrupted by user/.test(t)) return 'interrupted';
566
+ if (/^The user doesn't want to proceed with this tool use|^User rejected tool use|^Denied by voice/.test(t)) return 'user-rejected';
567
+ if (/^Permission (for this action was|to use \S+ has been) denied/.test(t)) return 'permission-rule';
568
+ return null;
569
+ }
570
+
571
+ // One API response is written as several records (one per content block). Subagent transcripts
572
+ // rewrite output_tokens as the stream progresses, so the first record can be far below the final
573
+ // count; the other fields repeat. Keep the largest value seen for each field.
574
+ function maxUsage(a, b) {
575
+ for (const k of Object.keys(a)) if ((b[k] || 0) > a[k]) a[k] = b[k];
576
+ return a;
577
+ }
578
+
546
579
  // ---------------------------------------------------------------------------
547
580
  // Line parsing
548
581
  // ---------------------------------------------------------------------------
@@ -643,6 +676,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
643
676
  agents.set(id, {
644
677
  id, type: id === 'main' ? 'main' : 'subagent', description: null, parentToolUseId: null,
645
678
  start: null, end: null, usage: emptyUsage(), requestCount: 0, toolCount: 0, model: null,
679
+ workflowRun: (String(rec._file || '').match(/workflows[\\/](wf_[^\\/]+)[\\/]/) || [])[1] || null,
646
680
  });
647
681
  }
648
682
  const a = agents.get(id);
@@ -693,7 +727,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
693
727
  if (!meta.gitBranch && rec.gitBranch) meta.gitBranch = rec.gitBranch;
694
728
  if (!meta.entrypoint && rec.entrypoint) meta.entrypoint = rec.entrypoint;
695
729
  const t = ts(rec);
696
- if (t != null) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
730
+ // Only conversation records bound the session: bookkeeping records (frame-link, file-history…)
731
+ // can be written days later and would stretch the wall clock.
732
+ if (t != null && (type === 'user' || type === 'assistant' || type === 'system')) { if (meta.start == null || t < meta.start) meta.start = t; if (meta.end == null || t > meta.end) meta.end = t; }
697
733
 
698
734
  if (type === 'summary') { if (rec.summary && !meta.title) meta.title = rec.summary; events.push({ kind: 'summary', at: t, detail: rec.summary }); continue; }
699
735
  if (type === 'attachment') { attachments.count++; attachments.chars += JSON.stringify(rec.attachment || '').length; continue; }
@@ -747,6 +783,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
747
783
  if (structured && typeof structured === 'object' && !text && !imgs.length) call.resultChars = JSON.stringify(structured).length;
748
784
  // stream-json puts is_error only in block; Claude Code also stores toolUseResult as an Error string
749
785
  if (!tr.is_error && typeof structured === 'string' && /^Error/i.test(structured)) { call.status = 'error'; call.isError = true; }
786
+ if (call.isError) call.denial = denialKind(rec, text);
750
787
  } else {
751
788
  toolCalls.push(mkOrphanResult(tr, rec, a.id, text, turns.length ? turns[turns.length - 1].index : null));
752
789
  }
@@ -759,6 +796,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
759
796
  let kind = 'human';
760
797
  if (rec.isCompactSummary) kind = 'compact';
761
798
  else if (rec.isMeta) kind = 'meta';
799
+ else if (/^\[Request interrupted by user/.test(text.trim())) kind = 'interrupt';
762
800
  else if (/^<(system-reminder|local-command|command-name|bash-input|bash-stdout)/.test(text.trim())) kind = 'meta';
763
801
  ensureTurn(rec, kind, text);
764
802
  continue;
@@ -781,16 +819,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
781
819
  turn.requestIds.push(req.id);
782
820
  a.requestCount++;
783
821
  if (msg.model) { pushModel(meta, msg.model); if (!a.model) a.model = msg.model; }
784
- const u = usageFrom(msg.usage);
785
- if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; addUsage(turn.usage, u); addUsage(a.usage, u); }
786
822
  } else {
787
823
  if (t != null) { if (req.start == null || t < req.start) req.start = t; if (req.end == null || t > req.end) req.end = t; }
788
- // usage: if the first record lacked it and a later one has it, take it once
789
- if (isEmptyUsage(req.usage)) {
790
- const u = usageFrom(msg.usage);
791
- if (!isEmptyUsage(u)) { req.usage = u; req.contextTokens = u.input + u.cacheRead + u.cacheWrite; const turn = turns[req.turnIndex]; addUsage(turn.usage, u); addUsage(a.usage, u); }
792
- }
793
824
  }
825
+ maxUsage(req.usage, usageFrom(msg.usage)); // turn and agent totals are summed once, after the loop
794
826
  req.records++;
795
827
  if (msg.stop_reason) req.stopReason = msg.stop_reason;
796
828
  if (rec.isApiErrorMessage) req.isApiError = true;
@@ -803,7 +835,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
803
835
  id: b.id || (key + ':' + blockIndex), agent: a.id, requestId: req.id, name: b.name || 'unknown', input: b.input || {},
804
836
  start: t, end: null, durationMs: null, status: 'orphan', isError: false, resultText: '', resultChars: 0,
805
837
  resultStructured: null, category: toolCategory(b.name), turnIndex: req.turnIndex, subagentId: null,
806
- inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0,
838
+ inputChars: JSON.stringify(b.input || {}).length, resultImages: 0, imageBytes: 0, denial: null,
807
839
  };
808
840
  toolCalls.push(call);
809
841
  toolCallById.set(call.id, call);
@@ -825,7 +857,12 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
825
857
  }
826
858
 
827
859
  // Finalise
828
- for (const req of requests) req.blocks.sort((x, y) => x.index - y.index);
860
+ for (const req of requests) {
861
+ req.blocks.sort((x, y) => x.index - y.index);
862
+ req.contextTokens = req.usage.input + req.usage.cacheRead + req.usage.cacheWrite;
863
+ addUsage(turns[req.turnIndex].usage, req.usage);
864
+ addUsage(agents.get(req.agent).usage, req.usage);
865
+ }
829
866
  for (const turn of turns) if (turn.end == null) turn.end = meta.end;
830
867
  // Response timing. Assistant record timestamps are written when a block finishes streaming,
831
868
  // so a request's "start" is really its first completed block. We measure from the moment the
@@ -857,9 +894,14 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
857
894
  const parent = toolCalls.find((c) => c.category === 'agent' && c.agent === 'main' && ((c.resultText && c.resultText.includes(ag.id)) || (c.resultStructured && JSON.stringify(c.resultStructured).includes(ag.id))));
858
895
  if (parent) ag.parentToolUseId = parent.id;
859
896
  }
897
+ // Workflow agents live in subagents/workflows/<runId>/; the Workflow call's result names that runId.
898
+ if (!ag.parentToolUseId && ag.workflowRun) {
899
+ const parent = toolCalls.find((c) => c.name === 'Workflow' && ((c.resultStructured && c.resultStructured.runId === ag.workflowRun) || (c.resultText && c.resultText.includes(ag.workflowRun))));
900
+ if (parent) ag.parentToolUseId = parent.id;
901
+ }
860
902
  if (ag.parentToolUseId) {
861
903
  const parent = toolCallById.get(ag.parentToolUseId);
862
- if (parent) { parent.subagentId = ag.id; if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
904
+ if (parent) { if (!parent.subagentId) parent.subagentId = ag.id; (parent.subagentIds || (parent.subagentIds = [])).push(ag.id); if (!ag.description && parent.input && parent.input.description) ag.description = parent.input.description; if (!ag.agentType && parent.input && parent.input.subagent_type) ag.agentType = parent.input.subagent_type; }
863
905
  }
864
906
  }
865
907
 
@@ -913,9 +955,20 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
913
955
  if (!byTurnSig.has(sig)) byTurnSig.set(sig, []);
914
956
  byTurnSig.get(sig).push(c);
915
957
  }
958
+ const callIndex = new Map(calls.map((c, i) => [c, i]));
916
959
  for (const group of byTurnSig.values()) {
917
960
  if (group.length >= o.retryLoopMin) {
918
961
  const errs = group.filter((c) => c.isError).length;
962
+ if (isWait(group[0]) && errs < 2) continue; // polling a running task; failing polls still count
963
+ if (errs < 2) {
964
+ // Re-running a test or taking another screenshot after something changed is observing, not
965
+ // looping: only flag successful repeats that returned the same thing with nothing in between.
966
+ const inGroup = new Set(group);
967
+ const between = calls.slice(callIndex.get(group[0]) + 1, callIndex.get(group[group.length - 1]));
968
+ const changed = between.some((c) => c.agent === group[0].agent && !inGroup.has(c) && c.category !== 'read');
969
+ const same = group.every((c) => c.resultText === group[0].resultText && c.imageBytes === group[0].imageBytes);
970
+ if (changed || !same) continue;
971
+ }
919
972
  push({
920
973
  id: 'retry-loop', severity: errs >= 2 ? 'error' : 'warn',
921
974
  title: `${group[0].name} called ${group.length}× with identical input`,
@@ -925,14 +978,16 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
925
978
  }
926
979
  }
927
980
 
928
- // failed-tool: per tool error rate
981
+ // failed-tool: per tool error rate. Calls the human or a permission rule stopped are not the
982
+ // tool's failure (see permission-denied); one stray error in a long session is not a pattern.
929
983
  const byTool = new Map();
930
- for (const c of calls) { if (c.unmatched) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
984
+ for (const c of calls) { if (c.unmatched || c.denial) continue; if (!byTool.has(c.name)) byTool.set(c.name, []); byTool.get(c.name).push(c); }
931
985
  for (const [name, list] of byTool) {
932
986
  const errs = list.filter((c) => c.isError);
933
987
  if (!errs.length) continue;
934
988
  const rate = errs.length / list.length;
935
989
  const bad = list.length >= o.failedToolMinCalls && rate >= o.failedToolRate;
990
+ if (!bad && (errs.length < o.failedToolWarnMinErrors || rate < o.failedToolWarnRate)) continue;
936
991
  push({
937
992
  id: 'failed-tool', severity: bad ? 'error' : 'warn',
938
993
  title: `${name} failed ${errs.length} of ${list.length} calls (${Math.round(rate * 100)}%)`,
@@ -941,6 +996,18 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
941
996
  });
942
997
  }
943
998
 
999
+ // permission-denied: calls that never ran because the human rejected them, a permission rule or
1000
+ // the auto-mode classifier blocked them, or the human interrupted.
1001
+ const denied = calls.filter((c) => c.denial);
1002
+ if (denied.length) {
1003
+ const kinds = {}; for (const c of denied) kinds[c.denial] = (kinds[c.denial] || 0) + 1;
1004
+ push({
1005
+ id: 'permission-denied', severity: 'info', title: `${denied.length} tool call${denied.length > 1 ? 's' : ''} denied or interrupted`,
1006
+ detail: Object.entries(kinds).map(([k, n]) => `${k} ×${n}`).join(', ') + ' — ' + summariseNames(denied),
1007
+ evidence: { toolCallIds: denied.map((c) => c.id) }, metric: denied.length,
1008
+ });
1009
+ }
1010
+
944
1011
  // orphan-tool
945
1012
  const orphans = calls.filter((c) => c.status === 'orphan');
946
1013
  if (orphans.length) push({
@@ -965,18 +1032,52 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
965
1032
  };
966
1033
  for (const c of calls) {
967
1034
  if (c.agent !== ag.id || c.unmatched) continue;
968
- if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec') flush(); // agent/user/mcp calls don't break a run
1035
+ // MCP calls that aren't reads are actions (browser navigate/click, device commands) and end a run;
1036
+ // agent and user calls don't.
1037
+ if (c.category === 'read') run.push(c); else if (c.category === 'write' || c.category === 'exec' || c.category === 'mcp') flush();
969
1038
  }
970
1039
  flush();
971
1040
  }
972
1041
 
973
- // oversized-result
1042
+ // duplicate-subagent-read: the same file read by several agents, each paying for it in its own context
1043
+ const sharedReads = new Set(); // reported here, so oversized-result below skips them
1044
+ const readsByFile = new Map();
974
1045
  for (const c of calls) {
975
- if (c.resultChars >= o.oversizedResultWarn) push({
1046
+ if (c.name !== 'Read' || c.isError || !c.input || !c.input.file_path) continue;
1047
+ const k = String(c.input.file_path).replace(/\\/g, '/').toLowerCase();
1048
+ if (!readsByFile.has(k)) readsByFile.set(k, []);
1049
+ readsByFile.get(k).push(c);
1050
+ }
1051
+ for (const list of readsByFile.values()) {
1052
+ const agentIds = new Set(list.map((c) => c.agent));
1053
+ if (agentIds.size < o.duplicateReadAgents) continue;
1054
+ const chars = list.reduce((s, c) => s + c.resultChars, 0);
1055
+ for (const c of list) sharedReads.add(c);
1056
+ const largest = Math.max(...list.map((c) => c.resultChars));
1057
+ push({
1058
+ id: 'duplicate-subagent-read', severity: largest >= o.oversizedResultError ? 'error' : chars >= o.duplicateReadWarnChars ? 'warn' : 'info',
1059
+ title: `Same file read by ${agentIds.size} agents (${fmtInt(chars)} chars)`, // path only in detail, which --redact blanks
1060
+ detail: `${list.length} reads of ${list[0].input.file_path} across ${agentIds.size} agents; each copy is billed in that agent's context on every later request.`,
1061
+ evidence: { toolCallIds: list.map((c) => c.id) }, metric: chars,
1062
+ });
1063
+ }
1064
+
1065
+ // oversized-result: one line per call, or one per tool when a tool does it repeatedly
1066
+ const bigByTool = new Map();
1067
+ for (const c of calls) if (c.resultChars >= o.oversizedResultWarn && !sharedReads.has(c)) { if (!bigByTool.has(c.name)) bigByTool.set(c.name, []); bigByTool.get(c.name).push(c); }
1068
+ for (const [name, list] of bigByTool) {
1069
+ const largest = Math.max(...list.map((c) => c.resultChars)), total = list.reduce((s, c) => s + c.resultChars, 0);
1070
+ const ranged = list.every(hasRange);
1071
+ const detail = 'Large tool results stay in context for the rest of the session. ' + (ranged ? 'The call already set a range; long lines or a wide window still cost, so narrow it or grep first.' : 'Consider head/limit, grep, or a subagent.');
1072
+ if (list.length >= o.groupRepeatsMin) push({
1073
+ id: 'oversized-result', severity: largest >= o.oversizedResultError ? 'error' : 'warn',
1074
+ title: `${name} returned over ${fmtInt(o.oversizedResultWarn)} chars ${list.length} times (largest ${fmtInt(largest)}, ${fmtInt(total)} in all)`,
1075
+ detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: largest,
1076
+ });
1077
+ else for (const c of list) push({
976
1078
  id: 'oversized-result', severity: c.resultChars >= o.oversizedResultError ? 'error' : 'warn',
977
1079
  title: `${c.name} returned ${fmtInt(c.resultChars)} chars`,
978
- detail: 'Large tool results stay in context for the rest of the session. Consider head/limit, grep, or a subagent.',
979
- evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
1080
+ detail, evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.resultChars,
980
1081
  });
981
1082
  }
982
1083
 
@@ -996,21 +1097,45 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
996
1097
  if (r.contextTokens >= o.contextBloatWarn && !crossed) crossed = r;
997
1098
  if (!peak || r.contextTokens > peak.contextTokens) peak = r;
998
1099
  }
999
- if (crossed) push({
1000
- id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
1001
- title: `Context reached ${fmtInt(peak.contextTokens)} tokens`,
1002
- detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}). Every later request pays for that prefix.`,
1003
- evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens,
1004
- });
1100
+ if (crossed) {
1101
+ // What the large context cost: every request (any agent) sent with more than the threshold.
1102
+ let above = 0, all = 0, n = 0;
1103
+ for (const r of reqs) { const c = requestCost(r.usage, rateFor(r.model, o.rates)); if (c == null) continue; all += c; if (r.contextTokens >= o.contextBloatWarn) { above += c; n++; } }
1104
+ const spend = all > 0 ? ` · ${usd(above)} (${Math.round(above / all * 100)}% of cost) spent above ${fmtInt(o.contextBloatWarn)}` : '';
1105
+ push({
1106
+ id: 'context-bloat', severity: peak.contextTokens >= o.contextBloatError ? 'error' : 'warn',
1107
+ title: `Context reached ${fmtInt(peak.contextTokens)} tokens${spend}`,
1108
+ detail: `First crossed ${fmtInt(o.contextBloatWarn)} at request #${reqs.indexOf(crossed) + 1} (turn ${crossed.turnIndex + 1}); ${n} request${n === 1 ? '' : 's'} ran above it. Every one of them re-reads that prefix.`,
1109
+ evidence: { requestIds: [crossed.id, peak.id], turnIndex: crossed.turnIndex }, metric: peak.contextTokens, cost: all > 0 ? above : null,
1110
+ });
1111
+ }
1005
1112
 
1006
- // cache-churn
1007
- const firstReqOfAgent = new Set();
1113
+ // cache-churn / idle-cache-expiry: a request that re-wrote prefix the previous request of the same
1114
+ // agent had already cached. A big cache write on its own is usually just a new tool result being
1115
+ // cached, which is expected. After a gap longer than the cache lifetime the miss is expiry, not
1116
+ // invalidation; right after a compaction it is an expected rebuild.
1117
+ const prevByAgent = new Map(); const ttlByAgent = new Map();
1008
1118
  for (const r of reqs) {
1009
- if (!firstReqOfAgent.has(r.agent)) { firstReqOfAgent.add(r.agent); continue; }
1010
- if (r.usage.cacheWrite >= o.cacheChurnMin) push({
1011
- id: 'cache-churn', severity: 'info', title: `${fmtInt(r.usage.cacheWrite)} tokens re-cached at request #${reqs.indexOf(r) + 1}`,
1012
- detail: 'A large cache write mid-session means the cached prefix was invalidated (system prompt, tool list or early messages changed).',
1013
- evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.usage.cacheWrite,
1119
+ const prev = prevByAgent.get(r.agent); prevByAgent.set(r.agent, r);
1120
+ if (r.usage.cacheWrite1h > 0) ttlByAgent.set(r.agent, 3600e3); else if (r.usage.cacheWrite5m > 0 && !ttlByAgent.has(r.agent)) ttlByAgent.set(r.agent, 300e3);
1121
+ if (!prev) continue;
1122
+ const cached = prev.usage.cacheRead + prev.usage.cacheWrite;
1123
+ const missed = cached - r.usage.cacheRead;
1124
+ if (missed < o.cacheChurnMin || missed < cached * o.cacheMissMinShare) continue;
1125
+ const sent = r.responseFrom != null ? r.responseFrom : r.start;
1126
+ if (prev.end != null && sent != null && trace.events.some((e) => e.kind === 'compact' && e.at != null && e.at >= prev.end && e.at <= sent)) continue;
1127
+ const idle = prev.end != null && sent != null ? sent - prev.end : null;
1128
+ const ttl = ttlByAgent.get(r.agent) || 300e3;
1129
+ const n = reqs.indexOf(r) + 1;
1130
+ if (idle != null && idle > ttl) push({
1131
+ id: 'idle-cache-expiry', severity: 'info', title: `${fmtInt(missed)} tokens re-cached after ${fmtDur(idle)} idle`,
1132
+ detail: `Request #${n}${r.agent !== 'main' ? ' (subagent)' : ''}: the gap was longer than the ${ttl >= 3600e3 ? '1-hour' : '5-minute'} cache lifetime, so the whole prefix was written again.`,
1133
+ evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
1134
+ });
1135
+ else push({
1136
+ id: 'cache-churn', severity: 'info', title: `${fmtInt(missed)} cached tokens re-written at request #${n}`,
1137
+ detail: `The previous request had ${fmtInt(cached)} tokens cached; this one read only ${fmtInt(r.usage.cacheRead)} back${idle != null ? ` ${fmtDur(idle)} later` : ''}. Something early in the prompt changed (system prompt, tool list, early messages).`,
1138
+ evidence: { requestIds: [prev.id, r.id], turnIndex: r.turnIndex }, metric: missed,
1014
1139
  });
1015
1140
  }
1016
1141
 
@@ -1021,14 +1146,40 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1021
1146
  evidence: {}, metric: trace.totals.cacheHitRatio,
1022
1147
  });
1023
1148
 
1024
- // slow-tool
1149
+ // slow-tool: blocking waits roll up per background task, other slow calls per tool when repeated
1150
+ const waits = new Map(), slowByTool = new Map();
1025
1151
  for (const c of calls) {
1026
- if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) push({
1152
+ if (isWait(c)) { const k = c.agent + '|' + c.name + '|' + waitTarget(c); if (!waits.has(k)) waits.set(k, []); waits.get(k).push(c); continue; }
1153
+ // Time spent on a question to the human, or on a permission prompt that ended in a denial, is the human's.
1154
+ if (c.category === 'user' || c.denial) continue;
1155
+ if (c.durationMs != null && c.durationMs >= o.slowToolInfoMs) { if (!slowByTool.has(c.name)) slowByTool.set(c.name, []); slowByTool.get(c.name).push(c); }
1156
+ }
1157
+ for (const [name, list] of slowByTool) {
1158
+ const longest = Math.max(...list.map((c) => c.durationMs)), total = list.reduce((s, c) => s + c.durationMs, 0);
1159
+ const detail = list[0].category === 'agent' ? 'Subagent run time.' : 'Long-running tool call.';
1160
+ if (list.length >= o.groupRepeatsMin) push({
1161
+ id: 'slow-tool', severity: longest >= o.slowToolWarnMs ? 'warn' : 'info',
1162
+ title: `${name} took over ${fmtDur(o.slowToolInfoMs)} ${list.length} times (longest ${fmtDur(longest)}, ${fmtDur(total)} in all)`,
1163
+ detail, evidence: { toolCallIds: list.map((c) => c.id) }, metric: total,
1164
+ });
1165
+ else for (const c of list) push({
1027
1166
  id: 'slow-tool', severity: c.durationMs >= o.slowToolWarnMs ? 'warn' : 'info',
1028
- title: `${c.name} took ${fmtDur(c.durationMs)}`, detail: c.category === 'agent' ? 'Subagent run time.' : c.category === 'user' ? 'Waiting on the human.' : 'Long-running tool call.',
1167
+ title: `${c.name} took ${fmtDur(c.durationMs)}`, detail,
1029
1168
  evidence: { toolCallIds: [c.id], turnIndex: c.turnIndex }, metric: c.durationMs,
1030
1169
  });
1031
1170
  }
1171
+ // One finding per waited-on task, always info: the time belongs to the background job.
1172
+ for (const group of waits.values()) {
1173
+ const ms = group.reduce((s, c) => s + (c.durationMs || 0), 0);
1174
+ if (ms < o.slowToolInfoMs) continue;
1175
+ const target = waitTarget(group[0]);
1176
+ push({
1177
+ id: 'slow-tool', severity: 'info',
1178
+ title: `${group[0].name} waited ${fmtDur(ms)} on ${target ? 'task ' + target : 'a background task'}${group.length > 1 ? ` (${group.length} polls)` : ''}`,
1179
+ detail: 'Blocking wait on background work: the time is the task\'s run time, not a slow tool.',
1180
+ evidence: { toolCallIds: group.map((c) => c.id), turnIndex: group[0].turnIndex }, metric: ms,
1181
+ });
1182
+ }
1032
1183
 
1033
1184
  // slow-model: a long response that produced little — API latency, rate limiting or a stall,
1034
1185
  // as opposed to a long response that was simply writing a lot (reported separately as info).
@@ -1041,9 +1192,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1041
1192
  detail: `Request #${n} (${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s): API latency, rate limiting or a stalled stream.`,
1042
1193
  evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
1043
1194
  });
1044
- else push({
1045
- id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens`,
1046
- detail: `Request #${n} at ${r.tokensPerSec != null ? r.tokensPerSec.toFixed(1) : '?'} tok/s (${r.blocksSummary}). Big single outputs are normal for file writes; a stall would show as low tok/s.`,
1195
+ // A long response that wrote a lot at a normal rate is just a big output. Only a slow rate is worth a line.
1196
+ else if (r.tokensPerSec != null && r.tokensPerSec < o.longGenerationMaxTokPerSec) push({
1197
+ id: 'long-generation', severity: 'info', title: `${fmtDur(r.responseMs)} writing ${fmtInt(r.usage.output)} tokens (${r.tokensPerSec.toFixed(1)} tok/s)`,
1198
+ detail: `Request #${n} (${r.blocksSummary}) streamed well below the usual rate: a partial stall or a slow API period.`,
1047
1199
  evidence: { requestIds: [r.id], turnIndex: r.turnIndex }, metric: r.responseMs,
1048
1200
  });
1049
1201
  }
@@ -1075,10 +1227,18 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1075
1227
  detail: 'Most of the spend happened in delegated work.', evidence: {}, metric: subTokens / allTokens,
1076
1228
  });
1077
1229
 
1078
- // long-turn
1079
- for (const tn of trace.turns) if (tn.toolCallIds.length >= o.longTurnTools) push({
1080
- id: 'long-turn', severity: 'info', title: `Turn ${tn.index + 1} made ${tn.toolCallIds.length} tool calls`,
1081
- detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: tn.index, toolCallIds: tn.toolCallIds.slice() }, metric: tn.toolCallIds.length,
1230
+ // long-turn: main conversation only (a subagent's whole run is one turn by design), numbered like
1231
+ // the viewer's T1, T2… (human prompts).
1232
+ // Meta turns (injected reminders, interrupts) between two prompts belong to the stretch before them.
1233
+ const stretches = [];
1234
+ for (const tn of trace.turns) {
1235
+ if (tn.agent !== 'main') continue;
1236
+ if (tn.promptKind === 'human' || !stretches.length) stretches.push({ first: tn, no: stretches.length + 1, ids: [] });
1237
+ stretches[stretches.length - 1].ids.push(...tn.toolCallIds);
1238
+ }
1239
+ for (const st of stretches) if (st.ids.length >= o.longTurnTools) push({
1240
+ id: 'long-turn', severity: 'info', title: `Turn ${st.no} made ${st.ids.length} tool calls`,
1241
+ detail: 'A very long autonomous stretch. Worth a skim for wasted work.', evidence: { turnIndex: st.first.index, toolCallIds: st.ids }, metric: st.ids.length,
1082
1242
  });
1083
1243
 
1084
1244
  const order = { error: 0, warn: 1, info: 2 };
@@ -1164,6 +1324,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1164
1324
  '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.',
1165
1325
  'subagent-share': 'Most spend was in subagents; give them narrower briefs and ask for short structured returns so the parent pays for less.',
1166
1326
  'long-turn': 'A very long autonomous turn; check in with a short progress summary at natural checkpoints so wasted work is caught earlier.',
1327
+ '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.',
1328
+ '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.',
1329
+ 'idle-cache-expiry': 'The cache expired while the session sat idle; resume sooner, use the 1-hour cache lifetime where available, or start a fresh session with a summary instead of re-sending the whole history.',
1167
1330
  };
1168
1331
 
1169
1332
  // Advice for a concrete finding: the rule's line, except where the evidence says otherwise.
@@ -1173,6 +1336,11 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1173
1336
  const c = trace.toolCalls.find((x) => x.id === ids[0]);
1174
1337
  if (c && c.category === 'user') return null; // waiting on the human is not the agent's time
1175
1338
  if (c && c.category === 'agent') return 'Subagent run time: give it a narrower brief and ask for a short structured return so the parent is not blocked for as long.';
1339
+ if (c && isWait(c)) return 'Waiting on a long background task: keep doing independent work while it runs, or tell the user it is still going, instead of blocking the turn on it.';
1340
+ }
1341
+ if (f.id === 'oversized-result') {
1342
+ const c = trace.toolCalls.find((x) => x.id === ((f.evidence && f.evidence.toolCallIds) || [])[0]);
1343
+ if (c && hasRange(c)) return 'The read already had a range and still returned a lot: grep for the part you need first, or read a narrower window.';
1176
1344
  }
1177
1345
  return ADVICE[f.id] || null;
1178
1346
  }
@@ -1207,9 +1375,44 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1207
1375
  }
1208
1376
 
1209
1377
  // Rules whose detail quotes tool output or model text (everything else is names and numbers).
1210
- const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error']);
1378
+ const CONTENT_DETAIL = new Set(['failed-tool', 'api-error', 'hook-error', 'duplicate-subagent-read']);
1211
1379
  function redactDetail(f) { return CONTENT_DETAIL.has(f.id) ? blank(f.detail) : f.detail; }
1212
1380
 
1381
+ // ---------------------------------------------------------------------------
1382
+ // Per-file use: how often each file was read or written, by how many agents, and how often
1383
+ // those calls failed. Paths are normalised (backslashes, case) but otherwise kept; the CLI's
1384
+ // legend replaces them with keys when sharing.
1385
+ // ---------------------------------------------------------------------------
1386
+ const FILE_READ = new Set(['Read']);
1387
+ const FILE_WRITE = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
1388
+ function filePathOf(c) { const i = c && c.input; if (!i) return null; const p = i.file_path || i.notebook_path; return typeof p === 'string' && p ? p : null; }
1389
+ function normalisePath(p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); }
1390
+ function fileStats(trace, opts) {
1391
+ const minCalls = (opts && opts.minCalls) || 3, minAgents = (opts && opts.minAgents) || 2;
1392
+ const rows = new Map();
1393
+ for (const c of trace.toolCalls) {
1394
+ if (c.unmatched) continue;
1395
+ const isRead = FILE_READ.has(c.name), isWrite = FILE_WRITE.has(c.name);
1396
+ if (!isRead && !isWrite) continue;
1397
+ const p = filePathOf(c); if (!p) continue;
1398
+ const k = normalisePath(p);
1399
+ if (!rows.has(k)) rows.set(k, { path: p, reads: 0, writes: 0, errors: 0, chars: 0, agentSet: new Set(), tools: {} });
1400
+ const r = rows.get(k);
1401
+ if (isRead) { r.reads++; r.chars += c.resultChars || 0; } else r.writes++;
1402
+ if (c.isError) r.errors++;
1403
+ r.agentSet.add(c.agent);
1404
+ r.tools[c.name] = (r.tools[c.name] || 0) + 1;
1405
+ }
1406
+ const out = [];
1407
+ for (const r of rows.values()) {
1408
+ const calls = r.reads + r.writes;
1409
+ if (calls < minCalls && !r.errors && r.agentSet.size < minAgents) continue;
1410
+ out.push({ path: r.path, reads: r.reads, writes: r.writes, errors: r.errors, agents: r.agentSet.size, chars: r.chars, tools: r.tools });
1411
+ }
1412
+ out.sort((a, b) => (b.reads + b.writes) - (a.reads + a.writes) || b.errors - a.errors || a.path.localeCompare(b.path));
1413
+ return out;
1414
+ }
1415
+
1213
1416
  function reportMarkdown(trace, findings, cost, opts) {
1214
1417
  opts = opts || {};
1215
1418
  const redact = !!opts.redact, maxEv = opts.maxEvidence || 6;
@@ -1365,7 +1568,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1365
1568
  }
1366
1569
  function toJsonl(records) { return records.map((r) => JSON.stringify(r)).join('\n') + '\n'; }
1367
1570
 
1368
- return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable };
1571
+ return { VERSION, RATES, DEFAULTS, ADVICE, METRICS, parseLines, parseTrace, diagnose, estimateCost, rateFor, redact, toJsonl, toolCategory, textOf, fmtDur, fmtInt, fmtMetric, fmtRatio, fmtChange, stableStringify, inputSummary, adviceFor, redactDetail, reportMarkdown, compare, compareMarkdown, toolTable, fileStats, filePathOf, normalisePath };
1369
1572
  });
1370
1573
  </script>
1371
1574
  <script>
@@ -1864,7 +2067,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
1864
2067
  }
1865
2068
  async function loadSession(s) {
1866
2069
  const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
1867
- try { const sdir = await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'); for await (const [n, h] of sdir.entries()) if (h.kind === 'file' && /\.(jsonl|json)$/.test(n)) files.push({ name: s.id + '/subagents/' + n, text: await (await h.getFile()).text() }); } catch (e) { }
2070
+ // subagents/*.jsonl plus Workflow agents in subagents/workflows/<runId>/ (journal.jsonl is the workflow's log)
2071
+ const walk = async (dir, rel) => { for await (const [n, h] of dir.entries()) { if (h.kind === 'directory') await walk(h, rel + '/' + n); else if (/\.(jsonl|json)$/.test(n) && n !== 'journal.jsonl') files.push({ name: rel + '/' + n, text: await (await h.getFile()).text() }); } };
2072
+ try { await walk(await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'), s.id + '/subagents'); } catch (e) { }
1868
2073
  $('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
1869
2074
  }
1870
2075
  async function showSessions(dir) {
package/package.json CHANGED
@@ -1,57 +1,57 @@
1
- {
2
- "name": "glassbox-trace",
3
- "version": "0.4.1",
4
- "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
- "main": "src/trace-core.js",
6
- "scripts": {
7
- "test": "node --test test/*.test.mjs",
8
- "build": "node scripts/build.mjs",
9
- "sanitize": "node scripts/sanitize.mjs",
10
- "e2e": "node test/browser.e2e.mjs",
11
- "check": "npm test && npm run build && npm run e2e",
12
- "audit": "node test/audit.mjs",
13
- "prepublishOnly": "npm test && npm run build"
14
- },
15
- "license": "MIT",
16
- "author": "Aldo Hushi",
17
- "bin": {
18
- "glassbox": "bin/glassbox.mjs"
19
- },
20
- "files": [
21
- "bin",
22
- "src",
23
- "dist/glassbox.html",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "engines": {
28
- "node": ">=18"
29
- },
30
- "keywords": [
31
- "claude-code",
32
- "claude-code-log",
33
- "compare",
34
- "live-tail",
35
- "claude",
36
- "agent",
37
- "agent-sdk",
38
- "trace",
39
- "jsonl",
40
- "viewer",
41
- "observability",
42
- "tokens",
43
- "cost",
44
- "accessibility"
45
- ],
46
- "devDependencies": {
47
- "playwright": "^1.49.0"
48
- },
49
- "homepage": "https://github.com/aldohushi1-stack/glassbox#readme",
50
- "repository": {
51
- "type": "git",
52
- "url": "git+https://github.com/aldohushi1-stack/glassbox.git"
53
- },
54
- "bugs": {
55
- "url": "https://github.com/aldohushi1-stack/glassbox/issues"
56
- }
57
- }
1
+ {
2
+ "name": "glassbox-trace",
3
+ "version": "0.6.0",
4
+ "description": "Other tools show what happened in a Claude Code session; Glassbox tells you what went wrong. Timeline, token and cost burn, auto-diagnostics with evidence and advice, session compare, live tail, a CLI and a Claude Code Stop hook that feeds the findings back to the agent.",
5
+ "main": "src/trace-core.js",
6
+ "scripts": {
7
+ "test": "node --test test/*.test.mjs",
8
+ "build": "node scripts/build.mjs",
9
+ "sanitize": "node scripts/sanitize.mjs",
10
+ "e2e": "node test/browser.e2e.mjs",
11
+ "check": "npm test && npm run build && npm run e2e",
12
+ "audit": "node test/audit.mjs",
13
+ "prepublishOnly": "npm test && npm run build"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Aldo Hushi",
17
+ "bin": {
18
+ "glassbox": "bin/glassbox.mjs"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "dist/glassbox.html",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "claude-code",
32
+ "claude-code-log",
33
+ "compare",
34
+ "live-tail",
35
+ "claude",
36
+ "agent",
37
+ "agent-sdk",
38
+ "trace",
39
+ "jsonl",
40
+ "viewer",
41
+ "observability",
42
+ "tokens",
43
+ "cost",
44
+ "accessibility"
45
+ ],
46
+ "devDependencies": {
47
+ "playwright": "1.63.0"
48
+ },
49
+ "homepage": "https://github.com/aldohushi1-stack/glassbox#readme",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/aldohushi1-stack/glassbox.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/aldohushi1-stack/glassbox/issues"
56
+ }
57
+ }