glassbox-trace 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -18
- package/dist/glassbox.html +214 -44
- package/package.json +57 -57
- package/src/cli.mjs +139 -21
- package/src/tail.mjs +5 -5
- package/src/trace-core.js +211 -43
- package/src/viewer.html +3 -1
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
**Other tools show you what happened in a Claude Code session. Glassbox tells you what went wrong.**
|
|
6
6
|
|
|
7
|
-
Drop a session `.jsonl` onto one HTML file. Get a timeline of every model call and tool call
|
|
7
|
+
Drop a session `.jsonl` onto one HTML file. Get a timeline of every model call and tool call — subagents and Workflow agents included — where the tokens and money went, and a list of things a reviewer would flag — retry loops, failing tools, oversized results, context that cost you money, files every subagent re-read — each with the evidence and one line on what to do differently. Compare two sessions side by side. Watch one live while Claude Code writes it. Hand the findings back to the agent. Nothing leaves your machine, and no tokens are spent: every rule is mechanical.
|
|
8
|
+
|
|
9
|
+
**Check an agent run like a test.** `glassbox check --fail-on warn` exits 1 when a session looped, kept failing, or ran up context — so a `claude -p` job in CI can fail the build the way a test would ([docs/CI.md](docs/CI.md)).
|
|
8
10
|
|
|
9
11
|

|
|
10
12
|
|
|
@@ -12,7 +14,7 @@ The demo built into the file is the trace of the session that built Glassbox, sa
|
|
|
12
14
|
|
|
13
15
|
## Why
|
|
14
16
|
|
|
15
|
-
Every Claude Code and Cowork session writes its full transcript to `~/.claude/projects/<project>/<session-id>.jsonl`, with subagents in `<session-id>/subagents/agent-*.jsonl
|
|
17
|
+
Every Claude Code and Cowork session writes its full transcript to `~/.claude/projects/<project>/<session-id>.jsonl`, with subagents in `<session-id>/subagents/agent-*.jsonl` and Workflow agents in `<session-id>/subagents/workflows/<runId>/`. The Agent SDK emits the same shapes as `--output-format stream-json`. Those files are the only complete record of what an agent did, and they're unreadable: one JSON object per line, assistant messages split one record per content block, usage duplicated across those records, tool calls joined to results only by id.
|
|
16
18
|
|
|
17
19
|
Agents can't see their own traces. Humans reviewing agent work can't either. Glassbox fixes both.
|
|
18
20
|
|
|
@@ -44,14 +46,20 @@ The npm package is `glassbox-trace` (plain `glassbox` was already taken); the co
|
|
|
44
46
|
|
|
45
47
|
**Watch a session live.** `glassbox watch` serves the viewer from `127.0.0.1` and streams the transcript to it as it grows — new tool calls land on the timeline within a second, tiles and findings update, **Follow** keeps the right edge on now (pan or zoom and it lets go). Loopback only; nothing leaves the machine.
|
|
46
48
|
|
|
47
|
-
**Let the agent read its own recorder.** `glassbox hook install` adds a Claude Code Stop hook: every session ends with a one-screen Glassbox summary (turns, tool calls, context peak, cost, top findings). Add `--feedback` and the findings — with their evidence and the "next time" advice — are handed back to the agent
|
|
49
|
+
**Let the agent read its own recorder.** `glassbox hook install` adds a Claude Code Stop hook: every session ends with a one-screen Glassbox summary (turns, tool calls, context peak, cost, top findings). Add `--feedback` and the findings — with their evidence and the "next time" advice — are handed back to the agent; it reads them and says, in a sentence, what it would do differently. Each finding is handed back once per session, it never loops (`stop_hook_active` is respected), never blocks a clean session, and `glassbox hook uninstall` removes it, with a `.glassbox-backup` of `settings.json` kept.
|
|
50
|
+
|
|
51
|
+
Feedback at the end of a session can only change that session's last reply. Add `--context` to carry it forward: the Stop hook keeps the findings (and, with `--feedback`, the agent's answer) in `<project>/.glassbox/last-session.md` — git-ignored, deleted after a clean session — and a SessionStart hook gives them to the next session in that project.
|
|
48
52
|
|
|
49
53
|
```
|
|
50
|
-
glassbox hook install --feedback --fail-on warn # summary
|
|
51
|
-
glassbox hook install
|
|
54
|
+
glassbox hook install --feedback --context --fail-on warn # summary, feedback, and notes for the next session
|
|
55
|
+
glassbox hook install # summary only
|
|
52
56
|
glassbox hook uninstall
|
|
53
57
|
```
|
|
54
58
|
|
|
59
|
+
**Or install it as a Claude Code plugin** — the same hooks plus `/glassbox:check` and a skill Claude uses when you ask "what went wrong in this session?", running the bundled code (no npx): `/plugin marketplace add aldohushi1-stack/glassbox`, then `/plugin install glassbox@glassbox-trace`. Feedback and context are opt-in plugin options. See [docs/PLUGIN.md](docs/PLUGIN.md).
|
|
60
|
+
|
|
61
|
+
**Many sessions at once.** `glassbox check --all --since 1h` checks every session written in the last hour (or `--all` alone for all of them), one line each, exit 1 if any fails — for CI jobs that run several `claude -p` tasks. `--rates rates.json` (or `GLASSBOX_RATES`) pins a team rate card for `check`, `compare` and the hook: `{ "claude-opus-5": { "in": 5, "out": 25, "read": 0.5, "w5m": 6.25, "w1h": 10 } }`, USD per million tokens, keyed by model-id prefix.
|
|
62
|
+
|
|
55
63
|
**In the browser:** the viewer is one HTML file — open `dist/glassbox.html` (double-click, no server; the CLI, compare-from-terminal and live tail need Node 18+). It shows the demo session at rest. Then either drop your `.jsonl` (or the whole `<session-id>` folder for subagent lanes), or in Chrome/Edge click **Open folder…**, pick `~/.claude/projects`, and choose a session from the list — the folder is remembered, so next time it's **Recent**. Finding the file by hand: `ls -t ~/.claude/projects/*/*.jsonl | head` on macOS/Linux, `%USERPROFILE%\.claude\projects\` on Windows.
|
|
56
64
|
|
|
57
65
|
Read top to bottom: stats → timeline (with minimap, search, fit-to-turn) → context & cost → findings → tools → turns. Click anything for detail. Every selection is a permalink (`#req=17`, `#tool=…`, `#find=3`, `#turn=2`).
|
|
@@ -68,25 +76,28 @@ Read top to bottom: stats → timeline (with minimap, search, fit-to-turn) → c
|
|
|
68
76
|
|
|
69
77
|
| rule | fires when |
|
|
70
78
|
|---|---|
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
79
|
+
| `context-bloat` | prompt size ≥ 120k tokens (error at 170k), with the request that first crossed it and the dollars spent on requests above it |
|
|
80
|
+
| `retry-loop` | same tool with identical input ≥ 3× in one turn, returning the same result with nothing changed in between (error if ≥ 2 failed, whatever the results) |
|
|
81
|
+
| `failed-tool` | a tool fails ≥ 2 times and ≥ 20% of its calls (error at ≥ 50% of ≥ 3); denied or interrupted calls don't count |
|
|
82
|
+
| `duplicate-subagent-read` | the same file read by ≥ 3 agents, each paying for it in its own context |
|
|
83
|
+
| `oversized-result` | a tool result ≥ 20k chars (error at 60k); one line per tool when it happens ≥ 3 times |
|
|
73
84
|
| `orphan-tool` | a tool call with no result (abort, crash, or still running) |
|
|
74
|
-
| `exploration-run` | ≥ 8 read-only calls in a row with no write or
|
|
75
|
-
| `oversized-result` | a single tool result ≥ 20k chars (error at 60k) |
|
|
85
|
+
| `exploration-run` | ≥ 8 read-only calls in a row with no write, exec or MCP action (warn at 15) |
|
|
76
86
|
| `image-heavy` | screenshots / image reads totalling ≥ 0.5 MB (warn at 2 MB) — they're tokens on every later request |
|
|
77
|
-
| `
|
|
78
|
-
| `cache-
|
|
87
|
+
| `cache-churn` | a request read back ≥ 20k fewer cached tokens than the previous one had cached — something early in the prompt changed |
|
|
88
|
+
| `idle-cache-expiry` | the same kind of miss after a gap longer than the cache lifetime (5 min or 1 h) |
|
|
79
89
|
| `low-cache-hit` | < 50% of input served from cache over ≥ 5 requests |
|
|
80
90
|
| `slow-model` | ≥ 60 s for a response under 1,500 tokens (latency / rate limit / stall) |
|
|
81
|
-
| `long-generation` | ≥ 60 s for a big response
|
|
82
|
-
| `slow-tool` | a tool call ≥ 60 s (warn at 5 min) |
|
|
83
|
-
| `
|
|
91
|
+
| `long-generation` | ≥ 60 s for a big response streamed below 15 tok/s |
|
|
92
|
+
| `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 |
|
|
93
|
+
| `permission-denied` | tool calls the human rejected, a permission rule or auto mode blocked, or an interrupt stopped (info) |
|
|
94
|
+
| `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) |
|
|
84
95
|
|
|
85
|
-
All mechanical, no AI. Thresholds are in `TraceCore.DEFAULTS` and can be overridden when calling `diagnose(trace, opts)`. Every rule has a one-line "next time" in `TraceCore.ADVICE`, which is what the Markdown report and the Stop hook hand back to the agent.
|
|
96
|
+
Identical findings print once with a `×N` count in `check`. All mechanical, no AI. The thresholds were tuned on a corpus of 33 real sessions (481 findings → 142, median 2 per session); `node scripts/corpus-audit.mjs` re-runs that audit on your own sessions and prints counts only. Thresholds are in `TraceCore.DEFAULTS` and can be overridden when calling `diagnose(trace, opts)`. Every rule has a one-line "next time" in `TraceCore.ADVICE`, which is what the Markdown report and the Stop hook hand back to the agent.
|
|
86
97
|
|
|
87
98
|
## Token and cost accounting
|
|
88
99
|
|
|
89
|
-
- Usage is counted once per API request (assistant records sharing a `requestId`), not once per record.
|
|
100
|
+
- 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.
|
|
90
101
|
- Context size = uncached input + cache read + cache write — the prompt the model actually saw.
|
|
91
102
|
- Thinking tokens are part of output tokens; shown as a share, never added twice.
|
|
92
103
|
- 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.
|
|
@@ -113,12 +124,15 @@ src/cli.mjs CLI library (session discovery, embed, check, compare, hoo
|
|
|
113
124
|
src/tail.mjs live tail: byte-offset tailer + loopback SSE server for `glassbox watch`
|
|
114
125
|
scripts/build.mjs build
|
|
115
126
|
scripts/sanitize.mjs turn a real transcript into a shareable fixture (demo or structure mode)
|
|
127
|
+
scripts/corpus-audit.mjs run every rule over your local sessions; counts only (--baseline to diff two runs)
|
|
116
128
|
test/ unit, e2e and audit harnesses, fixtures
|
|
117
129
|
DESIGN.md design doc — data model, rules, thresholds, UI, privacy
|
|
118
130
|
STUDY.md accessibility & ease-of-use study that drove v0.2
|
|
119
131
|
STUDY-IMPLEMENTATION.md adoption study (solo dev, Cowork/SDK, CI, feedback loop) that drove v0.4.1
|
|
120
|
-
docs/ CI recipe, feedback-loop study protocol, Community Extensions PR, post copy
|
|
121
|
-
scripts/feedback-study.mjs
|
|
132
|
+
docs/ CI recipe, plugin, feedback-loop study protocol, launch kit, Community Extensions PR, post copy
|
|
133
|
+
scripts/feedback-study.mjs measures with/without --feedback session groups
|
|
134
|
+
scripts/feedback-study-run.mjs runs that study with `claude -p` (plan only unless --run)
|
|
135
|
+
.claude-plugin/ hooks/ skills/ the Claude Code plugin: manifest + marketplace, Stop/SessionStart hook, skills
|
|
122
136
|
```
|
|
123
137
|
|
|
124
138
|
Use the engine on its own:
|
package/dist/glassbox.html
CHANGED
|
@@ -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.
|
|
405
|
+
const VERSION = '0.5.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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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:
|
|
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)
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
-
if (r.usage.
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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,7 +1375,7 @@ 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
|
|
|
1213
1381
|
function reportMarkdown(trace, findings, cost, opts) {
|
|
@@ -1864,7 +2032,9 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
|
|
|
1864
2032
|
}
|
|
1865
2033
|
async function loadSession(s) {
|
|
1866
2034
|
const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
|
|
1867
|
-
|
|
2035
|
+
// subagents/*.jsonl plus Workflow agents in subagents/workflows/<runId>/ (journal.jsonl is the workflow's log)
|
|
2036
|
+
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() }); } };
|
|
2037
|
+
try { await walk(await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'), s.id + '/subagents'); } catch (e) { }
|
|
1868
2038
|
$('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
|
|
1869
2039
|
}
|
|
1870
2040
|
async function showSessions(dir) {
|