claudon 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudon
3
+ Version: 0.2.0
4
+ Summary: Interactive session analytics & HTML dashboard for Claude Code
5
+ Author: Infenia Private Limited
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/infenia/claudon
8
+ Project-URL: Repository, https://github.com/infenia/claudon
9
+ Keywords: claude,claude-code,analytics,dashboard,anthropic,llm
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: User Interfaces
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ <div align="center">
24
+
25
+ # Claudon
26
+
27
+ **See where your Claude Code time and money actually go.**
28
+
29
+ One command turns your session logs into an interactive HTML dashboard.<br>
30
+ 100% local · zero dependencies · nothing leaves your machine.
31
+
32
+ [![CI](https://github.com/infenia/claudon/actions/workflows/ci.yml/badge.svg)](https://github.com/infenia/claudon/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/claudon)](https://pypi.org/project/claudon/)
34
+ [![npm](https://img.shields.io/npm/v/claudon)](https://www.npmjs.com/package/claudon)
35
+ [![Python](https://img.shields.io/pypi/pyversions/claudon)](https://pypi.org/project/claudon/)
36
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
37
+
38
+ ```bash
39
+ uvx claudon --open
40
+ ```
41
+
42
+ <img src="docs/assets/overview.png" alt="Claudon dashboard overview: tasks, wall time, cost, time breakdown and per-project cost" width="860">
43
+
44
+ <sub>Demo data. Your own sessions stay on your machine.</sub>
45
+
46
+ </div>
47
+
48
+ ---
49
+
50
+ ## Why Claudon?
51
+
52
+ Claude Code is fast until it isn't. Which turn burned ten minutes? Was it the model, a slow tool, or you being away? Did that failing tool call loop? What did the session really cost? Your answers are already in `~/.claude`; Claudon reads them and shows you:
53
+
54
+ - **Where time went**: model vs. tools vs. permission waits vs. idle, per task, turn and subagent.
55
+ - **What it cost**: token usage × list prices per model generation (override with `--pricing`).
56
+ - **What went wrong**: automatic detection of tool failures, slow turns and retry loops.
57
+ - **How hard it thought**: thinking time vs. thinking tokens.
58
+ - **Safe to share**: `--redact` strips prompts, paths, commands and project names.
59
+
60
+ It is a single Python file using only the standard library. No telemetry, no network, no account.
61
+
62
+ ---
63
+
64
+ ## 🚀 Quick Start
65
+
66
+ Use whichever runner you already have; each downloads and runs the latest release on demand:
67
+
68
+ | Tool | Run once | Install globally |
69
+ |---|---|---|
70
+ | **uv** | `uvx claudon` | `uv tool install claudon` |
71
+ | **pipx** | `pipx run claudon` | `pipx install claudon` |
72
+ | **pip** | | `pip install claudon` |
73
+ | **npm** | `npx claudon` | `npm install -g claudon` |
74
+ | **pnpm** | `pnpm dlx claudon` | `pnpm add -g claudon` |
75
+ | **Yarn** (2+) | `yarn dlx claudon` | |
76
+ | **Bun** | `bunx claudon` | `bun add -g claudon` |
77
+ | **Shell script** | | [`install.sh`](#standalone-shell-script) |
78
+ | **Browser** | [WebAssembly page](#-webassembly-in-browser--zero-python) | no install |
79
+
80
+ ```bash
81
+ claudon ~/.claude -o report.html --open # after a global install
82
+ ```
83
+
84
+ > The npm-family packages (npx, pnpm, yarn, bun) are a thin launcher around the same Python script, so they need
85
+ > **Python 3.11+** on your `PATH` (`python3`, `python` or `py`). No Python at all? Use the browser version or a
86
+ > standalone binary from [GitHub Releases](https://github.com/infenia/claudon/releases).
87
+ > Use `@beta` / `uvx --prerelease allow` for pre-releases.
88
+
89
+ ### Standalone Shell Script
90
+ ```bash
91
+ curl -fsSL https://raw.githubusercontent.com/infenia/claudon/main/install.sh | sh
92
+
93
+ # recommended: pin a release (see GitHub Releases); claudon.py is checked against that release's SHA256SUMS
94
+ curl -fsSL https://raw.githubusercontent.com/infenia/claudon/main/install.sh | CLAUDON_VERSION=vX.Y.Z sh
95
+ ```
96
+ The checksum catches corrupted or mismatched downloads. To also prove a release file was built by this repo's
97
+ workflow, download it and run `gh attestation verify claudon.py -R infenia/claudon`.
98
+
99
+ ### Direct Clone
100
+ ```bash
101
+ git clone https://github.com/infenia/claudon.git
102
+ cd claudon
103
+ python3 claudon.py ~/.claude -o report.html --open
104
+ ```
105
+
106
+ ### ⚡ WebAssembly (In-Browser / Zero Python)
107
+ Run Claudon entirely in your browser without local Python installed. The page loads `../claudon.py`,
108
+ so it has to be served over HTTP (opening the file directly via `file://` can't load it):
109
+ 1. From the repository root run `python3 -m http.server` (or host the repo on any static server, e.g. GitHub Pages).
110
+ 2. Open `http://localhost:8000/wasm/`.
111
+ 3. Drop `.jsonl` transcripts, or use **Open Folder** on `~/.claude/projects` to keep project grouping and subagents.
112
+
113
+ **Nothing is uploaded.** Files are read by the browser and analyzed on your machine; the page's only network requests
114
+ download the Pyodide runtime (jsDelivr) and `claudon.py`, and once it shows *Ready* it keeps working offline. Chrome and
115
+ Edge ask to let the page *view* the folder. Browsers without that folder API (Firefox, Safari) show their own
116
+ "upload" prompt for folder pickers, which only grants the page read access.
117
+
118
+ ### 🛠️ Native Binary (Nuitka)
119
+ To compile Claudon into a single native binary locally:
120
+ ```bash
121
+ pip install nuitka==4.2.2 zstandard
122
+ python scripts/build_nuitka.py --onefile
123
+ ```
124
+ Standalone executables are output to `dist/`.
125
+
126
+ ### 📦 Releases
127
+ Claudon follows **Semantic Versioning** and is released through an automated, reviewable pipeline: Conventional Commit PR titles drive an auto-generated release PR (version bump + [CHANGELOG](CHANGELOG.md)); merging it tags the release and publishes:
128
+ - **Nuitka Binaries**: Linux x86_64, macOS (arm64 and Intel) and Windows, attached to GitHub Releases with `SHA256SUMS` and build provenance attestations (`gh attestation verify <file> -R infenia/claudon`).
129
+ - **PyPI**: `uvx`, `pipx` or `pip` (trusted publishing).
130
+ - **npm**: `npx`, `pnpm dlx`, `yarn dlx` or `bunx` (with provenance).
131
+
132
+ Want pre-release builds? `uvx --prerelease allow claudon` or `npx claudon@beta`. Full process: [docs/RELEASING.md](docs/RELEASING.md).
133
+
134
+ ---
135
+
136
+ ## ⚡ Integration with Claude Code
137
+
138
+ Install Claudon as a native slash command inside Claude Code:
139
+
140
+ ```bash
141
+ claudon --install-plugin
142
+ ```
143
+
144
+ Once installed, simply type `/claudon` in any active Claude Code CLI session to instantly generate and open your analytics dashboard!
145
+
146
+ ---
147
+
148
+ ## ⚙️ Options & CLI Usage
149
+
150
+ ```bash
151
+ claudon [PATH] [-o FILE] [--redact] [--pricing FILE] [--open] [--install-plugin [--force]] [--version]
152
+ ```
153
+
154
+ `PATH` can be a `~/.claude` directory, its `projects/` subfolder, a specific project directory, or a single `.jsonl` transcript file (default: `$CLAUDE_CONFIG_DIR` if set, else `~/.claude`).
155
+
156
+ | Flag | Description |
157
+ |---|---|
158
+ | `-o FILE`, `--out FILE` | Output HTML report file path (default: `cc_report.html`) |
159
+ | `--redact` | Redact prompts, titles, project names, paths, commands, session IDs and MCP server names for safe report sharing |
160
+ | `--pricing FILE` | Custom $/MTok rates keyed by model-id substring (longest match wins): `{"claude-opus-4-1": [input, output, cache_read, write_5m, write_1h]}` |
161
+ | `--open` | Automatically open the generated HTML report in your default browser |
162
+ | `--install-plugin` | Register `/claudon` slash command in `<config dir>/commands/` |
163
+ | `--force` | With `--install-plugin`: overwrite a `claudon.md` you have edited (otherwise it is left alone) |
164
+ | `--version` | Print the version |
165
+
166
+ ---
167
+
168
+ ## 📊 What's in the Report?
169
+
170
+ - **Task Breakdown**: each human prompt through to completion.
171
+ - **Turns & Subagents**: model API calls vs. agent delegation.
172
+ - **Time, Thinking & Cost**: the breakdowns described [above](#why-claudon).
173
+ - **Bottlenecks**: failures, slow turns and loops, surfaced automatically.
174
+
175
+ The report is one self-contained `.html` file: no server, no external assets, easy to attach to an issue (use `--redact`).
176
+
177
+ ---
178
+
179
+ ## 🤝 Contributing
180
+
181
+ Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) (PR titles must be [Conventional Commits](https://www.conventionalcommits.org/)), follow the [Code of Conduct](CODE_OF_CONDUCT.md), and report vulnerabilities privately per [SECURITY.md](SECURITY.md). See the [CHANGELOG](CHANGELOG.md) for release history.
182
+
183
+ ---
184
+
185
+ ## 📄 License
186
+
187
+ Developed by **Infenia Private Limited**. MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,7 @@
1
+ claudon.py,sha256=VFSBDCqwfl4x6rjYK_orln09OhIfhkJKJNqfV1wP9yM,52840
2
+ claudon-0.2.0.dist-info/licenses/LICENSE,sha256=4EMzk--wqE38VLrrohl19UEUAOZgJeP_b0JtVRdAzJs,1064
3
+ claudon-0.2.0.dist-info/METADATA,sha256=u_3e67lVhTSkkygugQejfNR22gY2GEg_ubH-3qr2nnY,8633
4
+ claudon-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ claudon-0.2.0.dist-info/entry_points.txt,sha256=dTL_k3THiFjbh2dq5sDe1LGoILf-TyuTca3ne6eMPfs,41
6
+ claudon-0.2.0.dist-info/top_level.txt,sha256=6XxTN9uJ4VeY5f66bktSrhp10HPdP8bm5ecUsjdiblE,8
7
+ claudon-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claudon = claudon:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Infenia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ claudon
claudon.py ADDED
@@ -0,0 +1,711 @@
1
+ #!/usr/bin/env python3
2
+ """Claudon - Claude Code session analytics -> self-contained HTML dashboard.
3
+
4
+ claudon [PATH] [-o report.html] [--pricing prices.json] [--redact] [--open] [--install-plugin]
5
+
6
+ PATH: a ~/.claude dir, its projects/ dir, one project dir, or a single .jsonl
7
+ (default $CLAUDE_CONFIG_DIR, else ~/.claude).
8
+ Stdlib only, single file. Everything is derived from the transcripts; see the notes in the dashboard footer.
9
+ """
10
+ import argparse, bisect, collections, datetime as dt, json, math, os, re, sys, webbrowser
11
+ from pathlib import Path
12
+
13
+ __version__ = '0.2.0' # x-release-please-version
14
+
15
+ # $/MTok: input, output, cache_read, cache_write_5m, cache_write_1h (Anthropic list prices, 2026-09).
16
+ # Keys are model-id substrings; the longest matching key wins, so a bare family name is the
17
+ # fallback for its generations. Override or extend with --pricing.
18
+ PRICE = {
19
+ 'haiku': (1, 5, .1, 1.25, 2), # Haiku 4.5
20
+ '3-5-haiku': (.8, 4, .08, 1, 1.6),
21
+ '3-haiku': (.25, 1.25, .03, .3, .5),
22
+ 'sonnet': (3, 15, .3, 3.75, 6), # Sonnet 3.x / 4.x
23
+ 'sonnet-5': (2, 10, .2, 2.5, 4), # Sonnet 5, 5.5
24
+ 'opus': (5, 25, .5, 6.25, 10), # Opus 4.5 - 4.8, 5
25
+ 'opus-4-0': (15, 75, 1.5, 18.75, 30), 'opus-4-20': (15, 75, 1.5, 18.75, 30),
26
+ 'opus-4-1': (15, 75, 1.5, 18.75, 30), '3-opus': (15, 75, 1.5, 18.75, 30),
27
+ 'opus-5-5': (4, 20, .2, 5, 8),
28
+ 'fable': (10, 50, 1, 12.5, 20), 'mythos': (10, 50, 1, 12.5, 20), # Fable / Mythos 5
29
+ 'fable-5-1': (10, 50, .25, 12.5, 20), 'mythos-5-1': (10, 50, .25, 12.5, 20),
30
+ }
31
+ AGENT_TOOLS = {'Task', 'Agent'}
32
+ USER_TOOLS = {'AskUserQuestion', 'ExitPlanMode'}
33
+ SKIP_PREFIX = ('<task-notification', '<local-command', 'This session is being continued',
34
+ '[Request interrupted', '<system-reminder', 'Caveat:')
35
+ # built-in slash commands that only drive the CLI UI (no model call), so they don't start a task
36
+ SKIP_CMDS = {'clear', 'model', 'help', 'compact', 'config', 'resume', 'exit', 'status', 'cost',
37
+ 'login', 'logout', 'permissions', 'mcp', 'agents', 'doctor', 'hooks', 'fast', 'effort',
38
+ 'plugin', 'reload-skills', 'context', 'usage', 'ide', 'memory', 'add-dir', 'theme', 'vim',
39
+ 'terminal-setup', 'export', 'rename', 'tasks', 'bashes', 'rewind', 'sandbox', 'skills',
40
+ 'privacy-settings', 'release-notes', 'upgrade', 'output-style', 'keybindings'}
41
+ # how Claude Code marks a tool call the user rejected or interrupted (a user decision, not a tool failure)
42
+ USER_STOP = re.compile(r"doesn't want to proceed|tool use was rejected|\[Request interrupted by user", re.I)
43
+ MAX_SEGS = 1200
44
+ IDLE_SPLIT = 1800 # seconds of silence that ends a task
45
+
46
+
47
+ def price(model):
48
+ m = (model or '').lower()
49
+ key = max((k for k in PRICE if k in m), key=len, default=None)
50
+ return (PRICE[key], False) if key else ((0, 0, 0, 0, 0), True) # unknown: flagged as estimate
51
+
52
+
53
+ def ts_of(r):
54
+ s = r.get('timestamp')
55
+ if not isinstance(s, str):
56
+ return None
57
+ try:
58
+ return dt.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
59
+ except ValueError:
60
+ return None
61
+
62
+
63
+ def _finite(s):
64
+ f = float(s)
65
+ if not math.isfinite(f):
66
+ raise ValueError(f'non-finite number {s}')
67
+ return f
68
+
69
+
70
+ def _reject(s):
71
+ raise ValueError(f'invalid JSON constant {s}')
72
+
73
+
74
+ def loads(text):
75
+ """json.loads without NaN/Infinity (or floats that overflow to inf): browsers' JSON.parse rejects them."""
76
+ return json.loads(text, parse_float=_finite, parse_constant=_reject)
77
+
78
+
79
+ def num(x):
80
+ """a usable non-negative count/amount, else 0 (strings, bools, negatives and garbage count as 0)"""
81
+ return x if isinstance(x, (int, float)) and not isinstance(x, bool) and x > 0 else 0
82
+
83
+
84
+ def load(path):
85
+ out = []
86
+ with open(path, encoding='utf-8-sig', errors='replace') as f: # -sig: tolerate a UTF-8 BOM
87
+ for line in f:
88
+ try:
89
+ r = loads(line)
90
+ except ValueError:
91
+ continue
92
+ if isinstance(r, dict): # normalise shape so callers can index freely
93
+ r.setdefault('type', None)
94
+ if not isinstance(r.get('message'), dict):
95
+ r['message'] = {}
96
+ out.append(r)
97
+ return out
98
+
99
+
100
+ def text_of(c):
101
+ if isinstance(c, str):
102
+ return c
103
+ if not isinstance(c, list):
104
+ return ''
105
+ return ' '.join(str(b.get('text', '')) for b in c if isinstance(b, dict) and b.get('type') == 'text')
106
+
107
+
108
+ def prompt_text(r):
109
+ """Human prompt that starts a task, else None."""
110
+ if r.get('type') != 'user' or r.get('isSidechain') or r.get('isMeta') or r.get('isCompactSummary'):
111
+ return None
112
+ origin = r.get('origin') if isinstance(r.get('origin'), dict) else {}
113
+ if r.get('turnOrigin') == 'task_notification' or origin.get('kind') == 'task-notification':
114
+ return None
115
+ c = (r.get('message') or {}).get('content')
116
+ if isinstance(c, list) and any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in c):
117
+ return None
118
+ t = text_of(c).strip()
119
+ if not t or t.startswith(SKIP_PREFIX):
120
+ return None
121
+ # only a record that *is* a slash command (either tag may come first), not a prompt quoting the markup
122
+ m = re.search(r'<command-name>/?([\w:-]+)', t) if t.startswith(('<command-name>', '<command-message>')) else None
123
+ if m:
124
+ if m.group(1) in SKIP_CMDS:
125
+ return None
126
+ args = re.search(r'<command-args>(.*?)</command-args>', t, re.S)
127
+ return f"/{m.group(1)} {args.group(1).strip() if args else ''}".strip()
128
+ return t
129
+
130
+
131
+ TOKEN_KEYS = ('input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens')
132
+
133
+
134
+ def toks(d):
135
+ """usage-shaped dict -> dict(i, o, cr, c5, c1) with the cache-write split into 5m / 1h."""
136
+ cc = d.get('cache_creation') if isinstance(d.get('cache_creation'), dict) else {}
137
+ cc1, cc5 = num(cc.get('ephemeral_1h_input_tokens')), num(cc.get('ephemeral_5m_input_tokens'))
138
+ total_cc = num(d.get('cache_creation_input_tokens'))
139
+ if cc1 + cc5 != total_cc:
140
+ cc1 = min(cc1, total_cc)
141
+ cc5 = total_cc - cc1
142
+ return dict(i=num(d.get('input_tokens')), o=num(d.get('output_tokens')),
143
+ cr=num(d.get('cache_read_input_tokens')), c5=cc5, c1=cc1)
144
+
145
+
146
+ def usage_of(recs):
147
+ usages = [u if isinstance(u := r['message'].get('usage'), dict) else {} for r in recs]
148
+ best = max(usages, key=lambda u: sum(num(u.get(k)) for k in TOKEN_KEYS))
149
+ # streamed records accumulate iterations[]; the longest list is the complete one
150
+ its = max([u['iterations'] for u in usages if isinstance(u.get('iterations'), list)] or [[]], key=len)
151
+ its = [i for i in its if isinstance(i, dict)]
152
+ if not sum(num(best.get(k)) for k in TOKEN_KEYS): # some logs keep real numbers only in iterations[]
153
+ main = [i for i in its if i.get('type', 'message') == 'message']
154
+ best = {k: sum(num(i.get(k)) for i in main) for k in TOKEN_KEYS} | {'cache_creation': main[-1].get('cache_creation') if main else None}
155
+ think = max(num(d.get('thinking_tokens')) if isinstance(d := u.get('output_tokens_details'), dict) else 0 for u in usages)
156
+ # advisor sub-calls are billed on top of the top-level usage, which covers only the executor
157
+ adv = [(i['model'] if isinstance(i.get('model'), str) else '?', toks(i)) for i in its if i.get('type') == 'advisor_message']
158
+ return toks(best) | dict(think=think, adv=adv, fast=best.get('speed') == 'fast')
159
+
160
+
161
+ def cost_mix(u, model):
162
+ """-> ((fresh_in, out, cache_read, cache_write) in $, is_estimate)"""
163
+ p, est = price(model)
164
+ if u.get('fast'): # fast mode: 2x list price (documented for Opus 5 / 5.5)
165
+ p, est = tuple(2 * x for x in p), est or 'opus-5' not in (model or '').lower()
166
+ return (u['i'] * p[0] / 1e6, u['o'] * p[1] / 1e6, u['cr'] * p[2] / 1e6, (u['c5'] * p[3] + u['c1'] * p[4]) / 1e6), est
167
+
168
+
169
+ def tool_desc(inp):
170
+ if not isinstance(inp, dict):
171
+ return ''
172
+ for k in ('command', 'file_path', 'pattern', 'description', 'prompt', 'query', 'url', 'path'):
173
+ if inp.get(k):
174
+ return str(inp[k]).replace('\n', ' ')[:110]
175
+ return ''
176
+
177
+
178
+ def ulen(iv):
179
+ tot, s, e = 0, None, None
180
+ for a, b in sorted(iv):
181
+ if e is None or a > e:
182
+ if e is not None:
183
+ tot += e - s
184
+ s, e = a, b
185
+ else:
186
+ e = max(e, b)
187
+ return tot + (e - s if e is not None else 0)
188
+
189
+
190
+ def tok_sum(calls, k):
191
+ """token total over calls, advisor sub-calls included"""
192
+ return sum(c['u'][k] + sum(au[k] for _, au, _ in c['adv']) for c in calls)
193
+
194
+
195
+ def split(wall, u_m, u_t, u_a, u_u):
196
+ """Rounded time split whose parts never sum past the rounded wall time."""
197
+ parts = dict(model_s=round(u_m, 2), tool_s=round(u_t - u_m, 2), agent_s=round(u_a - u_t, 2), user_s=round(u_u - u_a, 2))
198
+ busy = round(sum(parts.values()), 2)
199
+ w = max(round(wall, 2), busy)
200
+ return dict(wall=w, wait_s=round(w - busy, 2), **parts)
201
+
202
+
203
+ def analyze_session(sid, proj, files, seen_msgs):
204
+ """files: [(path, is_sub)]. Returns (session dict, [task dicts])."""
205
+ recs = []
206
+ for p, is_sub in files:
207
+ for r in load(p):
208
+ r['_sub'] = is_sub or bool(r.get('isSidechain'))
209
+ r['_ts'] = ts_of(r)
210
+ recs.append(r)
211
+ uuid_ts = {r['uuid']: r['_ts'] for r in recs if r.get('uuid') and r['_ts']}
212
+ parent_of = {r['uuid']: r.get('parentUuid') for r in recs if r.get('uuid')}
213
+
214
+ # --- API calls (assistant records are streamed one content block per record) ---
215
+ by_msg = collections.OrderedDict()
216
+ for r in recs:
217
+ if r['type'] == 'assistant' and r['_ts'] and isinstance(r['message'].get('id'), str) and r['message'].get('model') != '<synthetic>':
218
+ by_msg.setdefault(r['message']['id'], []).append(r)
219
+ calls, results = [], {}
220
+ for r in recs: # tool_result lookup
221
+ c = (r.get('message') or {}).get('content')
222
+ if r['type'] == 'user' and isinstance(c, list) and r['_ts']:
223
+ for b in c:
224
+ if isinstance(b, dict) and b.get('type') == 'tool_result' and isinstance(b.get('tool_use_id'), str):
225
+ stopped = bool(b.get('is_error')) and bool(USER_STOP.search(str(b.get('content'))[:300]))
226
+ results[b.get('tool_use_id')] = (r['_ts'], bool(b.get('is_error')) and not stopped, stopped)
227
+ for mid, rs in by_msg.items():
228
+ if mid in seen_msgs: # forked/resumed sessions copy history
229
+ continue
230
+ rs.sort(key=lambda r: r['_ts'])
231
+ first = rs[0]
232
+ # request start = the parent record, skipping parents stamped as the response arrived
233
+ # (e.g. `deferred_tools_record` attachments), which would zero out the call's duration
234
+ start, u = None, first.get('parentUuid')
235
+ for _ in range(10):
236
+ t = uuid_ts.get(u)
237
+ if t is None or t < first['_ts'] - 0.05:
238
+ start = t
239
+ break
240
+ u = parent_of.get(u)
241
+ start = min(start or first['_ts'], first['_ts'])
242
+ prev, think_s, tools = start, 0.0, []
243
+ for r in rs:
244
+ blocks = r['message'].get('content') if isinstance(r['message'].get('content'), list) else []
245
+ if any(b.get('type') == 'thinking' for b in blocks if isinstance(b, dict)):
246
+ think_s += r['_ts'] - prev
247
+ prev = r['_ts']
248
+ for b in blocks:
249
+ if isinstance(b, dict) and b.get('type') == 'tool_use':
250
+ tid, name = b.get('id'), b.get('name')
251
+ tools.append(dict(id=tid if isinstance(tid, str) else None, name=name if isinstance(name, str) and name else '?',
252
+ ts=r['_ts'], desc=tool_desc(b.get('input'))))
253
+ u = usage_of(rs)
254
+ model = rs[0]['message'].get('model')
255
+ model = model if isinstance(model, str) else None
256
+ mix, est = cost_mix(u, model)
257
+ adv = []
258
+ for am, au in u['adv']:
259
+ amix, aest = cost_mix(au, am)
260
+ adv.append((am, au, sum(amix)))
261
+ mix, est = tuple(a + b for a, b in zip(mix, amix)), est or aest
262
+ calls.append(dict(adv=adv, mid=mid, start=start, end=rs[-1]['_ts'], think_s=think_s, model=model or '?', u=u, cost=sum(mix), mix=mix,
263
+ est=est, sub=first['_sub'], tools=tools, eff=first.get('effort'),
264
+ ctx=u['i'] + u['cr'] + u['c5'] + u['c1']))
265
+ seen_msgs.update(c['mid'] for c in calls)
266
+
267
+ # --- tasks = human prompts in the main transcript ---
268
+ prompts = [(r['_ts'], t) for r in recs if not r['_sub'] and r['_ts'] and (t := prompt_text(r))]
269
+ prompts.sort()
270
+ first_call = min((c['start'] for c in calls), default=None)
271
+ if first_call is not None and (not prompts or first_call < prompts[0][0] - 1):
272
+ prompts.insert(0, (first_call, '(session start / resumed without a prompt)'))
273
+ # activity resuming after a long gap with no new prompt (e.g. background wake-ups) is its own task
274
+ times = sorted(r['_ts'] for r in recs if r['_ts'] and r['type'] in ('assistant', 'user'))
275
+ pts = sorted(p[0] for p in prompts)
276
+ for a, b in zip(times, times[1:]):
277
+ if b - a > IDLE_SPLIT and not any(abs(b - x) < 2 for x in pts):
278
+ prompts.append((b, '(resumed activity, no new prompt)'))
279
+ prompts.sort()
280
+ if not prompts:
281
+ return None, []
282
+ starts = [p[0] for p in prompts]
283
+ idx = lambda t: max(0, bisect.bisect_right(starts, t) - 1)
284
+ T = [dict(start=s, prompt=t, calls=[], ends=[s], compacts=[], interrupts=0) for s, t in prompts]
285
+ for c in calls:
286
+ T[idx(c['start'])]['calls'].append(c)
287
+ for r in recs:
288
+ if r['_ts'] and r['type'] in ('assistant', 'user'):
289
+ T[idx(r['_ts'])]['ends'].append(r['_ts'])
290
+ if r['type'] == 'system' and r.get('subtype') == 'compact_boundary' and r['_ts']:
291
+ m = r.get('compactMetadata') or {}
292
+ m = m if isinstance(m, dict) else {}
293
+ T[idx(r['_ts'])]['compacts'].append((r['_ts'], num(m.get('durationMs')) / 1000, num(m.get('preTokens')), num(m.get('postTokens'))))
294
+ title = ''
295
+ for r in recs:
296
+ if r['type'] in ('ai-title', 'custom-title'):
297
+ title = r.get('aiTitle') or r.get('customTitle') or title
298
+ cwd = next((r['cwd'] for r in recs if isinstance(r.get('cwd'), str) and r['cwd']), '')
299
+ reported = max((num(r.get('totalCostUSD')) for r in recs if r['type'] == 'cost-state'), default=0)
300
+
301
+ tasks = []
302
+ for n, t in enumerate(T):
303
+ cs = t['calls']
304
+ if not cs:
305
+ continue
306
+ t0, t1 = t['start'], max(max(t['ends']), max(c['end'] for c in cs))
307
+ wall = max(t1 - t0, 0.01) # stays > 0 after round(wall, 2)
308
+ clip = lambda a, b: (max(a, t0), min(b, t1))
309
+ mi, ti, ai, ui, tool_stats, tcalls, segs = [], [], [], [], {}, [], []
310
+ for c in cs:
311
+ mi.append(clip(c['start'], c['end']))
312
+ segs.append(['ms' if c['sub'] else 'm', round(c['start'] - t0, 2), round(c['end'] - c['start'], 2),
313
+ c['model'], round(c['think_s'], 2), c['u']['o']])
314
+ for tu in c['tools']:
315
+ res = results.get(tu['id'])
316
+ st = tool_stats.setdefault(tu['name'], [0, 0, 0.0, 0.0, 0]) # n, err, sum, max, interrupted
317
+ st[0] += 1
318
+ if not res:
319
+ continue
320
+ d = max(0.0, res[0] - tu['ts'])
321
+ st[1] += res[1]; st[2] += d; st[3] = max(st[3], d); st[4] += res[2]
322
+ (ai if tu['name'] in AGENT_TOOLS else ui if tu['name'] in USER_TOOLS else ti).append(clip(tu['ts'], res[0]))
323
+ segs.append(['ts' if c['sub'] else 'te' if res[1] else 't', round(tu['ts'] - t0, 2), round(d, 2), tu['name'], tu['desc'], 0])
324
+ tcalls.append([tu['name'], round(d, 1), tu['desc'], int(res[1])])
325
+ for ct, dur, pre, post in t['compacts']:
326
+ mi.append(clip(ct - dur, ct))
327
+ mi, ti, ai, ui = [[x for x in l if x[1] > x[0]] for l in (mi, ti, ai, ui)]
328
+ u_m = ulen(mi); u_t = ulen(mi + ti); u_a = ulen(mi + ti + ai); u_u = ulen(mi + ti + ai + ui)
329
+ main = [c for c in cs if not c['sub']]
330
+ models = {}
331
+ for c in cs:
332
+ m = models.setdefault(c['model'], [0, 0.0, 0, 0, 0.0, 0.0, 0, 0, 0]) # calls,cost,out,think_tok,dur,think_s,in,cr,cc
333
+ m[0] += 1; m[1] += c['cost']; m[2] += c['u']['o']; m[3] += c['u']['think']; m[4] += c['end'] - c['start']
334
+ m[5] += c['think_s']; m[6] += c['u']['i']; m[7] += c['u']['cr']; m[8] += c['u']['c5'] + c['u']['c1']
335
+ m[1] -= sum(x[2] for x in c['adv']) # advisor share is booked on its own row
336
+ for am, au, acost in c['adv']:
337
+ a = models.setdefault(f'{am} (advisor)', [0, 0.0, 0, 0, 0.0, 0.0, 0, 0, 0])
338
+ a[0] += 1; a[1] += acost; a[2] += au['o']; a[6] += au['i']; a[7] += au['cr']; a[8] += au['c5'] + au['c1']
339
+ if len(segs) > MAX_SEGS:
340
+ segs = sorted(sorted(segs, key=lambda s: -s[2])[:MAX_SEGS], key=lambda s: s[1])
341
+ ctx = [[round(c['start'] - t0, 1), c['ctx']] for c in sorted(main, key=lambda c: c['start'])]
342
+ if len(ctx) > 200:
343
+ ctx = ctx[::len(ctx) // 200 + 1]
344
+ tcalls.sort(key=lambda x: -x[1])
345
+ tasks.append(dict(
346
+ id=f'{sid}#{n}', sid=sid, proj=proj, title=title, prompt=t['prompt'][:600], start=t0, **split(wall, u_m, u_t, u_a, u_u),
347
+ calls=len(main), sub_calls=len(cs) - len(main), tools=sum(len(c['tools']) for c in cs),
348
+ think_calls=sum(1 for c in cs if c['think_s'] > 0), think_s=round(sum(c['think_s'] for c in cs), 2),
349
+ think_tok=sum(c['u']['think'] for c in cs), out_tok=tok_sum(cs, 'o'),
350
+ in_tok=tok_sum(cs, 'i'), cr_tok=tok_sum(cs, 'cr'), cc_tok=tok_sum(cs, 'c5') + tok_sum(cs, 'c1'),
351
+ cost=round(sum(c['cost'] for c in cs), 5), cost_mix=[round(sum(c['mix'][i] for c in cs), 5) for i in range(4)],
352
+ sub_cost=round(sum(c['cost'] for c in cs if c['sub']), 5),
353
+ est=any(c['est'] for c in cs), ctx_max=max(c['ctx'] for c in cs), compacts=[[round(ct - t0, 1), dur, pre, post] for ct, dur, pre, post in t['compacts']],
354
+ models=models, tool_stats=tool_stats, slow=tcalls, segs=segs, ctx=ctx,
355
+ efforts=sorted({str(c['eff']) for c in cs if c['eff']})))
356
+ if not tasks:
357
+ return None, []
358
+ sess = dict(id=sid, proj=proj, cwd=cwd, title=title, start=min(t['start'] for t in tasks),
359
+ end=max(t['start'] + t['wall'] for t in tasks), tasks=len(tasks), reported_cost=round(reported, 4),
360
+ est_cost=round(sum(t['cost'] for t in tasks), 4))
361
+ return sess, tasks
362
+
363
+
364
+ def discover(root):
365
+ root = Path(root).expanduser()
366
+ if root.is_file():
367
+ return root.parent, [root]
368
+ base = root / 'projects' if (root / 'projects').is_dir() else root
369
+ # is_file(): skips directories named *.jsonl and broken symlinks
370
+ return base, sorted(p for p in base.rglob('*.jsonl') if 'tool-results' not in p.parts and p.is_file())
371
+
372
+
373
+ def first_session_id(path):
374
+ """sessionId of the first record that has one; stops reading at the first match."""
375
+ try:
376
+ with open(path, encoding='utf-8-sig', errors='replace') as f:
377
+ for line in f:
378
+ if 'sessionId' not in line:
379
+ continue
380
+ try:
381
+ r = loads(line)
382
+ except ValueError:
383
+ continue
384
+ if isinstance(r, dict) and isinstance(r.get('sessionId'), str):
385
+ return r['sessionId']
386
+ except OSError:
387
+ # unreadable here -> treat as an original session; build() reports the read error when it analyses it
388
+ return None
389
+ return None
390
+
391
+
392
+ def copy_last(item):
393
+ """Sort key: originals before forked/resumed copies, so shared history is credited to the original.
394
+ A copy's leading records carry the source session's id; file mtime only breaks ties."""
395
+ (proj, sid), files = item
396
+ main = [p for p, is_sub in files if not is_sub]
397
+ is_copy = bool(main) and first_session_id(main[0]) not in (None, sid)
398
+ return is_copy, min(p.stat().st_mtime for p, _ in files)
399
+
400
+
401
+ def build(root):
402
+ base, paths = discover(root)
403
+ groups = collections.defaultdict(list) # (project, session id) -> files
404
+ for p in paths:
405
+ parts = p.relative_to(base).parts
406
+ sub = 'subagents' in parts
407
+ i = parts.index('subagents') if sub else None
408
+ sid = (parts[i - 1] if i else base.name) if sub else p.stem # i == 0: PATH is the <session>/ dir itself
409
+ depth = parts.index('subagents') - 1 if sub else len(parts) - 1 # dirs above the session
410
+ proj = parts[0] if depth >= 1 else base.name # base may itself be one project
411
+ groups[(proj, sid)].append((p, sub))
412
+ sessions, tasks, seen, keys = [], [], set(), set()
413
+ for (proj, sid), files in sorted(groups.items(), key=copy_last):
414
+ key = sid if sid not in keys else f'{sid}@{proj}' # same session id under two projects: keep task ids unique
415
+ keys.add(key)
416
+ try:
417
+ s, ts = analyze_session(key, proj, sorted(files, key=lambda f: f[1]), seen)
418
+ except Exception as e: # one unreadable/malformed session must not sink the report
419
+ print(f'warning: skipped session {sid} in {proj}: {type(e).__name__}: {e}', file=sys.stderr)
420
+ continue
421
+ if s:
422
+ sessions.append(s); tasks += ts
423
+ for s in sessions: # friendlier project label from cwd
424
+ s['label'] = Path(s['cwd'].replace('\\', '/')).name or s['proj']
425
+ lab = {s['id']: s['label'] for s in sessions}
426
+ for t in tasks:
427
+ t['proj'] = lab.get(t['sid'], t['proj'])
428
+ tasks.sort(key=lambda t: t['start'])
429
+ return dict(generated=dt.datetime.now().isoformat(timespec='seconds'), root=str(base), files=len(paths),
430
+ price=PRICE, sessions=sessions, tasks=tasks)
431
+
432
+
433
+ def redact(d):
434
+ names, servers, sids = {}, {}, {}
435
+
436
+ def tool(n): # mcp__<server>__<tool>: server names can be internal
437
+ parts = n.split('__') if isinstance(n, str) else []
438
+ if len(parts) < 2 or parts[0] != 'mcp':
439
+ return n
440
+ server, rest = ('__'.join(parts[1:-1]), f'__{parts[-1]}') if len(parts) > 2 else (parts[1], '')
441
+ return f"mcp__{servers.setdefault(server, f'server-{len(servers) + 1}')}{rest}" # server names may contain '__'
442
+
443
+ for t in d['tasks']:
444
+ t['proj'] = names.setdefault(t['proj'], f'project-{len(names) + 1}')
445
+ t['sid'] = sids.setdefault(t['sid'], f'session-{len(sids) + 1}')
446
+ t['id'] = f"{t['sid']}#{t['id'].rsplit('#', 1)[1]}"
447
+ t['prompt'] = f"Task {t['id']}"; t['title'] = ''
448
+ t['slow'] = [[tool(n), dur, '', e] for n, dur, _, e in t['slow']]
449
+ t['tool_stats'] = {tool(k): v for k, v in t['tool_stats'].items()}
450
+ for sg in t['segs']:
451
+ if sg[0] not in ('m', 'ms'):
452
+ sg[3], sg[4] = tool(sg[3]), ''
453
+ for x in d['sessions']:
454
+ x['cwd'] = x['title'] = ''; x['label'] = x['proj'] = names.get(x['label'], '')
455
+ x['id'] = sids.get(x['id'], '')
456
+ d['root'] = '(redacted)'
457
+
458
+
459
+ def render_html(data):
460
+ # escape '<' so transcript text like '</script>' can't close the embedded JSON block
461
+ payload = json.dumps(data, separators=(',', ':'), allow_nan=False) # NaN would break JSON.parse
462
+ return TEMPLATE.replace('__DATA__', payload.replace('<', '\\u003c'))
463
+
464
+
465
+ def config_dir():
466
+ return Path(os.environ.get('CLAUDE_CONFIG_DIR') or '~/.claude').expanduser()
467
+
468
+
469
+ def load_pricing(path):
470
+ """--pricing file: {"model-substring": [in, out, cache_read, write_5m, write_1h]} in $/MTok."""
471
+ try:
472
+ raw = loads(Path(path).read_text(encoding='utf-8-sig'))
473
+ if not isinstance(raw, dict):
474
+ raise ValueError('expected a JSON object')
475
+ out = {}
476
+ for k, v in raw.items():
477
+ if not (isinstance(v, list) and len(v) == 5
478
+ and all(isinstance(x, (int, float)) and not isinstance(x, bool) and 0 <= x < 1e6 for x in v)):
479
+ raise ValueError(f'{k!r}: expected a list of 5 prices, each a number from 0 to 1e6 $/MTok')
480
+ out[k.lower()] = tuple(v)
481
+ return out
482
+ except (OSError, ValueError) as e:
483
+ sys.exit(f'--pricing {path}: {e}')
484
+
485
+
486
+ def install_plugin(force=False):
487
+ cmd_dir = config_dir() / 'commands'
488
+ cmd_dir.mkdir(parents=True, exist_ok=True)
489
+ plugin_file = cmd_dir / 'claudon.md'
490
+ plugin_content = (
491
+ "---\n"
492
+ "description: Generate and open interactive Claudon analytics dashboard for Claude Code sessions\n"
493
+ "---\n\n"
494
+ "Run `claudon -o cc_report.html --open` (or `npx claudon -o cc_report.html --open`) to analyze sessions.\n"
495
+ )
496
+ if plugin_file.exists():
497
+ if plugin_file.read_text(encoding='utf-8') == plugin_content:
498
+ print(f"Claudon slash command is already up to date at {plugin_file}")
499
+ return
500
+ if not force:
501
+ sys.exit(f"{plugin_file} already exists with different content; "
502
+ "re-run with --install-plugin --force to overwrite it")
503
+ plugin_file.write_text(plugin_content, encoding='utf-8')
504
+ print(f"Successfully installed Claudon slash command to {plugin_file}")
505
+ print("You can now type /claudon inside Claude Code to launch analytics!")
506
+
507
+
508
+ def main():
509
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
510
+ ap.add_argument('path', nargs='?', default=str(config_dir()))
511
+ ap.add_argument('--version', action='version', version=f'claudon {__version__}')
512
+ ap.add_argument('-o', '--out', default='cc_report.html')
513
+ ap.add_argument('--pricing', help='JSON {"model-substring": [in, out, cache_read, write_5m, write_1h]} $/MTok; longest match wins')
514
+ ap.add_argument('--redact', action='store_true', help='strip prompts, titles, paths, commands and project names (safe to share)')
515
+ ap.add_argument('--open', action='store_true')
516
+ ap.add_argument('--install-plugin', action='store_true', help='install /claudon slash command into <config dir>/commands/')
517
+ ap.add_argument('--force', action='store_true', help='with --install-plugin: overwrite a modified claudon.md')
518
+ a = ap.parse_args()
519
+ if a.install_plugin:
520
+ install_plugin(a.force)
521
+ return
522
+ if a.pricing:
523
+ PRICE.update(load_pricing(a.pricing))
524
+ data = build(a.path)
525
+ if not data['tasks']:
526
+ sys.exit(f'no analysable sessions under {a.path}')
527
+ if a.redact:
528
+ redact(data)
529
+ out = Path(a.out)
530
+ try:
531
+ out.parent.mkdir(parents=True, exist_ok=True)
532
+ out.write_text(render_html(data), encoding='utf-8')
533
+ except OSError as e:
534
+ sys.exit(f'cannot write {out}: {e}')
535
+ print(f'{len(data["sessions"])} sessions, {len(data["tasks"])} tasks from {data["files"]} files -> {a.out}')
536
+ if a.open:
537
+ webbrowser.open(Path(a.out).resolve().as_uri())
538
+
539
+
540
+
541
+ TEMPLATE = r'''<!doctype html>
542
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
543
+ <title>Claudon</title>
544
+ <style>
545
+ :root{--bg:#f6f7f9;--card:#fff;--fg:#1c2330;--mute:#6b7585;--line:#e3e6ec;--model:#3b82f6;--think:#8b5cf6;--tool:#f59e0b;--agent:#14b8a6;--user:#ec4899;--wait:#cbd2dc;--err:#ef4444;--ok:#10b981}
546
+ @media(prefers-color-scheme:dark){:root{--bg:#10131a;--card:#181c26;--fg:#e6e9ef;--mute:#8d97a8;--line:#272d3a;--wait:#3a4252}}
547
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
548
+ header{position:sticky;top:0;z-index:5;background:var(--card);border-bottom:1px solid var(--line);padding:10px 20px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
549
+ h1{font-size:16px;margin:0 8px 0 0}nav button,select,input{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:5px 11px}
550
+ nav button{cursor:pointer}nav button.on{background:var(--model);border-color:var(--model);color:#fff}
551
+ main{padding:20px;max-width:1500px;margin:auto}
552
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px}
553
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin-bottom:16px;overflow-x:auto}
554
+ .grid .card{margin:0}.k{color:var(--mute);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.v{font-size:24px;font-weight:600}.s{color:var(--mute);font-size:12px}
555
+ h2{font-size:14px;margin:0 0 10px}.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(420px,100%),1fr));gap:16px}
556
+ table{border-collapse:collapse;width:100%}th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}
557
+ th{color:var(--mute);font-weight:500;font-size:12px;cursor:pointer;white-space:nowrap;user-select:none}th:hover{color:var(--fg)}
558
+ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}tr.c{cursor:pointer}tr.c:hover{background:var(--bg)}
559
+ .pr{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
560
+ .bar{display:flex;height:10px;border-radius:5px;overflow:hidden;background:var(--wait);min-width:110px}.bar i{display:block}
561
+ .m{background:var(--model)}.th{background:var(--think)}.t{background:var(--tool)}.a{background:var(--agent)}.u{background:var(--user)}.w{background:var(--wait)}.e{background:var(--err)}
562
+ .leg span{margin-right:14px;font-size:12px;color:var(--mute)}.leg b{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px}
563
+ .hb{height:8px;background:var(--model);border-radius:4px;min-width:1px}
564
+ .days{display:flex;align-items:flex-end;gap:2px;height:110px}.days div{flex:1;background:var(--model);min-width:2px;border-radius:2px 2px 0 0}
565
+ .find{border-left:4px solid var(--tool);padding:6px 12px;margin:10px 0;background:var(--bg);border-radius:0 6px 6px 0}.find.hi{border-color:var(--err)}.find.lo{border-color:var(--model)}
566
+ .find b{display:block}.chip{display:inline-block;margin:4px 6px 0 0;padding:1px 8px;border:1px solid var(--line);border-radius:10px;font-size:12px;cursor:pointer;background:var(--card)}.chip:hover{border-color:var(--model)}
567
+ #modal{position:fixed;inset:0;background:#0008;display:none;z-index:10;overflow:auto;padding:30px}#modal>div{background:var(--card);max-width:1200px;margin:auto;border-radius:12px;padding:20px;position:relative}
568
+ #x{position:absolute;right:14px;top:10px;cursor:pointer;font-size:22px;color:var(--mute)}
569
+ .gantt{position:relative;border:1px solid var(--line);border-radius:6px;margin:8px 0 4px;background:var(--bg)}.lane{position:relative;height:24px;border-bottom:1px dashed var(--line)}
570
+ .lane:last-child{border:0}.lane span{position:absolute;top:3px;height:18px;border-radius:3px;min-width:2px;opacity:.9}.lane label{position:absolute;left:4px;top:4px;font-size:11px;color:var(--mute);z-index:1;pointer-events:none}
571
+ .axis{display:flex;justify-content:space-between;color:var(--mute);font-size:11px}.pre{white-space:pre-wrap;background:var(--bg);padding:10px;border-radius:6px;max-height:160px;overflow:auto;font-size:12px}
572
+ footer{color:var(--mute);font-size:12px;line-height:1.5;padding:16px 20px 40px;max-width:1500px;margin:auto;border-top:1px solid var(--line)}
573
+ footer .meta{margin-bottom:12px}footer .notes{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px 28px}
574
+ footer .notes>div>b:first-child{display:block;color:var(--fg);font-size:12px;margin-bottom:2px}footer b{color:var(--fg);font-weight:600}
575
+ footer details{margin-top:14px}footer summary{cursor:pointer;color:var(--fg)}footer details table{width:auto;margin-top:8px}footer details th,footer details td{font-size:12px;padding:3px 12px 3px 0;cursor:default}code{background:var(--bg);padding:1px 5px;border-radius:4px}
576
+ .more{display:flex;gap:8px;align-items:center;margin-top:8px}.more button{font:inherit;font-size:12px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:3px 10px;cursor:pointer}.more button:hover{border-color:var(--model)}
577
+ .tip{color:var(--mute);cursor:help;border-bottom:1px dotted}
578
+ </style></head><body>
579
+ <header><h1>Claudon</h1>
580
+ <nav id="nav"></nav><span style="flex:1"></span>
581
+ <select id="fp"></select><input id="fq" placeholder="search prompts…" size="22"></header>
582
+ <main id="view"></main><div id="modal"><div><span id="x">×</span><div id="mbody"></div></div></div>
583
+ <footer id="foot"></footer>
584
+ <script id="d" type="application/json">__DATA__</script>
585
+ <script>
586
+ window.onerror=(m,u,l)=>{const e=document.createElement('pre');e.style.cssText='color:#c00;padding:16px';e.textContent='Dashboard error: '+m+' (line '+l+')';document.body.prepend(e)};
587
+ const D=JSON.parse(document.getElementById('d').textContent);
588
+ const $=s=>document.querySelector(s),esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
589
+ const T=s=>{s=Math.round(s);if(s<60)return s+'s';const m=Math.floor(s/60);if(m<60)return m+'m '+s%60+'s';return Math.floor(m/60)+'h '+m%60+'m'};
590
+ const N=n=>n>=1e9?(n/1e9).toFixed(2)+'B':n>=1e6?(n/1e6).toFixed(2)+'M':n>=1e3?(n/1e3).toFixed(1)+'k':String(Math.round(n));
591
+ const $$=n=>'$'+(n>=100?n.toFixed(0):n.toFixed(2)),P=x=>isFinite(x)?(x*100).toFixed(0)+'%':'–';
592
+ const sum=(a,f)=>a.reduce((s,x)=>s+f(x),0);
593
+ const Q=(a,p)=>{if(!a.length)return 0;a=[...a].sort((x,y)=>x-y);return a[Math.min(a.length-1,Math.floor(p*a.length))]};
594
+ const date=t=>new Date(t*1000).toISOString().slice(0,10);
595
+ const sn=m=>m.replace('claude-','').replace(/-20\d{6}$/,'');
596
+ const tabs=['Overview','Tasks','Tools','Models & thinking','Bottlenecks'];let cur,tab=0,sorts={},F=[];
597
+ $('#nav').innerHTML=tabs.map((t,i)=>`<button data-t="${i}">${t}</button>`).join('');
598
+ const projs=[...new Set(D.tasks.map(t=>t.proj))].sort();
599
+ $('#fp').innerHTML='<option value="">All projects ('+projs.length+')</option>'+projs.map(p=>`<option>${esc(p)}</option>`).join('');
600
+ function filt(){const p=$('#fp').value,q=$('#fq').value.toLowerCase();F=D.tasks.filter(t=>(!p||t.proj==p)&&(!q||(t.prompt+t.title).toLowerCase().includes(q)))}
601
+ const bar=t=>{const w=t.wall||1,seg=(k,c)=>`<i class="${c}" style="width:${t[k]/w*100}%" title="${c}"></i>`;
602
+ return `<div class="bar" title="model ${T(t.model_s)} · tools ${T(t.tool_s)} · subagent ${T(t.agent_s)} · user ${T(t.user_s)} · idle ${T(t.wait_s)}">${seg('model_s','m')}${seg('tool_s','t')}${seg('agent_s','a')}${seg('user_s','u')}</div>`};
603
+ const legend='<div class="leg"><span><b class="m"></b>model</span><span><b class="th"></b>thinking</span><span><b class="t"></b>tools</span><span><b class="a"></b>subagent wait</span><span><b class="u"></b>user prompts</span><span><b class="w"></b>idle / waiting</span></div>';
604
+ const shown={}; // table id -> rows currently shown ("show more" grows it by the table's page size)
605
+ function tbl(id,cols,rows,limit=200,click){
606
+ const s=sorts[id]||(sorts[id]={i:cols.findIndex(c=>c.d),d:-1}),n=shown[id]||limit;
607
+ if(s.i>=0){const c=cols[s.i];rows=[...rows].sort((a,b)=>{const x=c.v(a),y=c.v(b);return (x>y?1:x<y?-1:0)*s.d})}
608
+ return `<table><tr>${cols.map((c,i)=>`<th class="${c.n?'n':''}" data-s="${id}|${i}">${c.h}${s.i==i?(s.d<0?' ▾':' ▴'):''}</th>`).join('')}</tr>`+
609
+ rows.slice(0,n).map(r=>`<tr class="${click?'c':''}" ${click?`data-task="${esc(click(r))}"`:''}>${cols.map(c=>`<td class="${c.n?'n':''}">${c.f(r)}</td>`).join('')}</tr>`).join('')+'</table>'+
610
+ (rows.length>n?`<div class="more s">showing ${n} of ${rows.length}<button data-more="${id}|${limit}">show ${Math.min(limit,rows.length-n)} more</button><button data-more="${id}|all">show all</button></div>`:'')}
611
+ const kpi=(k,v,s='')=>`<div class="card"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${s}</div></div>`;
612
+ const A=a=>({wall:sum(a,t=>t.wall),model:sum(a,t=>t.model_s),tool:sum(a,t=>t.tool_s),agent:sum(a,t=>t.agent_s),user:sum(a,t=>t.user_s),wait:sum(a,t=>t.wait_s),
613
+ calls:sum(a,t=>t.calls),sub:sum(a,t=>t.sub_calls),tools:sum(a,t=>t.tools),cost:sum(a,t=>t.cost),think:sum(a,t=>t.think_s),thinkTok:sum(a,t=>t.think_tok),
614
+ out:sum(a,t=>t.out_tok),inp:sum(a,t=>t.in_tok),cr:sum(a,t=>t.cr_tok),cc:sum(a,t=>t.cc_tok)});
615
+ function toolAgg(a){const m=Object.create(null);a.forEach(t=>Object.entries(t.tool_stats).forEach(([k,v])=>{const x=m[k]||(m[k]=[k,0,0,0,0,0]);x[1]+=v[0];x[2]+=v[1];x[3]+=v[2];x[4]=Math.max(x[4],v[3]);x[5]+=v[4]}));return Object.values(m)}
616
+ function modelAgg(a){const m=Object.create(null);a.forEach(t=>Object.entries(t.models).forEach(([k,v])=>{const x=m[k]||(m[k]=[k,0,0,0,0,0,0,0,0,0]);for(let i=0;i<9;i++)x[i+1]+=v[i]}));return Object.values(m)}
617
+
618
+ function overview(){const a=A(F),sess=new Set(F.map(t=>t.sid)).size,cacheHit=a.cr/Math.max(1,a.cr+a.inp+a.cc);
619
+ const days={};F.forEach(t=>days[date(t.start)]=(days[date(t.start)]||0)+t.cost);const dk=Object.keys(days).sort(),mx=Math.max(...Object.values(days),.01);
620
+ const dist=(h,f,fmt)=>`<tr><td>${h}</td><td class="n">${fmt(Q(F.map(f),.5))}</td><td class="n">${fmt(Q(F.map(f),.9))}</td><td class="n">${fmt(Q(F.map(f),1))}</td></tr>`;
621
+ const byP=Object.create(null);F.forEach(t=>(byP[t.proj]=byP[t.proj]||[]).push(t));
622
+ return `<div class="grid">${kpi('Tasks',F.length,sess+' sessions')}${kpi('API turns',N(a.calls),N(a.sub)+' by subagents')}${kpi('Tool calls',N(a.tools))}
623
+ ${kpi('Wall time',T(a.wall),'sum over tasks')}${kpi('Model time',T(a.model),P(a.model/a.wall)+' of wall')}${kpi('Thinking time',T(a.think),P(a.think/Math.max(1,a.model))+' of model time')}
624
+ ${kpi('Est. cost',$$(a.cost),$$(a.cost/F.length)+' / task')}${kpi('Output tokens',N(a.out),'thinking '+N(a.thinkTok)+' (where reported)')}${kpi('Cache hit',P(cacheHit),'of input-side tokens')}</div>
625
+ <div class="card"><h2>Where the wall-clock time goes</h2>${bar({wall:a.wall,model_s:a.model,tool_s:a.tool,agent_s:a.agent,user_s:a.user,wait_s:a.wait})}<div style="height:8px"></div>${legend}
626
+ <div class="s" style="margin-top:6px">model ${T(a.model)} (${P(a.model/a.wall)}) · tools ${T(a.tool)} (${P(a.tool/a.wall)}) · subagent wait ${T(a.agent)} · user prompts ${T(a.user)} · idle ${T(a.wait)} (${P(a.wait/a.wall)})</div></div>
627
+ <div class="cols"><div class="card"><h2>Per-task distribution</h2><table><tr><th></th><th class="n">median</th><th class="n">p90</th><th class="n">max</th></tr>
628
+ ${dist('API turns',t=>t.calls,N)}${dist('Tool calls',t=>t.tools,N)}${dist('Wall time',t=>t.wall,T)}${dist('Thinking time',t=>t.think_s,T)}${dist('Cost',t=>t.cost,$$)}${dist('Peak context',t=>t.ctx_max,N)}</table></div>
629
+ <div class="card"><h2>Cost per day</h2><div class="days">${dk.map(d=>`<div title="${d}: ${$$(days[d])}" style="height:${days[d]/mx*100}%"></div>`).join('')}</div><div class="axis"><span>${dk[0]}</span><span>${dk.at(-1)}</span></div></div></div>
630
+ <div class="cols"><div class="card"><h2>Cost by project</h2>${tbl('proj',[{h:'Project',f:r=>esc(r[0]),v:r=>r[0]},{h:'Tasks',n:1,f:r=>r[1],v:r=>r[1]},{h:'Wall',n:1,f:r=>T(r[3]),v:r=>r[3]},{h:'Cost',n:1,d:1,f:r=>$$(r[2]),v:r=>r[2]}],Object.entries(byP).map(([k,v])=>[k,v.length,sum(v,t=>t.cost),sum(v,t=>t.wall)]),15)}</div>
631
+ <div class="card"><h2>Token cost mix</h2>${costMix()}</div></div>`}
632
+ function costMix(){const r=[0,1,2,3].map(i=>sum(F,t=>t.cost_mix[i])),tot=sum(r,x=>x)||1; // [fresh in, out, cache read, cache write] $
633
+ return [['Output',r[1]],['Cache read',r[2]],['Cache write',r[3]],['Fresh input',r[0]]].map(([k,v])=>`<div style="display:flex;gap:10px;align-items:center;margin:6px 0"><span style="width:90px">${k}</span><div class="hb" style="width:${v/tot*60}%"></div><span class="s">${P(v/tot)}</span></div>`).join('')}
634
+
635
+ function tasksView(){return `<div class="card">${legend}<div style="height:8px"></div>`+tbl('tasks',[
636
+ {h:'Date',f:t=>date(t.start),v:t=>t.start,d:1},{h:'Project',f:t=>esc(t.proj),v:t=>t.proj},{h:'Task',f:t=>`<div class="pr" title="${esc(t.prompt)}">${esc(t.prompt)}</div>`,v:t=>t.prompt},
637
+ {h:'Wall',n:1,f:t=>T(t.wall),v:t=>t.wall},{h:'<span class="tip" title="API round-trips by the main agent">Turns</span>',n:1,f:t=>t.calls+(t.sub_calls?` <span class="s">+${t.sub_calls}</span>`:''),v:t=>t.calls},
638
+ {h:'Tools',n:1,f:t=>t.tools,v:t=>t.tools},{h:'Think',n:1,f:t=>T(t.think_s),v:t=>t.think_s},{h:'Think tok',n:1,f:t=>N(t.think_tok),v:t=>t.think_tok},
639
+ {h:'Time split',f:bar,v:t=>t.wait_s/t.wall},{h:'Peak ctx',n:1,f:t=>N(t.ctx_max),v:t=>t.ctx_max},{h:'Cost',n:1,f:t=>$$(t.cost)+(t.est?'*':''),v:t=>t.cost}],F,300,t=>t.id)+'</div>'}
640
+
641
+ function toolsView(){const rows=toolAgg(F),tot=sum(rows,r=>r[3])||1,mx=Math.max(...rows.map(r=>r[3]),1);
642
+ const slow=F.flatMap(t=>t.slow.map(s=>[...s,t])).sort((a,b)=>b[1]-a[1]);
643
+ return `<div class="card"><h2>Tools by total time</h2>`+tbl('tools',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Calls',n:1,f:r=>N(r[1]),v:r=>r[1]},{h:'Errors',n:1,f:r=>r[2]?`${r[2]} <span class="s">(${P(r[2]/r[1])})</span>`:'–',v:r=>r[2]/r[1]},
644
+ {h:'Total time',n:1,d:1,f:r=>T(r[3]),v:r=>r[3]},{h:'',f:r=>`<div class="hb t" style="width:${r[3]/mx*100}%"></div>`,v:r=>r[3]},{h:'Avg',n:1,f:r=>(r[3]/r[1]).toFixed(1)+'s',v:r=>r[3]/r[1]},{h:'Max',n:1,f:r=>T(r[4]),v:r=>r[4]},{h:'<span class="tip" title="calls the user rejected or interrupted; not counted as errors">Rejected</span>',n:1,f:r=>r[5]||'–',v:r=>r[5]},{h:'% of tool time',n:1,f:r=>P(r[3]/tot),v:r=>r[3]}],rows,60)+
645
+ `<div class="s">Tool duration = tool_use → tool_result timestamps, so it includes time spent waiting on permission prompts. Subagent (Task/Agent) calls are counted here but painted separately in the time split.</div></div>
646
+ <div class="card"><h2>Slowest individual calls</h2>`+tbl('slow',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Duration',n:1,d:1,f:r=>T(r[1]),v:r=>r[1]},{h:'Input',f:r=>`<div class="pr">${esc(r[2])}</div>`,v:r=>r[2]},{h:'Err',f:r=>r[3]?'✗':'',v:r=>r[3]},{h:'Task',f:r=>`<span class="chip" data-task="${esc(r[4].id)}">${esc(r[4].prompt.slice(0,50))}</span>`,v:r=>r[4].prompt}],slow,25)+'</div>'}
647
+
648
+ function modelsView(){const rows=modelAgg(F),a=A(F);
649
+ return `<div class="card"><h2>Models</h2>`+tbl('models',[{h:'Model',f:r=>esc(sn(r[0])),v:r=>r[0]},{h:'Calls',n:1,f:r=>N(r[1]),v:r=>r[1]},{h:'Cost',n:1,d:1,f:r=>$$(r[2]),v:r=>r[2]},{h:'Output tok',n:1,f:r=>N(r[3]),v:r=>r[3]},
650
+ {h:'Thinking tok',n:1,f:r=>N(r[4]),v:r=>r[4]},{h:'Avg call',n:1,f:r=>(r[5]/r[1]).toFixed(1)+'s',v:r=>r[5]/r[1]},{h:'Out tok/s',n:1,f:r=>r[5]?(r[3]/r[5]).toFixed(0):'–',v:r=>r[3]/r[5]},
651
+ {h:'Thinking time',n:1,f:r=>T(r[6]),v:r=>r[6]},{h:'Think / call time',n:1,f:r=>P(r[6]/Math.max(.1,r[5])),v:r=>r[6]/r[5]},{h:'Fresh in',n:1,f:r=>N(r[7]),v:r=>r[7]},{h:'Cache read',n:1,f:r=>N(r[8]),v:r=>r[8]},{h:'Cache write',n:1,f:r=>N(r[9]),v:r=>r[9]}],rows)+'</div>'+
652
+ `<div class="card"><h2>Thinking</h2><div class="grid" style="margin:0">${kpi('Thinking time',T(a.think),P(a.think/Math.max(1,a.model))+' of model time')}${kpi('Thinking tokens',N(a.thinkTok),P(a.thinkTok/Math.max(1,a.out))+' of output (where reported)')}
653
+ ${kpi('Calls with thinking',N(sum(F,t=>t.think_calls)),P(sum(F,t=>t.think_calls)/Math.max(1,a.calls+a.sub))+' of calls')}</div>
654
+ <div class="s" style="margin-top:8px">Thinking text is redacted in the transcripts, so thinking time is measured as the gap between a request and its thinking block being written; tokens come from <code>usage.output_tokens_details.thinking_tokens</code> when the log has it.</div>
655
+ <h2 style="margin-top:14px">Most thinking-heavy tasks</h2>`+tbl('think',[{h:'Task',f:t=>`<div class="pr">${esc(t.prompt)}</div>`,v:t=>t.prompt},{h:'Think time',n:1,d:1,f:t=>T(t.think_s),v:t=>t.think_s},{h:'% of model time',n:1,f:t=>P(t.think_s/Math.max(1,t.model_s)),v:t=>t.think_s/Math.max(1,t.model_s)},{h:'Think tok',n:1,f:t=>N(t.think_tok),v:t=>t.think_tok},{h:'Wall',n:1,f:t=>T(t.wall),v:t=>t.wall}],F,15,t=>t.id)+'</div>'}
656
+
657
+ function bottlenecks(){const a=A(F),out=[],ids=(l,n=6)=>l.slice(0,n).map(t=>t.id),top=(f,n=5)=>[...F].sort((x,y)=>f(y)-f(x)).slice(0,n);
658
+ const cats=[['model inference',a.model],['tool execution',a.tool],['subagent waits',a.agent],['idle / waiting',a.wait]].sort((x,y)=>y[1]-x[1]);
659
+ out.push(['lo',`Biggest time sink: ${cats[0][0]} (${P(cats[0][1]/a.wall)} of wall time)`,`Breakdown: ${cats.map(c=>c[0]+' '+P(c[1]/a.wall)).join(' · ')}.`,[]]);
660
+ const idle=F.filter(t=>t.wall>60&&t.wait_s/t.wall>.4);if(idle.length)out.push(['hi',`${idle.length} tasks were mostly idle (>40% of time with no model or tool activity)`,`Typically permission prompts, a person reading, background jobs, or hook/startup latency. Total idle ${T(sum(idle,t=>t.wait_s))}. Worst offenders:`,ids(top(t=>t.wait_s).filter(t=>idle.includes(t)))]);
661
+ const tr=toolAgg(F).sort((x,y)=>y[3]-x[3]);if(tr.length)out.push(['',`Slowest tool category: ${tr[0][0]} — ${T(tr[0][3])} total (${P(tr[0][3]/Math.max(1,a.tool+a.agent))} of tool time)`,`Avg ${(tr[0][3]/tr[0][1]).toFixed(1)}s over ${tr[0][1]} calls, max ${T(tr[0][4])}. Next: ${tr.slice(1,4).map(r=>r[0]+' '+T(r[3])).join(', ')}.`,[]]);
662
+ const bad=toolAgg(F).filter(r=>r[1]>=8&&r[2]/r[1]>.15).sort((x,y)=>y[2]/y[1]-x[2]/x[1]);if(bad.length)out.push(['hi','Tools with high failure rates (retry churn)',bad.slice(0,5).map(r=>`${r[0]}: ${r[2]}/${r[1]} failed (${P(r[2]/r[1])})`).join(' · ')+'. Each failure usually costs another full model round-trip.',ids(top(t=>sum(Object.values(t.tool_stats),v=>v[1]))) ]);
663
+ const big=F.filter(t=>t.ctx_max>400000);if(big.length)out.push(['hi',`${big.length} tasks grew context past 400k tokens`,`Every extra turn re-reads the whole context (cache reads) — these tasks cost ${$$(sum(big,t=>t.cost))} (${P(sum(big,t=>t.cost)/a.cost)} of spend). Compact or split them earlier.`,ids(top(t=>t.ctx_max).filter(t=>big.includes(t)))]);
664
+ const th=F.filter(t=>t.model_s>30&&t.think_s/t.model_s>.35);if(th.length)out.push(['',`${th.length} tasks spent >35% of model time thinking`,`Total ${T(sum(th,t=>t.think_s))}. Lowering effort level or giving more precise prompts would shorten these.`,ids(top(t=>t.think_s).filter(t=>th.includes(t)))]);
665
+ const many=top(t=>t.calls);out.push(['',`Most turns for a single task: ${many[0].calls} API round-trips`,`Median task takes ${Q(F.map(t=>t.calls),.5)} turns, p90 ${Q(F.map(t=>t.calls),.9)}. Long tails usually mean exploration loops or rework.`,ids(many)]);
666
+ const cmp=sum(F,t=>t.compacts.length);if(cmp)out.push(['',`${cmp} context compactions`,`Total compaction time ${T(sum(F,t=>sum(t.compacts,c=>c[1])))}; each one discards history the agent may need to re-read.`,ids(F.filter(t=>t.compacts.length))]);
667
+ const sorted=[...F].sort((x,y)=>y.cost-x.cost),k=Math.max(1,Math.ceil(F.length*.05)),topc=sum(sorted.slice(0,k),t=>t.cost);
668
+ out.push(['',`Cost is concentrated: top ${k} task${k>1?'s':''} (5%) = ${P(topc/a.cost)} of spend`,`${$$(topc)} of ${$$(a.cost)}.`,ids(sorted)]);
669
+ const slow=top(t=>t.wall);out.push(['',`Longest tasks by wall time`,'',ids(slow)]);
670
+ return `<div class="card"><h2>Automatic findings</h2>`+out.map(([c,h,b,l])=>`<div class="find ${c}"><b>${esc(h)}</b>${esc(b)}<div>${l.map(id=>{const t=D.tasks.find(x=>x.id==id);return `<span class="chip" data-task="${esc(id)}">${esc(t.prompt.slice(0,45))} · ${T(t.wall)} · ${$$(t.cost)}</span>`}).join('')}</div></div>`).join('')+'</div>'}
671
+
672
+ function detail(id){const t=D.tasks.find(x=>x.id==id),w=t.wall,pc=x=>(x/w*100).toFixed(3)+'%';
673
+ const lanes=['model','tools','subagent'].map((n,i)=>`<div class="lane"><label>${n}</label>`+t.segs.filter(s=>i==0?s[0]=='m':i==1?(s[0]=='t'||s[0]=='te'):(s[0]=='ms'||s[0]=='ts')).map(s=>{
674
+ const col=s[0]=='te'?'e':(s[0]=='m'||s[0]=='ms')?'m':s[0]=='ts'?'a':'t',extra=s[0][0]=='m'?`${esc(sn(s[3]))} · ${s[5]} out tok · think ${T(s[4])}`:esc(s[3]+': '+s[4]);
675
+ const th=s[0][0]=='m'&&s[4]>0?`<span class="th" style="left:${pc(s[1])};width:${pc(Math.min(s[4],s[2]))}" title="thinking ${T(s[4])}"></span>`:'';
676
+ return `<span class="${col}" style="left:${pc(s[1])};width:${pc(s[2])}" title="${T(s[2])} — ${extra}"></span>${th}`}).join('')+'</div>').join('');
677
+ const ctxMax=Math.max(...t.ctx.map(c=>c[1]),1),svg=t.ctx.length>1?`<svg viewBox="0 0 400 60" width="100%" height="60" preserveAspectRatio="none"><polyline fill="none" stroke="var(--model)" stroke-width="1.5" points="${t.ctx.map(c=>`${c[0]/w*400},${58-c[1]/ctxMax*54}`).join(' ')}"/></svg>`:'';
678
+ const trows=Object.entries(t.tool_stats).map(([k,v])=>[k,...v]);
679
+ return `<h2 style="font-size:16px">${esc(t.title||'Task')} <span class="s">· ${esc(t.proj)} · ${new Date(t.start*1000).toLocaleString()} · ${esc(t.id)}</span></h2><div class="pre">${esc(t.prompt)}</div>
680
+ <div class="grid" style="margin:12px 0">${kpi('Wall',T(w))}${kpi('Turns',t.calls,t.sub_calls+' subagent calls')}${kpi('Tool calls',t.tools)}${kpi('Thinking',T(t.think_s),N(t.think_tok)+' tok')}${kpi('Cost',$$(t.cost)+(t.est?'*':''),'subagents '+$$(t.sub_cost))}${kpi('Peak context',N(t.ctx_max),t.compacts.length+' compactions')}</div>
681
+ ${bar(t)}<div style="height:6px"></div>${legend}<h2 style="margin-top:14px">Timeline <span class="s">(purple = thinking portion of a model call, red = failed tool)</span></h2><div class="gantt">${lanes}</div><div class="axis">${[0,.25,.5,.75,1].map(f=>`<span>${T(w*f)}</span>`).join('')}</div>
682
+ <div class="cols" style="margin-top:14px"><div><h2>Tools in this task</h2>${tbl('dt',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Calls',n:1,f:r=>r[1],v:r=>r[1]},{h:'Err',n:1,f:r=>r[2],v:r=>r[2]},{h:'Total',n:1,d:1,f:r=>T(r[3]),v:r=>r[3]},{h:'Max',n:1,f:r=>T(r[4]),v:r=>r[4]}],trows,20)}</div>
683
+ <div><h2>Tool calls, slowest first</h2>${tbl('ds',[{h:'Tool',f:r=>esc(r[0]),v:r=>r[0]},{h:'Dur',n:1,f:r=>T(r[1]),v:r=>r[1]},{h:'Input',f:r=>`<div class="pr" style="max-width:300px">${esc(r[2])}</div>`,v:r=>r[2]}],t.slow,10)}
684
+ <h2 style="margin-top:12px">Context size per turn</h2>${svg}<div class="s">peak ${N(t.ctx_max)} tokens</div></div></div>`}
685
+
686
+ const views=[overview,tasksView,toolsView,modelsView,bottlenecks];
687
+ function render(){filt();document.querySelectorAll('#nav button').forEach((b,i)=>b.classList.toggle('on',i==tab));
688
+ $('#view').innerHTML=F.length?views[tab]():'<div class="card">No tasks match.</div>'}
689
+ const pl=(n,w)=>`${N(n)} ${w}${n==1?'':'s'}`;
690
+ const rates=Object.entries(D.price).sort(([a],[b])=>a.localeCompare(b)).map(([k,p])=>`<tr><td><code>${esc(k)}</code></td>${p.map(x=>`<td class="n">${x}</td>`).join('')}</tr>`).join('');
691
+ $('#foot').innerHTML=`<div class="meta">Generated ${esc(new Date(D.generated).toLocaleString())} from <code>${esc(D.root)}</code> · ${pl(D.files,'file')} · ${pl(D.sessions.length,'session')} · Developed by <b>Infenia Private Limited</b> under the MIT License</div>
692
+ <div class="notes">
693
+ <div><b>Definitions</b>A <b>task</b> is one human prompt through the last activity before the next prompt; a <b>turn</b> is one model API call. Subagent transcripts count toward the task that was running when they started.</div>
694
+ <div><b>Time split</b>Interval union (model › tools › subagent › user tools), so parallel work is not double counted. <i>Idle</i> is time with no model or tool activity.</div>
695
+ <div><b>Costs</b>Estimates: token usage × the rates below; <code>*</code> marks tasks that include models with unknown pricing. On real transcripts they land at a median ~95% of the CLI's own reported total. Advisor sub-calls and fast mode are priced; server-tool fees (e.g. web search) are not.</div>
696
+ </div>
697
+ <details><summary>Rates used ($ per million tokens; the longest key contained in the model name wins)</summary>
698
+ <table><tr><th>Model key</th><th class="n">Input</th><th class="n">Output</th><th class="n">Cache read</th><th class="n">Cache write 5m</th><th class="n">Cache write 1h</th></tr>${rates}</table></details>`;
699
+ document.addEventListener('click',e=>{const el=e.target.closest('[data-t],[data-s],[data-task],[data-more]');if(!el)return;
700
+ const modal=()=>{if(cur&&$('#modal').style.display=='block')$('#mbody').innerHTML=detail(cur)};
701
+ if(el.dataset.more){const[id,step]=el.dataset.more.split('|');shown[id]=step=='all'?Infinity:(shown[id]||+step)+ +step;render();modal();return}
702
+ if(el.dataset.t!=null){tab=+el.dataset.t;render()}else if(el.dataset.s){const[id,i]=el.dataset.s.split('|'),s=sorts[id];s.d=s.i==+i?-s.d:-1;s.i=+i;render();modal()}
703
+ else{cur=el.dataset.task;delete shown.dt;delete shown.ds;$('#mbody').innerHTML=detail(cur);$('#modal').style.display='block'}});
704
+ $('#x').onclick=()=>$('#modal').style.display='none';$('#modal').onclick=e=>{if(e.target.id=='modal')e.target.style.display='none'};
705
+ $('#fp').onchange=$('#fq').oninput=()=>{for(const k in shown)delete shown[k];render()};render(); // a new filter starts tables at page 1
706
+ </script></body></html>
707
+ '''
708
+
709
+
710
+ if __name__ == '__main__':
711
+ main()