diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f05c1..3ce271c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## v1.5.0 — 2026-06-17 + +### Subagent attribution + +- The scanner now records which turns came from dispatched subagents (Task/Agent tool) via new `turns.is_subagent` / `turns.agent_id` columns and an `agents` dispatch table (agent type + aggregate stats captured from the parent `toolUseResult`). Subagents are detected by `isSidechain`, an `agentId`, or a `subagents/` transcript path. Schema changes are additive, so existing `usage.db` files migrate in place (no rebuild). +- Dashboard: a **Subagent Tokens by Type** chart, a **Top Subagent Dispatches** table, and a Subagent Tokens stat card — all driven by the existing model + range filters. Dynamic values are HTML-escaped. +- CLI: `today` and `stats` now print subagent token/turn summaries (included in totals). + +### ccusage integration (optional) + +- New optional bridge (`ccusage_bridge.py`): when Node/`npx` + [ccusage](https://github.com/ryoppippi/ccusage) are present, `scan` ingests ccusage's 5-hour billing **blocks** and per-source **daily** totals into new `billing_windows` / `ccusage_daily_cache` tables. It degrades gracefully when absent — the native, stdlib-only tool is unchanged and ccusage is never required. Windows `npx.cmd` invocation and UTF-8 output are handled; the subprocess never raises. +- Dashboard: a **Current 5h Billing Window** card (progress bar vs your P90 window baseline, burn rate, time remaining, projected end-of-window tokens/cost, cost so far) with an install prompt when ccusage isn't present, and an **Other Agent CLIs (via ccusage)** chart for non-Claude usage (Codex/Gemini/Copilot/…). Claude Code is always counted natively and never double-counted against ccusage. + +### Scanner / CLI + +- Pricing now lives in a single `pricing.py` module shared by the CLI and (via `/api/data`) the dashboard, so the Python and JS pricing tables can no longer drift; the embedded JS table is now only a cold-start fallback. +- Detect "Claude AI usage limit reached" events into a new `limit_events` table, gated on `isApiErrorMessage` so ordinary text mentioning a limit isn't misdetected. + +### Project / docs + +- Footer now notes that figures are transcript-derived estimates (Claude Code doesn't write every request to disk) and may not match Anthropic billing exactly, and that native vs ccusage numbers are shown separately, never summed. + +### Fixes + +- **Scanner:** a transcript rewritten *shorter* (e.g. compaction) no longer gets skipped forever — the shrink path now syncs the stored line count, not just the mtime, so later appends are still ingested. +- **CLI:** `today` and `week` now compute their date window in **UTC** to match the UTC transcript timestamps (previously used the local date, off by one near midnight away from UTC). +- **Dashboard:** the `/api/data` payload is cached keyed on the DB's path + mtime, so the 30-second poll doesn't re-run every GROUP BY/JOIN when nothing changed; any scan/ingest invalidates it. Dashboard date ranges (week/month/relative) are now computed in UTC for consistency with the data. + ## v1.4.0 — 2026-06-15 ### Dashboard diff --git a/ccusage_bridge.py b/ccusage_bridge.py new file mode 100644 index 0000000..5c00d47 --- /dev/null +++ b/ccusage_bridge.py @@ -0,0 +1,283 @@ +""" +ccusage_bridge.py - Optional integration with the `ccusage` CLI. + +ccusage (https://github.com/ryoppippi/ccusage) is a Node-distributed Rust binary +that reads the same ~/.claude transcripts and emits deduplicated, billing-accurate +JSON. We wrap it as an OPTIONAL data source: when Node/npx is available we ingest +its 5-hour "blocks" (billing windows, burn rate, projections) and per-day totals +into separate SQLite tables. When it isn't, everything degrades gracefully and the +native scanner is unaffected. + +Nothing here is required for the core tool to work. +""" + +import json +import os +import shutil +import sqlite3 +import statistics +import subprocess +from datetime import datetime, timezone + +from scanner import DB_PATH, get_db, init_db + +# Pin via env if you want reproducible output; defaults to latest. The JSON shape +# is stable across the 20.0.x line (field names verified against 20.0.9). +CCUSAGE_SPEC = os.environ.get("CCUSAGE_SPEC", "ccusage@latest") +_TIMEOUT_S = 90 + +# Non-Claude agent CLIs ccusage can read. Claude Code is intentionally excluded +# here — it's covered billing-accurately by the native scanner, and including the +# unified `ccusage daily` (which also counts Claude) would double-count it. +CCUSAGE_EXTRA_SOURCES = ["codex", "gemini", "copilot", "amp", "droid", "opencode"] + + +def detect_runtime(): + """Locate an npx/bunx runner. On Windows shutil.which returns npx.cmd, which + must be invoked with its full path (a bare 'npx' raises FileNotFoundError + under shell=False), so we probe the .cmd name first.""" + for name in ("npx.cmd", "npx", "bunx"): + path = shutil.which(name) + if path: + return {"available": True, "runner": path, + "kind": "bunx" if name == "bunx" else "npx"} + return { + "available": False, "runner": None, "kind": None, + "reason": "Node/npx not found in PATH — install Node.js from " + "https://nodejs.org to enable ccusage billing-window data.", + } + + +def _build_argv(rt, sub_args): + # npx needs -y to auto-install; bunx takes the spec directly. + if rt["kind"] == "bunx": + return [rt["runner"], CCUSAGE_SPEC, *sub_args] + return [rt["runner"], "-y", CCUSAGE_SPEC, *sub_args] + + +def run_ccusage(sub_args, rt=None): + """Run a ccusage subcommand and return parsed JSON, or None on any failure. + + Always decodes as UTF-8 (Windows' default cp1252 mangles non-ASCII output), + suppresses ccusage's progress chatter via LOG_LEVEL=0, and never raises. + """ + rt = rt or detect_runtime() + if not rt["available"]: + return None + env = {**os.environ, "LOG_LEVEL": "0"} + try: + proc = subprocess.run( + _build_argv(rt, sub_args), + capture_output=True, env=env, timeout=_TIMEOUT_S, + encoding="utf-8", errors="replace", + ) + except (subprocess.SubprocessError, OSError): + return None + if proc.returncode != 0 or not (proc.stdout or "").strip(): + return None + try: + return json.loads(proc.stdout) + except json.JSONDecodeError: + return None + + +def _now_iso(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# ── Pure transforms (unit-testable without Node) ──────────────────────────────── + +def blocks_to_rows(data, ingested_at): + """Map ccusage `blocks --json` output to billing_windows rows. Skips gaps. + + ccusage fields are camelCase; burnRate/projection are nested objects present + only on the active block (null otherwise).""" + rows = [] + for b in (data or {}).get("blocks", []): + if not isinstance(b, dict) or b.get("isGap"): + continue + if not b.get("id"): + continue + tc = b.get("tokenCounts") or {} + br = b.get("burnRate") or {} + pj = b.get("projection") or {} + rows.append({ + "block_id": b.get("id"), + "start_time": b.get("startTime"), + "end_time": b.get("endTime"), + "actual_end_time": b.get("actualEndTime"), + "is_active": 1 if b.get("isActive") else 0, + "input_tokens": tc.get("inputTokens", 0) or 0, + "output_tokens": tc.get("outputTokens", 0) or 0, + "cache_read_tokens": tc.get("cacheReadInputTokens", 0) or 0, + "cache_creation_tokens": tc.get("cacheCreationInputTokens", 0) or 0, + "total_tokens": b.get("totalTokens", 0) or 0, + "cost_usd": b.get("costUSD", 0) or 0, + "models": json.dumps(b.get("models") or []), + "burn_rate_tpm": br.get("tokensPerMinute"), + "burn_rate_cost_per_hour": br.get("costPerHour"), + "projected_total_tokens": pj.get("totalTokens"), + "projected_cost_usd": pj.get("totalCost"), + "remaining_minutes": pj.get("remainingMinutes"), + "ingested_at": ingested_at, + }) + return rows + + +def daily_to_rows(data, source, ingested_at): + """Map ccusage `daily --json` output to ccusage_daily_cache rows. + + `ccusage daily` uses period/totalCost/cacheReadTokens; source-prefixed + variants (e.g. `ccusage codex daily`) use date/costUSD — accept both.""" + rows = [] + for r in (data or {}).get("daily", []): + if not isinstance(r, dict): + continue + day = r.get("period") or r.get("date") + if not day: + continue + rows.append({ + "day": day, + "source": source, + "input_tokens": r.get("inputTokens", 0) or 0, + "output_tokens": r.get("outputTokens", 0) or 0, + "cache_read_tokens": r.get("cacheReadTokens", 0) or 0, + "cache_creation_tokens": r.get("cacheCreationTokens", 0) or 0, + "total_tokens": r.get("totalTokens", 0) or 0, + "cost_usd": r.get("totalCost", r.get("costUSD", 0)) or 0, + "models": json.dumps(r.get("modelsUsed") or r.get("models") or []), + "ingested_at": ingested_at, + }) + return rows + + +# ── DB writes ────────────────────────────────────────────────────────────────── + +_BW_COLS = ("block_id", "start_time", "end_time", "actual_end_time", "is_active", + "input_tokens", "output_tokens", "cache_read_tokens", + "cache_creation_tokens", "total_tokens", "cost_usd", "models", + "burn_rate_tpm", "burn_rate_cost_per_hour", "projected_total_tokens", + "projected_cost_usd", "remaining_minutes", "ingested_at") + + +def upsert_billing_windows(conn, rows): + if not rows: + return + placeholders = ", ".join("?" for _ in _BW_COLS) + updates = ", ".join(f"{c} = excluded.{c}" for c in _BW_COLS if c != "block_id") + conn.executemany( + f"INSERT INTO billing_windows ({', '.join(_BW_COLS)}) " + f"VALUES ({placeholders}) " + f"ON CONFLICT(block_id) DO UPDATE SET {updates}", + [tuple(r[c] for c in _BW_COLS) for r in rows], + ) + + +_DC_COLS = ("day", "source", "input_tokens", "output_tokens", "cache_read_tokens", + "cache_creation_tokens", "total_tokens", "cost_usd", "models", + "ingested_at") + + +def upsert_ccusage_daily(conn, rows): + if not rows: + return + placeholders = ", ".join("?" for _ in _DC_COLS) + updates = ", ".join(f"{c} = excluded.{c}" for c in _DC_COLS if c not in ("day", "source")) + conn.executemany( + f"INSERT INTO ccusage_daily_cache ({', '.join(_DC_COLS)}) " + f"VALUES ({placeholders}) " + f"ON CONFLICT(day, source) DO UPDATE SET {updates}", + [tuple(r[c] for c in _DC_COLS) for r in rows], + ) + + +# ── Plan-limit baseline (Monitor-style P90 of your own history) ───────────────── + +def compute_p90_limit(window_totals, floor=0): + """The 90th-percentile of your completed 5h-window totals — a personal + 'typical heavy window' baseline. Anthropic's hard token limit isn't exposed + anywhere, so (like Claude-Code-Usage-Monitor) we use your own history as the + yardstick. Returns `floor` when there isn't enough history.""" + vals = [int(v) for v in window_totals if v and v > 0] + if not vals: + return floor + if len(vals) == 1: + return max(vals[0], floor) + p90 = statistics.quantiles(vals, n=10)[8] # 9 cut points; [8] = 90th pct + return max(int(p90), floor) + + +def summarize_billing(conn): + """Read billing_windows into a dashboard-ready summary. Returns + {'available': False} when ccusage has never populated the table (no Node), + so the UI can show an install prompt instead of an empty card.""" + try: + rows = conn.execute( + "SELECT * FROM billing_windows ORDER BY start_time" + ).fetchall() + except sqlite3.OperationalError: + return {"available": False} + if not rows: + return {"available": False} + + windows = [dict(r) for r in rows] + completed_totals = [w["total_tokens"] for w in windows if not w["is_active"]] + active = next((w for w in windows if w["is_active"]), None) + return { + "available": True, + "window_count": len(windows), + "plan_limit_estimate": compute_p90_limit(completed_totals), + "active": active, + "recent": windows[-30:], + } + + +# ── Orchestration ─────────────────────────────────────────────────────────────── + +def ingest(db_path=DB_PATH, verbose=True, rt=None): + """Fetch ccusage blocks + daily and upsert into the DB. Safe to call always: + returns {'available': False} (no error) when Node/ccusage is missing.""" + rt = rt or detect_runtime() + if not rt["available"]: + if verbose: + print(f"[ccusage] {rt.get('reason', 'unavailable')}") + return {"available": False} + + ingested_at = _now_iso() + blocks = run_ccusage(["blocks", "--json", "--offline"], rt) + daily = run_ccusage(["daily", "--json", "--offline"], rt) + if blocks is None and daily is None: + if verbose: + print("[ccusage] runner found but no data returned (first run downloads " + "the package; check network/version).") + return {"available": True, "blocks": 0, "daily": 0, "sources": {}} + + conn = get_db(db_path) + init_db(conn) + bw = blocks_to_rows(blocks, ingested_at) + dl = daily_to_rows(daily, "ccusage-all", ingested_at) + upsert_billing_windows(conn, bw) + upsert_ccusage_daily(conn, dl) + + # Per-source daily totals for the OTHER agent CLIs (Claude Code is already + # covered, billing-accurately, by the native scanner). Best effort: a source + # the user doesn't use returns nothing and is skipped. + sources = {} + for src in CCUSAGE_EXTRA_SOURCES: + srows = daily_to_rows( + run_ccusage([src, "daily", "--json", "--offline"], rt), + f"ccusage-{src}", ingested_at) + if srows: + upsert_ccusage_daily(conn, srows) + sources[src] = len(srows) + + conn.commit() + conn.close() + if verbose: + extra = (" + " + ", ".join(f"{k}:{v}" for k, v in sources.items())) if sources else "" + print(f"[ccusage] ingested {len(bw)} billing windows, {len(dl)} daily rows{extra}") + return {"available": True, "blocks": len(bw), "daily": len(dl), "sources": sources} + + +if __name__ == "__main__": + print(ingest()) diff --git a/cli.py b/cli.py index 98f3a12..8e6ccca 100644 --- a/cli.py +++ b/cli.py @@ -12,59 +12,21 @@ import sys import sqlite3 from pathlib import Path -from datetime import datetime, date, timedelta +from datetime import datetime, timedelta, timezone from scanner import VERSION +# Pricing lives in a single module shared by the CLI and (via /api/data) the +# dashboard, so the two can never drift. See pricing.py. +from pricing import PRICING, get_pricing, calc_cost DB_PATH = Path.home() / ".claude" / "usage.db" -PRICING = { - # Fable / Mythos — Anthropic's most capable class, priced at 2x Opus. - # (Mythos 5 shares Fable 5's pricing; Project-Glasswing access only.) - "claude-fable-5": {"input": 10.00, "output": 50.00, "cache_read": 1.00, "cache_write": 12.50}, - "claude-mythos-5": {"input": 10.00, "output": 50.00, "cache_read": 1.00, "cache_write": 12.50}, - "claude-opus-4-8": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-opus-4-5": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25}, - "claude-sonnet-4-7": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, - "claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, - "claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75}, - "claude-haiku-4-7": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, - "claude-haiku-4-6": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, - "claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25}, -} - -def get_pricing(model): - if not model: - return None - if model in PRICING: - return PRICING[model] - for key in PRICING: - if model.startswith(key): - return PRICING[key] - # Substring fallback: match model family by keyword - m = model.lower() - if "fable" in m or "mythos" in m: - return PRICING["claude-fable-5"] - if "opus" in m: - return PRICING["claude-opus-4-8"] - if "sonnet" in m: - return PRICING["claude-sonnet-4-6"] - if "haiku" in m: - return PRICING["claude-haiku-4-5"] - return None -def calc_cost(model, inp, out, cache_read, cache_creation): - p = get_pricing(model) - if not p: - return 0.0 - return ( - inp * p["input"] / 1_000_000 + - out * p["output"] / 1_000_000 + - cache_read * p["cache_read"] / 1_000_000 + - cache_creation * p["cache_write"] / 1_000_000 - ) +def _utc_today(): + """Today's date in UTC. Transcript timestamps are stored as UTC ISO strings, + so day-range filters must use the UTC date too — otherwise `today` / `week` + are off by one near midnight for users far from UTC.""" + return datetime.now(timezone.utc).date() def fmt(n): if n >= 1_000_000: @@ -91,12 +53,19 @@ def require_db(): def cmd_scan(projects_dir=None): from scanner import scan scan(projects_dir=Path(projects_dir) if projects_dir else None) + # Optional: enrich with ccusage billing-window data if Node/npx is present. + # Never let this block or fail a successful native scan. + try: + from ccusage_bridge import ingest + ingest() + except Exception as e: + print(f"[ccusage] skipped: {e}") def cmd_today(): conn = require_db() conn.row_factory = sqlite3.Row - today = date.today().isoformat() + today = _utc_today().isoformat() rows = conn.execute(""" SELECT @@ -118,6 +87,15 @@ def cmd_today(): WHERE substr(timestamp, 1, 10) = ? """, (today,)).fetchone() + subagent = conn.execute(""" + SELECT + COUNT(*) as turns, + SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens) as tokens + FROM turns + WHERE substr(timestamp, 1, 10) = ? + AND COALESCE(is_subagent, 0) = 1 + """, (today,)).fetchone() + print() hr() print(f" Today's Usage ({today})") @@ -145,6 +123,7 @@ def cmd_today(): print(f" {'TOTAL':<30} turns={total_turns:<4} in={fmt(total_inp):<8} out={fmt(total_out):<8} cost={fmt_cost(total_cost)}") print() print(f" Sessions today: {sessions['cnt']}") + print(f" Subagent tokens: {fmt(subagent['tokens'] or 0)} ({fmt(subagent['turns'] or 0)} turns)") print(f" Cache read: {fmt(total_cr)}") print(f" Cache creation: {fmt(total_cc)}") hr() @@ -156,7 +135,7 @@ def cmd_week(): conn = require_db() conn.row_factory = sqlite3.Row - today_d = date.today() + today_d = _utc_today() start_d = today_d - timedelta(days=6) start = start_d.isoformat() end = today_d.isoformat() @@ -302,6 +281,15 @@ def cmd_stats(): LIMIT 5 """).fetchall() + # Subagent totals (subagent tokens are included in the all-time totals above) + subagent = conn.execute(""" + SELECT + COUNT(*) as turns, + SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens) as tokens + FROM turns + WHERE COALESCE(is_subagent, 0) = 1 + """).fetchone() + # Daily average (last 30 days) daily_avg = conn.execute(""" SELECT @@ -334,11 +322,13 @@ def cmd_stats(): print(f" Period: {first_date} to {last_date}") print(f" Total sessions: {session_info['sessions'] or 0:,}") print(f" Total turns: {fmt(totals['turns'] or 0)}") + print(f" Subagent turns: {fmt(subagent['turns'] or 0)}") print() print(f" Input tokens: {fmt(totals['inp'] or 0):<12} (raw prompt tokens)") print(f" Output tokens: {fmt(totals['out'] or 0):<12} (generated tokens)") print(f" Cache read: {fmt(totals['cr'] or 0):<12} (90% cheaper than input)") print(f" Cache creation: {fmt(totals['cc'] or 0):<12} (25% premium on input)") + print(f" Subagent tokens: {fmt(subagent['tokens'] or 0):<12} (included in totals)") print() print(f" Est. total cost: ${total_cost:.4f}") hr() diff --git a/dashboard.py b/dashboard.py index e2ac2f6..a9921f4 100644 --- a/dashboard.py +++ b/dashboard.py @@ -11,6 +11,7 @@ from datetime import datetime from scanner import VERSION +from pricing import PRICING # single source of truth, also served via /api/data DB_PATH = Path.home() / ".claude" / "usage.db" @@ -23,11 +24,24 @@ # would misfire there because the Marketplace publish lags the GitHub release). SURFACE = "web" +# Cache the assembled /api/data payload keyed on the DB file's path + mtime, so +# repeated polls (the client refreshes every 30s) don't re-run every GROUP BY / +# JOIN when nothing has changed. Any scan/ingest commit bumps usage.db's mtime +# and invalidates it. Single entry — the dashboard only ever reads one DB. +_DATA_CACHE = {} + def get_dashboard_data(db_path=DB_PATH): if not db_path.exists(): return {"error": "Database not found. Run: python cli.py scan"} + try: + cache_key = (str(db_path), db_path.stat().st_mtime) + except OSError: + cache_key = None + if cache_key is not None and _DATA_CACHE.get("key") == cache_key: + return _DATA_CACHE["data"] + conn = sqlite3.connect(db_path) # The dashboard reads while a background scan may be committing (cmd_dashboard # serves first, scans in a background thread; /api/rescan scans in-process too). @@ -128,15 +142,136 @@ def get_dashboard_data(db_path=DB_PATH): "cache_creation": r["total_cache_creation"] or 0, }) + # ── Subagent breakdown by type, by day & model ──────────────────────────── + # JOIN turns to agents (parent tool_result metadata captured by the scanner). + # acompact-* ids are Claude Code's auto-compaction subagent (no parent + # dispatch record); anything else without a match is shown as 'unknown'. + AGENT_TYPE_EXPR = ( + "COALESCE(a.agent_type, " + "CASE WHEN t.agent_id LIKE 'acompact-%' THEN 'auto-compact' " + "ELSE 'unknown' END)" + ) + + subagent_daily_rows = conn.execute(f""" + SELECT + substr(t.timestamp, 1, 10) as day, + {AGENT_TYPE_EXPR} as agent_type, + COALESCE(NULLIF(t.model, ''), 'unknown') as model, + SUM(t.input_tokens) as input, + SUM(t.output_tokens) as output, + SUM(t.cache_read_tokens) as cache_read, + SUM(t.cache_creation_tokens) as cache_creation, + COUNT(DISTINCT t.agent_id) as dispatches, + COUNT(*) as turns + FROM turns t + LEFT JOIN agents a ON t.agent_id = a.agent_id + WHERE t.is_subagent = 1 + GROUP BY day, agent_type, model + ORDER BY day, agent_type + """).fetchall() + + subagent_by_type = [{ + "day": r["day"], + "agent_type": r["agent_type"], + "model": r["model"], + "input": r["input"] or 0, + "output": r["output"] or 0, + "cache_read": r["cache_read"] or 0, + "cache_creation": r["cache_creation"] or 0, + "dispatches": r["dispatches"] or 0, + "turns": r["turns"] or 0, + } for r in subagent_daily_rows] + + # ── Top individual subagent dispatches (one row per agent_id) ───────────── + top_dispatch_rows = conn.execute(f""" + SELECT + t.agent_id as agent_id, + {AGENT_TYPE_EXPR} as agent_type, + COALESCE(NULLIF(t.model, ''), 'unknown') as model, + MIN(t.timestamp) as start_ts, + SUM(t.input_tokens) as input, + SUM(t.output_tokens) as output, + SUM(t.cache_read_tokens) as cache_read, + SUM(t.cache_creation_tokens) as cache_creation, + COUNT(*) as turns, + a.dispatched_in_session as parent_session, + a.total_duration_ms as duration_ms, + a.tool_use_count as tool_uses, + a.status as status + FROM turns t + LEFT JOIN agents a ON t.agent_id = a.agent_id + WHERE t.is_subagent = 1 AND t.agent_id IS NOT NULL + GROUP BY t.agent_id + ORDER BY (SUM(t.input_tokens) + SUM(t.output_tokens) + + SUM(t.cache_read_tokens) + SUM(t.cache_creation_tokens)) DESC + LIMIT 50 + """).fetchall() + + top_dispatches = [{ + "agent_id": r["agent_id"], + "agent_type": r["agent_type"], + "model": r["model"], + "start": (r["start_ts"] or "")[:16].replace("T", " "), + "start_date": (r["start_ts"] or "")[:10], + "input": r["input"] or 0, + "output": r["output"] or 0, + "cache_read": r["cache_read"] or 0, + "cache_creation": r["cache_creation"] or 0, + "turns": r["turns"] or 0, + "duration_ms": r["duration_ms"], + "tool_uses": r["tool_uses"], + "status": r["status"], + } for r in top_dispatch_rows] + + # Per-source daily from ccusage (other agent CLIs). Excludes the unified + # 'ccusage-all' so Claude Code (counted natively) is never double-counted. + try: + cda_rows = conn.execute(""" + SELECT day, source, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_tokens, cost_usd + FROM ccusage_daily_cache + WHERE source != 'ccusage-all' + ORDER BY day + """).fetchall() + ccusage_daily = [{ + "day": r["day"], + "source": (r["source"] or "").replace("ccusage-", ""), + "input": r["input_tokens"] or 0, + "output": r["output_tokens"] or 0, + "cache_read": r["cache_read_tokens"] or 0, + "cache_creation": r["cache_creation_tokens"] or 0, + "total": r["total_tokens"] or 0, + "cost": r["cost_usd"] or 0, + } for r in cda_rows] + except sqlite3.OperationalError: + ccusage_daily = [] + + # Optional ccusage billing-window summary (5h windows + P90 baseline). + # Guarded so a bridge issue can never take down the dashboard. + try: + from ccusage_bridge import summarize_billing + billing = summarize_billing(conn) + except Exception: + billing = {"available": False} + conn.close() - return { + result = { "all_models": all_models, "daily_by_model": daily_by_model, "hourly_by_model": hourly_by_model, "sessions_all": sessions_all, + "subagent_by_type": subagent_by_type, + "top_dispatches": top_dispatches, + "billing": billing, + "ccusage_daily": ccusage_daily, + "pricing": PRICING, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } + if cache_key is not None: + _DATA_CACHE["key"] = cache_key + _DATA_CACHE["data"] = result + return result HTML_TEMPLATE = r""" @@ -270,6 +405,14 @@ def get_dashboard_data(db_path=DB_PATH): .export-btn { background: var(--card); border: 1px solid var(--border); color: var(--muted); padding: 3px 10px; border-radius: 5px; cursor: pointer; font-size: 11px; } .export-btn:hover { color: var(--text); border-color: var(--accent); } .table-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 24px; overflow-x: auto; } + .bw-track { height: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; margin: 10px 0 16px; } + .bw-fill { height: 100%; border-radius: 7px; transition: width .3s ease; } + .bw-metrics { display: flex; flex-wrap: wrap; gap: 28px; } + .bw-metric { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } + .bw-metric strong { display: block; font-size: 18px; color: var(--text); font-weight: 700; margin-top: 4px; letter-spacing: 0; text-transform: none; } + .bw-prompt { color: var(--muted); font-size: 13px; line-height: 1.6; } + .bw-prompt a { color: var(--blue); text-decoration: none; } + .bw-prompt a:hover { text-decoration: underline; } .table-foot { display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-top: 12px; } .table-foot:empty { margin-top: 0; } .show-more-btn { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; } @@ -329,6 +472,10 @@ def get_dashboard_data(db_path=DB_PATH):
+

Daily Token Usage

@@ -356,6 +503,24 @@ def get_dashboard_data(db_path=DB_PATH):

Top Projects by Tokens

+
+

Subagent Tokens by Type

+
+
+ +
+
+
Top Subagent Dispatches · ranked by total tokens; unknown = parent dispatch record not found
+ + + + + + +
TypeStartedModelTurnsTool UsesDurationInputOutputCache ReadTokensEst. Cost
Cost by Model
@@ -427,6 +592,7 @@ def get_dashboard_data(db_path=DB_PATH):