| | #!/usr/bin/env python3 | | | """How fast do your Claude Code agents run, and who is the session waiting on? | | | Reads your local Claude Code session logs (~/.claude/projects//.jsonl) and prints: | | | - how many responses ran at each speed setting (standard vs fast mode) | | | - the median end-to-end output speed of your responses, in tokens per second | | | - while the agent works on its own: the model's share of the time vs the tools' | | | - across whole sessions: the model's, the tools' and your own share of the clock | | | It reads only timestamps, token counts and the speed field. It never prints or | | | sends message content. Nothing leaves your machine. | | | Usage: | | | python3 claude_code_speed.py # last 30 days, every project | | | python3 claude_code_speed.py 7 # last 7 days | | | python3 claude_code_speed.py 30 ~/.claude/projects/<one-project-dir> | | | How the times are measured, so you can judge the numbers: | | | - Model time: from the message the model answered (your prompt or a tool | | | result) to the model's last chunk of that answer. It includes the wait for | | | the first token, so the speed is end to end, like Artificial Analysis's. | | | - Tool time: from the model's tool call to the tool's result. Waits over | | | 5 minutes are left out: those are permission prompts or background jobs. | | | - Your time: from the model's answer to your next message, each gap capped at | | | 1 hour, so a night away from the keyboard does not count as thinking. | | | - Speed is computed on responses of 200 output tokens or more, where the | | | first-token wait matters least. Claude Code sends long contexts, so this | | | end-to-end speed runs below the streaming speed a benchmark reports. | | | - Output tokens include thinking tokens. | | | - Resumed and forked sessions copy earlier history into a new log; each | | | message is counted once. | | | - Dollars are what the tokens would cost at Claude Opus 5.5 list prices, | | | whatever model or plan you actually used. Fast mode doubles every price. | | | """ | | | import glob | | | import json | | | import os | | | import sys | | | import time | | | from datetime import datetime | | | TOOL_CAP = 300.0 | | | # Claude Opus 5.5 list prices, dollars per million tokens, from | | | # https://platform.claude.com/docs/en/about-claude/pricing (fetched 2026-11-10): | | | # input, cache read, 5-minute cache write, 1-hour cache write, output. |
| | # Fast mode doubles every one of them (https://platform.claude.com/docs/en/build-with-claude/fast-mode). |
| | PRICE = (4.00, 0.20, 5.00, 8.00, 20.00) |
| | HUMAN_CAP = 3600.0 |
| | def ts(s): |
| | return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() |
| | SEEN = set() |
| | SEEN_REQ = set() |
| | def events(path): |
| | out = [] |
| | for line in open(path, errors="ignore"): |
| | try: | | | d = json.loads(line) | | | except ValueError: | | | continue | | | if d.get("isSidechain") or "timestamp" not in d: | | | continue | | | uid = d.get("uuid") | | | if uid in SEEN: | | | continue # a resumed or forked session repeats earlier history | | | if uid: |
| | SEEN.add(uid) |
| | kind = d.get("type") |
| | msg = d.get("message") or {} |
| | if kind == "user": |
| | c = msg.get("content") |
| | if isinstance(c, list) and any(isinstance(x, dict) and x.get("type") == "tool_result" for x in c): |
| | who = "tool_result" | | | elif d.get("isMeta") or (isinstance(c, str) and c.startswith("<")): | | | who = "meta" | | | else: | | | who = "human" |
| | out.append((ts(d["timestamp"]), "user", who, 0, None)) |
| | elif kind == "assistant": |
| | u = msg.get("usage") or {} |
| | cc = u.get("cache_creation") or {} |
| | w1h = cc.get("ephemeral_1h_input_tokens") or 0 |
| | w5m = (u.get("cache_creation_input_tokens") or 0) - w1h |
| | use = (u.get("input_tokens") or 0, u.get("cache_read_input_tokens") or 0, w5m, w1h) |
| | ctx = sum(use) |
| | out.append((ts(d["timestamp"]), "assistant", d.get("requestId"), |
| | u.get("output_tokens") or 0, (u.get("speed"), ctx, use))) |
| | out.sort(key=lambda e: e[0]) |
| | return out |
| | def main(): |
| | days = int(sys.argv[1]) if len(sys.argv) > 1 else 30 |
| | root = os.path.expanduser(sys.argv[2]) if len(sys.argv) > 2 else os.path.expanduser("~/.claude/projects/*") |
| | cutoff = time.time() - days * 86400 |
| | files = sorted((f for f in glob.glob(os.path.join(root, "*.jsonl")) if os.path.getmtime(f) >= cutoff), |
| | key=os.path.getmtime) |
| | model = tool = human = 0.0 |
| | sessions = responses = 0 |
| | speed_setting, speeds, contexts, out_tokens = {}, [], [], 0 |
| | tokens_in = [0, 0, 0, 0] # uncached input, cache reads, 5-minute cache writes, 1-hour cache writes | | | for f in files: | | | ev = events(f) | | | if not ev: | | | continue | | | sessions += 1 | | | prev, i = None, 0 |
| | while i < len(ev): |
| | t, kind, key, _, _ = ev[i] |
| | if kind == "user": |
| | if prev is not None and t >= prev: |
| | gap = t - prev |
| | if key == "tool_result" and gap <= TOOL_CAP: |
| | tool += gap |
| | elif key == "human": |
| | human += min(gap, HUMAN_CAP) |
| | prev, i = t, i + 1 | | | continue |
| | j, out, (setting, ctx, use) = i, 0, ev[i][4] |
| | while j < len(ev) and ev[j][1] == "assistant" and ev[j][2] == key: |
| | out = max(out, ev[j][3]) |
| | j += 1 | | | end = ev[j - 1][0] | | | first = key not in SEEN_REQ # one request can be logged in more than one piece | | | SEEN_REQ.add(key) | | | if prev is not None and 0 < end - prev < 1800: | | | model += end - prev | | | if first: | | | responses += 1 |
| | contexts.append(ctx) |
| | if out >= 200: |
| | speeds.append(out / (end - prev)) |
| | if first: | | | out_tokens += out |
| | for k in range(4): |
| | tokens_in[k] += use[k] |
| | if setting and first: | | | speed_setting[setting] = speed_setting.get(setting, 0) + 1 | | | prev, i = end, j | | | if not responses: | | | print("No sessions found. Check the path:", root) | | | return |
| | speeds.sort() |
| | contexts.sort() |
| | q = lambda p: speeds[int(p * (len(speeds) - 1))] |
| | clock = model + tool + human |
| | print(f"Last {days} days: {sessions} sessions, {responses} model responses") |
| | print("Speed setting recorded:", ", ".join(f"{k} {v}" for k, v in sorted(speed_setting.items())) or "none") |
| | print(f"Median output speed: {q(0.5):.0f} tok/s (middle half {q(0.25):.0f} to {q(0.75):.0f}), " |
| | f"over {len(speeds)} responses of 200+ tokens") |
| | print(f"While the agent works: model {100 * model / (model + tool):.0f}% of the time, " |
| | f"tools {100 * tool / (model + tool):.0f}%") |
| | print(f"Across whole sessions: you {100 * human / clock:.0f}%, model {100 * model / clock:.0f}%, " |
| | f"tools {100 * tool / clock:.0f}%") |
| | print(f"Model time: {model / 3600:.0f} hours, {out_tokens / 1e6:.0f}M output tokens, " |
| | f"median context {contexts[len(contexts) // 2] / 1000:.0f}K tokens per response") |
| | cost = (sum(t * pr for t, pr in zip(tokens_in, PRICE[:4])) + out_tokens * PRICE[4]) / 1e6 |
| | print(f"At Claude Opus 5.5 list prices: ${cost:,.0f} on the standard tier " |
| | f"(${out_tokens * PRICE[4] / 1e6:,.0f} of it output), ${2 * cost:,.0f} on fast mode") |
| | m_share, a_share = model / clock, model / (model + tool) | | | print(f"A model 2.5x faster would save about {100 * m_share * 0.6:.0f}% of your session clock, " | | | f"and about {100 * a_share * 0.6:.0f}% of an agent's solo run time, " | | | f"at most {model * 0.6 / 3600:.0f} hours of model time (fast mode speeds up streaming, " | | | f"not the wait for the first token, so read these as ceilings)") |
| | if __name__ == "__main__": |
| | main() |