diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e71374..52e367a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add session name support: the dashboard now shows the human-readable name set via Claude Code's `/rename` (from `customTitle` / `agentName` JSONL records), alongside the session ID, and exports it to CSV (#56) + ## 2026-04-09 - Fix token counts inflated ~2x by deduplicating streaming events that share the same message ID diff --git a/cli.py b/cli.py index 01cf362..4f111d8 100644 --- a/cli.py +++ b/cli.py @@ -14,37 +14,9 @@ from pathlib import Path from datetime import datetime, date, timedelta -DB_PATH = Path.home() / ".claude" / "usage.db" - -PRICING = { - "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}, -} +from pricing import PRICING, get_pricing -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 "opus" in m: - return PRICING["claude-opus-4-7"] - if "sonnet" in m: - return PRICING["claude-sonnet-4-6"] - if "haiku" in m: - return PRICING["claude-haiku-4-5"] - return None +DB_PATH = Path.home() / ".claude" / "usage.db" def calc_cost(model, inp, out, cache_read, cache_creation): p = get_pricing(model) diff --git a/cowork.py b/cowork.py new file mode 100644 index 0000000..023cef7 --- /dev/null +++ b/cowork.py @@ -0,0 +1,131 @@ +""" +cowork.py - Parses Claude Desktop Cowork audit logs. +""" + +import json +import os +import re +import sys +from pathlib import Path + + +def cowork_sessions_dir(): + """Return Claude Desktop's local-agent-mode-sessions directory.""" + if sys.platform == "darwin": + user_data = Path.home() / "Library" / "Application Support" / "Claude" + elif sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if not appdata: + return None + user_data = Path(appdata) / "Claude" + elif sys.platform.startswith("linux"): + config_home = os.environ.get("XDG_CONFIG_HOME") + user_data = Path(config_home) / "Claude" if config_home else Path.home() / ".config" / "Claude" + else: + return None + return user_data / "local-agent-mode-sessions" + + +def find_audit_files(base_dir=None): + """Return audit.jsonl files below the Cowork sessions directory.""" + base = Path(base_dir) if base_dir is not None else cowork_sessions_dir() + if base is None or not base.exists(): + return [] + return sorted(base.rglob("audit.jsonl")) + + +def is_audit_file(filepath): + """Return True when filepath looks like a Cowork audit log.""" + return Path(filepath).name == "audit.jsonl" + + +def normalize_model_name(model): + """Strip Cowork tier hints like [1m] so pricing lookup still matches.""" + return re.sub(r"\[[^\]]+\]$", "", model or "") + + +def _session_meta(session_id, timestamp): + short_id = session_id[:8] if session_id else "unknown" + return { + "session_id": session_id, + "project_name": f"Cowork/{short_id}", + "first_timestamp": timestamp, + "last_timestamp": timestamp, + "git_branch": "", + "model": None, + "custom_title": None, + "agent_name": None, + } + + +def parse_audit_file(filepath): + """Parse a Cowork audit.jsonl file. + + Returns (session_metas, turns, line_count), matching scanner.parse_jsonl_file. + Cowork result events contain cumulative authoritative modelUsage totals, so + the last result event per session is used. + """ + latest_results = {} + line_count = 0 + + try: + with open(filepath, encoding="utf-8", errors="replace") as f: + for line_count, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if record.get("type") != "result": + continue + session_id = record.get("session_id") + model_usage = record.get("modelUsage") + if not session_id or not isinstance(model_usage, dict): + continue + latest_results[session_id] = record + except Exception as e: + print(f" Warning: error reading {filepath}: {e}") + + session_metas = [] + turns = [] + for session_id, record in latest_results.items(): + timestamp = record.get("_audit_timestamp") or record.get("timestamp") or "" + session_metas.append(_session_meta(session_id, timestamp)) + + totals_by_model = {} + for raw_model, usage in record.get("modelUsage", {}).items(): + if not isinstance(usage, dict): + continue + model = normalize_model_name(raw_model) + if not model: + continue + totals = totals_by_model.setdefault(model, { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + }) + totals["input_tokens"] += usage.get("inputTokens", 0) or 0 + totals["output_tokens"] += usage.get("outputTokens", 0) or 0 + totals["cache_read_tokens"] += usage.get("cacheReadInputTokens", 0) or 0 + totals["cache_creation_tokens"] += usage.get("cacheCreationInputTokens", 0) or 0 + + for model, totals in totals_by_model.items(): + if sum(totals.values()) == 0: + continue + turns.append({ + "session_id": session_id, + "timestamp": timestamp, + "model": model, + "input_tokens": totals["input_tokens"], + "output_tokens": totals["output_tokens"], + "cache_read_tokens": totals["cache_read_tokens"], + "cache_creation_tokens": totals["cache_creation_tokens"], + "tool_name": None, + "cwd": "", + "message_id": f"cowork:{session_id}:{model}", + }) + + return session_metas, turns, line_count diff --git a/dashboard.py b/dashboard.py index ebf8d5f..c846c45 100644 --- a/dashboard.py +++ b/dashboard.py @@ -5,11 +5,15 @@ import json import os import sqlite3 -from http.server import HTTPServer, BaseHTTPRequestHandler +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from datetime import datetime +from pricing import PRICING + DB_PATH = Path.home() / ".claude" / "usage.db" +PRICING_JSON_PLACEHOLDER = "/*__PRICING_JSON__*/" def get_dashboard_data(db_path=DB_PATH): @@ -21,7 +25,7 @@ def get_dashboard_data(db_path=DB_PATH): # ── All models (for filter UI) ──────────────────────────────────────────── model_rows = conn.execute(""" - SELECT COALESCE(model, 'unknown') as model + SELECT COALESCE(NULLIF(model, ''), 'unknown') as model FROM turns GROUP BY model ORDER BY SUM(input_tokens + output_tokens) DESC @@ -31,8 +35,8 @@ def get_dashboard_data(db_path=DB_PATH): # ── Daily per-model, ALL history (client filters by range) ──────────────── daily_rows = conn.execute(""" SELECT - substr(timestamp, 1, 10) as day, - COALESCE(model, 'unknown') as model, + substr(timestamp, 1, 10) as day, + COALESCE(NULLIF(model, ''), 'unknown') as model, SUM(input_tokens) as input, SUM(output_tokens) as output, SUM(cache_read_tokens) as cache_read, @@ -59,7 +63,7 @@ def get_dashboard_data(db_path=DB_PATH): SELECT substr(timestamp, 1, 10) as day, CAST(substr(timestamp, 12, 2) AS INTEGER) as hour, - COALESCE(model, 'unknown') as model, + COALESCE(NULLIF(model, ''), 'unknown') as model, SUM(output_tokens) as output, COUNT(*) as turns FROM turns @@ -77,15 +81,28 @@ def get_dashboard_data(db_path=DB_PATH): } for r in hourly_rows] # ── All sessions (client filters by range and model) ────────────────────── - session_rows = conn.execute(""" - SELECT - session_id, project_name, first_timestamp, last_timestamp, - total_input_tokens, total_output_tokens, - total_cache_read, total_cache_creation, model, turn_count, - git_branch - FROM sessions - ORDER BY last_timestamp DESC - """).fetchall() + # session_name may be missing on older DB schemas; fall back gracefully. + try: + session_rows = conn.execute(""" + SELECT + session_id, project_name, first_timestamp, last_timestamp, + total_input_tokens, total_output_tokens, + total_cache_read, total_cache_creation, model, turn_count, + git_branch, session_name + FROM sessions + ORDER BY last_timestamp DESC + """).fetchall() + except sqlite3.OperationalError: + # Pre-migration DB: synthesise session_name=None + session_rows = conn.execute(""" + SELECT + session_id, project_name, first_timestamp, last_timestamp, + total_input_tokens, total_output_tokens, + total_cache_read, total_cache_creation, model, turn_count, + git_branch, NULL AS session_name + FROM sessions + ORDER BY last_timestamp DESC + """).fetchall() sessions_all = [] for r in session_rows: @@ -97,6 +114,7 @@ def get_dashboard_data(db_path=DB_PATH): duration_min = 0 sessions_all.append({ "session_id": r["session_id"][:8], + "session_name": r["session_name"] or "", "project": r["project_name"] or "unknown", "branch": r["git_branch"] or "", "last": (r["last_timestamp"] or "")[:16].replace("T", " "), @@ -187,9 +205,6 @@ def get_dashboard_data(db_path=DB_PATH): .tz-btn:last-child { border-right: none; } .tz-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); } .tz-btn.active { background: rgba(217,119,87,0.15); color: var(--accent); } - .peak-legend { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); } - .peak-swatch { width: 10px; height: 10px; background: rgba(248,113,113,0.8); border-radius: 2px; display: inline-block; } - table { width: 100%; border-collapse: collapse; } th { text-align: left; padding: 8px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; } th.sortable { cursor: pointer; user-select: none; } @@ -199,6 +214,7 @@ def get_dashboard_data(db_path=DB_PATH): tr:last-child td { border-bottom: none; } tr:hover td { background: rgba(255,255,255,0.02); } .model-tag { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 11px; background: rgba(79,142,247,0.15); color: var(--blue); } + .session-name { color: var(--text); font-weight: 600; } .cost { color: var(--green); font-family: monospace; } .cost-na { color: var(--muted); font-family: monospace; font-size: 11px; } .num { font-family: monospace; } @@ -256,7 +272,6 @@ def get_dashboard_data(db_path=DB_PATH):